diff --git a/.gitattributes b/.gitattributes index c70f4d0bcee0a..4c25c056bd7e3 100644 --- a/.gitattributes +++ b/.gitattributes @@ -18,6 +18,7 @@ /DEPENDENCIES.md text eol=lf /DEPENDENCIES.json text eol=lf /AUTHORS text eol=lf +/docs/lib/content/nav.yml text eol=lf # fixture tarballs should be treated as binary /workspaces/*/test/fixtures/**/*.tgz binary diff --git a/docs/lib/build.js b/docs/lib/build.js index 86f8acac102f1..9c25513010ff8 100644 --- a/docs/lib/build.js +++ b/docs/lib/build.js @@ -7,6 +7,281 @@ const parseFrontMatter = require('front-matter') const checkNav = require('./check-nav.js') const { DOC_EXT, ...transform } = require('./index.js') +// Helper to check if a directory exists +const dirExists = async (path) => { + try { + const stat = await fs.stat(path) + return stat.isDirectory() + } catch { + return false + } +} + +// Helper to read docs from a section directory +const readSectionDocs = async (contentPath, section, orderedUrls) => { + const sectionPath = join(contentPath, section) + if (!await dirExists(sectionPath)) { + return [] + } + + const files = await fs.readdir(sectionPath) + const docFiles = files.filter(f => f.endsWith(DOC_EXT)) + + // If no doc files exist, return empty array + /* istanbul ignore if - defensive check for empty directories */ + if (docFiles.length === 0) { + return [] + } + + // Parse each doc file to get title and description from frontmatter + const docs = await Promise.all( + docFiles.map(async (file) => { + const content = await fs.readFile(join(sectionPath, file), 'utf-8') + const { attributes } = parseFrontMatter(content) + const name = basename(file, DOC_EXT) + + return { + title: attributes.title, + url: `/${section}/${name}`, + description: attributes.description, + name, + } + }) + ) + + // Preserve order from orderedUrls, append any new files at the end sorted alphabetically + const orderedDocs = [] + const docsByUrl = new Map(docs.map(d => [d.url, d])) + + // First, add docs in the order they appear in orderedUrls + for (const url of orderedUrls) { + const doc = docsByUrl.get(url) + if (doc) { + orderedDocs.push(doc) + docsByUrl.delete(url) + } + } + + return orderedDocs.map(({ name, ...rest }) => rest) +} + +// Generate nav.yml from the filesystem +const generateNav = async (contentPath, navPath) => { + const docsCommandsPath = join(contentPath, 'commands') + + // Read all command files + const commandFiles = await dirExists(docsCommandsPath) ? await fs.readdir(docsCommandsPath) : [] + const commandDocs = commandFiles.filter(f => f.endsWith(DOC_EXT)) + + // Parse each command file to get title and description + const allCommands = await Promise.all( + commandDocs.map(async (file) => { + const content = await fs.readFile(join(docsCommandsPath, file), 'utf-8') + const { attributes } = parseFrontMatter(content) + const name = basename(file, DOC_EXT) + const title = (attributes.title || name).replace(/^npm-/, 'npm ') + + return { + title, + url: `/commands/${name}`, + description: attributes.description || '', + name, + } + }) + ) + + // Sort commands: npm first, then alphabetically, npx last + const npm = allCommands.find(c => c.name === 'npm') + const npx = allCommands.find(c => c.name === 'npx') + const others = allCommands + .filter(c => c.name !== 'npm' && c.name !== 'npx') + .sort((a, b) => a.name.localeCompare(b.name)) + + // Remove the name field + const commands = [npm, ...others, npx].filter(Boolean).map(({ name, ...rest }) => rest) + + // Hardcoded order for configuring-npm section (only urls - title/description come from frontmatter) + const configuringNpmOrder = [ + '/configuring-npm/install', + '/configuring-npm/folders', + '/configuring-npm/npmrc', + '/configuring-npm/npm-shrinkwrap-json', + '/configuring-npm/package-json', + '/configuring-npm/package-lock-json', + ] + + // Hardcoded order for using-npm section (only urls - title/description come from frontmatter) + const usingNpmOrder = [ + '/using-npm/registry', + '/using-npm/package-spec', + '/using-npm/config', + '/using-npm/logging', + '/using-npm/scope', + '/using-npm/scripts', + '/using-npm/workspaces', + '/using-npm/orgs', + '/using-npm/dependency-selectors', + '/using-npm/developers', + '/using-npm/removal', + ] + + // Read actual docs from configuring-npm and using-npm directories + const configuringNpmDocs = await readSectionDocs(contentPath, 'configuring-npm', configuringNpmOrder) + const usingNpmDocs = await readSectionDocs(contentPath, 'using-npm', usingNpmOrder) + + // Build the navigation structure - only include sections with content + const navData = [] + + if (commands.length > 0) { + navData.push({ + title: 'CLI Commands', + shortName: 'Commands', + url: '/commands', + children: commands, + }) + } + + if (configuringNpmDocs.length > 0) { + navData.push({ + title: 'Configuring npm', + shortName: 'Configuring', + url: '/configuring-npm', + children: configuringNpmDocs, + }) + } + + if (usingNpmDocs.length > 0) { + navData.push({ + title: 'Using npm', + shortName: 'Using', + url: '/using-npm', + children: usingNpmDocs, + }) + } + + 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${yaml.stringify(navData, { indent: 2, indentSeq: false })}`, 'utf-8') +} + +// 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 commands from commandsPath directory + let commands + try { + const cmdListPath = join(commandsPath, '..', 'utils', 'cmd-list.js') + const cmdList = require(cmdListPath) + commands = cmdList.commands + } catch { + // Fall back to reading command files from commandsPath + const cmdFiles = await fs.readdir(commandsPath) + commands = cmdFiles + .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 + const newEntries = [] + 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') + + // Track new entry for nav update + newEntries.push({ + title: `npm ${cmd}`, + url: `/commands/npm-${cmd}`, + description, + }) + } + + // Update nav.yml if there are new entries + if (newEntries.length > 0) { + const navContent = await fs.readFile(navPath, 'utf-8') + const navData = yaml.parse(navContent) + + // Find CLI Commands section + let commandsSection = navData.find(s => s.title === 'CLI Commands') + if (!commandsSection) { + // Create CLI Commands section if it doesn't exist + commandsSection = { + title: 'CLI Commands', + shortName: 'Commands', + url: '/commands', + children: [], + } + navData.unshift(commandsSection) + } + + if (!commandsSection.children) { + commandsSection.children = [] + } + + // Add new entries that don't already exist + for (const entry of newEntries) { + const exists = commandsSection.children.some(c => c.url === entry.url) + if (!exists) { + commandsSection.children.push(entry) + } + } + + // Sort children: npm first, then alphabetically, npx last + const npm = commandsSection.children.find(c => c.title === 'npm') + const npx = commandsSection.children.find(c => c.title === 'npx') + const others = commandsSection.children + .filter(c => c.title !== 'npm' && c.title !== 'npx') + .sort((a, b) => a.title.localeCompare(b.title)) + + commandsSection.children = [npm, ...others, npx].filter(Boolean) + + // Write updated nav + 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${yaml.stringify(navData, { indent: 2, indentSeq: false })}`, '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 +303,18 @@ const pAll = async (obj) => { }, {}) } -const run = async ({ content, template, nav, man, html, md }) => { +const run = async (opts) => { + const { content, template, nav, man, html, md, skipAutoGenerate, skipGenerateNav } = opts + // Auto-generate docs for commands without documentation + if (!skipAutoGenerate) { + await autoGenerateMissingDocs(content, nav) + } + + // Generate nav.yml from filesystem + if (!skipGenerateNav) { + await generateNav(content, nav) + } + await rmAll(man, html, md) const [contentPaths, navFile, options] = await Promise.all([ readDocs(content), @@ -145,3 +431,5 @@ const run = async ({ content, template, nav, man, html, md }) => { } module.exports = run +module.exports.generateNav = generateNav +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/commands/npm-trust.md b/docs/lib/content/commands/npm-trust.md new file mode 100644 index 0000000000000..0a295c964ef9a --- /dev/null +++ b/docs/lib/content/commands/npm-trust.md @@ -0,0 +1,21 @@ +--- +title: npm-trust +section: 1 +description: Create a trusted relationship between a package and a OIDC provider +--- + +### Synopsis + + + +### Description + +Create a trusted relationship between a package and a OIDC provider + +### Configuration + + + +### See Also + +* [npm help config](/commands/npm-config) diff --git a/docs/lib/content/configuring-npm/folders.md b/docs/lib/content/configuring-npm/folders.md index 56459c86930ba..20458512d8b90 100644 --- a/docs/lib/content/configuring-npm/folders.md +++ b/docs/lib/content/configuring-npm/folders.md @@ -1,7 +1,7 @@ --- -title: folders +title: Folders section: 5 -description: Folder Structures Used by npm +description: Folder structures used by npm --- ### Description diff --git a/docs/lib/content/configuring-npm/install.md b/docs/lib/content/configuring-npm/install.md index 1d7e7b80e6c22..4af74954692f8 100644 --- a/docs/lib/content/configuring-npm/install.md +++ b/docs/lib/content/configuring-npm/install.md @@ -1,5 +1,5 @@ --- -title: install +title: Install section: 5 description: Download and install node and npm --- diff --git a/docs/lib/content/configuring-npm/npmrc.md b/docs/lib/content/configuring-npm/npmrc.md index 41d7c5e462c51..ee80908341aba 100644 --- a/docs/lib/content/configuring-npm/npmrc.md +++ b/docs/lib/content/configuring-npm/npmrc.md @@ -1,5 +1,5 @@ --- -title: npmrc +title: .npmrc section: 5 description: The npm config files --- diff --git a/docs/lib/content/nav.yml b/docs/lib/content/nav.yml index f6f8014f28071..0681b7b77e1b8 100644 --- a/docs/lib/content/nav.yml +++ b/docs/lib/content/nav.yml @@ -8,7 +8,7 @@ children: - title: npm url: /commands/npm - description: JavaScript package manager + description: javascript package manager - title: npm access url: /commands/npm-access description: Set access level on published packages @@ -20,22 +20,22 @@ description: Run a security audit - title: npm bugs url: /commands/npm-bugs - description: Bugs for a package in a web browser maybe + description: Report bugs for a package in a web browser - 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 + description: Clean install a project - title: npm completion url: /commands/npm-completion - description: Tab completion for npm + 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 + description: Reduce duplication in the package tree - title: npm deprecate url: /commands/npm-deprecate description: Deprecate a version of a package @@ -47,16 +47,16 @@ description: Modify package distribution tags - title: npm docs url: /commands/npm-docs - description: Docs for a package in a web browser maybe + description: Open documentation for a package in a web browser - title: npm doctor url: /commands/npm-doctor - description: Check your environments + description: Check the health of your npm environment - 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 + description: Run a command from a local or remote npm package - title: npm explain url: /commands/npm-explain description: Explain installed packages @@ -69,12 +69,15 @@ - 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 + description: Get help on npm - title: npm help-search url: /commands/npm-help-search - description: Get help on npm + description: Search npm help documentation - title: npm init url: /commands/npm-init description: Create a package.json file @@ -90,6 +93,9 @@ - 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 @@ -131,7 +137,7 @@ description: Publish a package - title: npm query url: /commands/npm-query - description: Retrieve a filtered list of packages + description: Dependency selector query - title: npm rebuild url: /commands/npm-rebuild description: Rebuild a package @@ -153,6 +159,9 @@ - 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 @@ -177,6 +186,9 @@ - title: npm token url: /commands/npm-token description: Manage your authentication tokens + - title: npm trust + url: /commands/npm-trust + description: Create a trusted relationship between a package and a OIDC provider - title: npm undeprecate url: /commands/npm-undeprecate description: Undeprecate a version of a package @@ -191,7 +203,7 @@ description: Remove an item from your favorite packages - title: npm update url: /commands/npm-update - description: Update a package + description: Update packages - title: npm version url: /commands/npm-version description: Bump a package version @@ -203,8 +215,7 @@ description: Display npm username - title: npx url: /commands/npx - description: Run a command from an npm package - + description: Run a command from a local or remote npm package - title: Configuring npm shortName: Configuring url: /configuring-npm @@ -227,7 +238,6 @@ - title: package-lock.json url: /configuring-npm/package-lock-json description: A manifestation of the manifest - - title: Using npm shortName: Using url: /using-npm diff --git a/docs/lib/content/using-npm/config.md b/docs/lib/content/using-npm/config.md index 252bbcf3a27e2..7f788375bae6c 100644 --- a/docs/lib/content/using-npm/config.md +++ b/docs/lib/content/using-npm/config.md @@ -1,7 +1,7 @@ --- -title: config +title: Config section: 7 -description: More than you probably want to know about npm configuration +description: About npm configuration --- ### Description diff --git a/docs/lib/content/using-npm/dependency-selectors.md b/docs/lib/content/using-npm/dependency-selectors.md index 9a1502e9349da..b12c640c586ec 100644 --- a/docs/lib/content/using-npm/dependency-selectors.md +++ b/docs/lib/content/using-npm/dependency-selectors.md @@ -1,5 +1,5 @@ --- -title: Dependency Selector Syntax & Querying +title: Dependency Selectors section: 7 description: Dependency Selector Syntax & Querying --- diff --git a/docs/lib/content/using-npm/developers.md b/docs/lib/content/using-npm/developers.md index 0261d137b36b7..de0cb848c59ff 100644 --- a/docs/lib/content/using-npm/developers.md +++ b/docs/lib/content/using-npm/developers.md @@ -1,7 +1,7 @@ --- -title: developers +title: Developers section: 7 -description: Developer Guide +description: Developer guide --- ### Description diff --git a/docs/lib/content/using-npm/logging.md b/docs/lib/content/using-npm/logging.md index d5fca42f595c2..6f1a2be102a1a 100644 --- a/docs/lib/content/using-npm/logging.md +++ b/docs/lib/content/using-npm/logging.md @@ -1,7 +1,7 @@ --- title: Logging section: 7 -description: Why, What & How We Log +description: Why, What & How we Log --- ### Description diff --git a/docs/lib/content/using-npm/orgs.md b/docs/lib/content/using-npm/orgs.md index 8faf939d0b5e8..ea1173a852acc 100644 --- a/docs/lib/content/using-npm/orgs.md +++ b/docs/lib/content/using-npm/orgs.md @@ -1,7 +1,7 @@ --- -title: orgs +title: Organizations section: 7 -description: Working with Teams & Orgs +description: Working with teams & organizations --- ### Description diff --git a/docs/lib/content/using-npm/package-spec.md b/docs/lib/content/using-npm/package-spec.md index d5c319fa43c9c..7318ca29a4899 100644 --- a/docs/lib/content/using-npm/package-spec.md +++ b/docs/lib/content/using-npm/package-spec.md @@ -1,5 +1,5 @@ --- -title: package-spec +title: Package spec section: 7 description: Package name specifier --- diff --git a/docs/lib/content/using-npm/registry.md b/docs/lib/content/using-npm/registry.md index a707b97ac5a9b..739f2a6a203f5 100644 --- a/docs/lib/content/using-npm/registry.md +++ b/docs/lib/content/using-npm/registry.md @@ -1,5 +1,5 @@ --- -title: registry +title: Registry section: 7 description: The JavaScript Package Registry --- diff --git a/docs/lib/content/using-npm/removal.md b/docs/lib/content/using-npm/removal.md index 9b431aaf7f38a..4cf3b64c6d4cf 100644 --- a/docs/lib/content/using-npm/removal.md +++ b/docs/lib/content/using-npm/removal.md @@ -1,7 +1,7 @@ --- -title: removal +title: Removal section: 7 -description: Cleaning the Slate +description: Cleaning the slate --- ### Synopsis diff --git a/docs/lib/content/using-npm/scope.md b/docs/lib/content/using-npm/scope.md index ed069752b63ad..f9fc14075c4a3 100644 --- a/docs/lib/content/using-npm/scope.md +++ b/docs/lib/content/using-npm/scope.md @@ -1,5 +1,5 @@ --- -title: scope +title: Scope section: 7 description: Scoped packages --- diff --git a/docs/lib/content/using-npm/scripts.md b/docs/lib/content/using-npm/scripts.md index 1613d803ee3e9..e8c4dcb17e39c 100644 --- a/docs/lib/content/using-npm/scripts.md +++ b/docs/lib/content/using-npm/scripts.md @@ -1,5 +1,5 @@ --- -title: scripts +title: Scripts section: 7 description: How npm handles the "scripts" field --- diff --git a/docs/lib/content/using-npm/workspaces.md b/docs/lib/content/using-npm/workspaces.md index 91d0f99745a25..57344341be76c 100644 --- a/docs/lib/content/using-npm/workspaces.md +++ b/docs/lib/content/using-npm/workspaces.md @@ -1,5 +1,5 @@ --- -title: workspaces +title: Workspaces section: 7 description: Working with workspaces --- diff --git a/docs/lib/index.js b/docs/lib/index.js index 5e40f48882cad..840e4c91a0074 100644 --- a/docs/lib/index.js +++ b/docs/lib/index.js @@ -22,7 +22,37 @@ const assertPlaceholder = (src, path, placeholder) => { return placeholder } -const getCommandByDoc = (docFile, docExt) => { +// Default command loader - loads commands from lib/commands +const defaultCommandLoader = (name) => { + return require(`../../lib/commands/${name}`) +} + +// Registry of custom commands for testing +let commandRegistry = {} + +// Command loader that checks registry first, then falls back to default +const getCommand = (name, commandLoader = defaultCommandLoader) => { + if (commandRegistry[name]) { + return commandRegistry[name] + } + return commandLoader(name) +} + +// Functions to manage the command registry for testing +const registerCommand = (name, command) => { + commandRegistry[name] = command +} + +/* istanbul ignore next - testing utility for cleanup */ +const unregisterCommand = (name) => { + delete commandRegistry[name] +} + +const clearCommandRegistry = () => { + commandRegistry = {} +} + +const getCommandByDoc = (docFile, docExt, commandLoader = defaultCommandLoader) => { // Grab the command name from the *.md filename // NOTE: We cannot use the name property command file because in the case of // `npx` the file being used is `lib/commands/exec.js` @@ -40,12 +70,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 = getCommand(srcName, commandLoader) + 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) } @@ -64,9 +99,9 @@ const getCommandByDoc = (docFile, docExt) => { const replaceVersion = (src) => src.replace(/@VERSION@/g, version) -const replaceUsage = (src, { path }) => { +const replaceUsage = (src, { path }, commandLoader) => { const replacer = assertPlaceholder(src, path, TAGS.USAGE) - const { usage, name, workspaces } = getCommandByDoc(path, DOC_EXT) + const { usage, name, workspaces } = getCommandByDoc(path, DOC_EXT, commandLoader) const synopsis = ['```bash', usage] @@ -92,17 +127,162 @@ const replaceUsage = (src, { path }) => { return src.replace(replacer, synopsis.join('\n')) } -const replaceParams = (src, { path }) => { - const { params } = getCommandByDoc(path, DOC_EXT) - const replacer = params && assertPlaceholder(src, path, TAGS.CONFIG) +const replaceParams = (src, { path }, commandLoader) => { + const { params, name } = getCommandByDoc(path, DOC_EXT, commandLoader) + + // Load command to get command-specific definitions and subcommands if they exist + let commandDefinitions = {} + let subcommands = {} + try { + const command = getCommand(name, commandLoader) + commandDefinitions = command.definitions || {} + subcommands = command.subcommands || {} + } catch { + // If command doesn't exist or has no definitions, continue with global definitions only + } + + // If no params and no subcommands, nothing to replace + if (!params && Object.keys(subcommands).length === 0) { + return src + } + + // Assert placeholder is present - commands with params must have the config placeholder + const replacer = assertPlaceholder(src, path, TAGS.CONFIG) + + // If command has subcommands, generate sections for each subcommand + if (Object.keys(subcommands).length > 0) { + const subcommandSections = Object.entries(subcommands).map(([subName, SubCommand]) => { + const subUsage = SubCommand.usage || [] + const subDefinitions = SubCommand.definitions || {} + // If params not defined, extract from definitions + const subParams = SubCommand.params || Object.keys(subDefinitions) + + const parts = [`### \`npm ${name} ${subName}\``, ''] + + if (SubCommand.description) { + parts.push(SubCommand.description, '') + } + + // Add usage/synopsis + if (subUsage.length > 0) { + parts.push('#### Synopsis', '', '```bash') + subUsage.forEach(u => { + parts.push(`npm ${name} ${subName} ${u}`.trim()) + }) + parts.push('```', '') + } + + // Separate command-specific and global config params for this subcommand + const commandSpecificParams = [] + const globalConfigParams = [] + + for (const paramName of subParams) { + const isCommandSpecific = subDefinitions[paramName] && !definitions[paramName] + if (isCommandSpecific) { + commandSpecificParams.push(paramName) + } else { + globalConfigParams.push(paramName) + } + } + + // Add command-specific flags section if any exist + if (commandSpecificParams.length > 0) { + parts.push('#### Flags', '') + parts.push('These flags are specific to this subcommand and are not part of npm\'s global configuration or `.npmrc` files.', '') + + commandSpecificParams.forEach((n) => { + const def = subDefinitions[n] + const shortcuts = def.short ? `\n* Shortcut: \`-${def.short}\`` : '' + const defAliases = def.alias || [] + const aliasText = defAliases.length > 0 + ? `\n* Aliases: ${defAliases.map(a => `\`--${a}\``).join(', ')}` + : '' + parts.push(`${def.describe()}${shortcuts}${aliasText}`, '') + }) + } + + // Add global config section if any exist + if (globalConfigParams.length > 0) { + if (commandSpecificParams.length === 0) { + parts.push('#### Configuration', '') + } + globalConfigParams.forEach((n) => { + const def = subDefinitions[n] || definitions[n] + const shortcuts = def.short ? `\n* Shortcut: \`-${def.short}\`` : '' + const defAliases = def.alias || [] + const aliasText = defAliases.length > 0 + ? `\n* Aliases: ${defAliases.map(a => `\`--${a}\``).join(', ')}` + : '' + parts.push(`${def.describe()}${shortcuts}${aliasText}`, '') + }) + } + + return parts.join('\n') + }) + return src.replace(replacer, subcommandSections.join('\n')) + } + + // Original behavior for commands without subcommands but with params + /* istanbul ignore if - all commands with no subcommands have params */ if (!params) { return src } - const paramsConfig = params.map((n) => definitions[n].describe()) + // Separate command-specific and global config params + const commandSpecificParams = [] + const globalConfigParams = [] - return src.replace(replacer, paramsConfig.join('\n\n')) + for (const paramName of params) { + const isCommandSpecific = commandDefinitions[paramName] && !definitions[paramName] + /* istanbul ignore if - no current non-subcommand commands have command-specific definitions */ + if (isCommandSpecific) { + commandSpecificParams.push(paramName) + } else { + globalConfigParams.push(paramName) + } + } + + const sections = [] + + // Add command-specific flags section if any exist + /* istanbul ignore if - no current non-subcommand commands have command-specific definitions */ + if (commandSpecificParams.length > 0) { + const commandSpecificConfig = commandSpecificParams.map((n) => { + const def = commandDefinitions[n] + const shortcuts = def.short ? `\n* Shortcut: \`-${def.short}\`` : '' + const defAliases = def.alias || [] + const aliasText = defAliases.length > 0 + ? `\n* Aliases: ${defAliases.map(a => `\`--${a}\``).join(', ')}` + : '' + return `${def.describe()}${shortcuts}${aliasText}` + }) + + sections.push( + '#### Command-Specific Flags', + '', + 'These flags are specific to this command and are not part of npm\'s global configuration or `.npmrc` files.', + '', + commandSpecificConfig.join('\n\n') + ) + } + + // Add global config section if any exist + if (globalConfigParams.length > 0) { + const globalConfig = globalConfigParams.map((n) => { + const def = commandDefinitions[n] || definitions[n] + const shortcuts = def.short ? `\n* Shortcut: \`-${def.short}\`` : '' + return `${def.describe()}${shortcuts}` + }) + + /* istanbul ignore if - no current non-subcommand commands have command-specific definitions */ + if (commandSpecificParams.length > 0) { + sections.push('', '#### Configuration', '') + } + sections.push(globalConfig.join('\n\n')) + } + + return src.replace(replacer, sections.join('\n')) } const replaceConfig = (src, { path }) => { @@ -186,4 +366,11 @@ module.exports = { manPath: manPath, md: transformMd, html: transformHTML, + // Testing utilities for command injection + testing: { + registerCommand, + unregisterCommand, + clearCommandRegistry, + getCommandByDoc, + }, } diff --git a/docs/test/index.js b/docs/test/index.js index be537e68b2a18..699484c7cd818 100644 --- a/docs/test/index.js +++ b/docs/test/index.js @@ -1,7 +1,30 @@ const t = require('tap') const { join } = require('path') const walk = require('ignore-walk') -const { paths: { content: CONTENT_DIR, nav: NAV, template: TEMPLATE } } = require('../lib/index.js') +const fs = require('fs/promises') +const yaml = require('yaml') +const { + paths: { content: CONTENT_DIR, nav: NAV, template: TEMPLATE }, + testing: { registerCommand, clearCommandRegistry }, +} = require('../lib/index.js') + +// Helper to generate nav entries from content structure +const generateNavFromContent = (content, prefix = '') => { + const entries = [] + for (const [key, value] of Object.entries(content)) { + if (key.endsWith('.md')) { + const name = key.replace('.md', '') + const url = prefix ? `${prefix}/${name}` : `/${name}` + entries.push({ url }) + } else if (typeof value === 'object') { + const children = generateNavFromContent(value, `/${key}`) + if (children.length > 0) { + entries.push(...children) + } + } + } + return entries +} const testBuildDocs = async (t, { verify, ...opts } = {}) => { const mockedBuild = require('../lib/build.js') @@ -13,6 +36,17 @@ const testBuildDocs = async (t, { verify, ...opts } = {}) => { ...opts, } + // Ensure commands directory exists if content is provided + if (fixtures.content && !fixtures.content.commands) { + fixtures.content.commands = {} + } + + // If custom content is provided but not custom nav, auto-generate nav from content + if (fixtures.content && !fixtures.nav) { + const navEntries = generateNavFromContent(fixtures.content) + fixtures.nav = yaml.stringify(navEntries) + } + const root = t.testdir(fixtures) const paths = { @@ -22,6 +56,10 @@ 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, + // Skip nav generation when using test fixtures with custom content + skipGenerateNav: !!fixtures.content, } return { @@ -31,6 +69,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 +224,954 @@ 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('generateNav', async t => { + const { generateNav } = require('../lib/build.js') + + t.test('commands directory does not exist', async t => { + // Tests line 63: await dirExists(docsCommandsPath) ? await fs.readdir(docsCommandsPath) : [] + // When commands directory doesn't exist, should return empty array + const testDir = t.testdir({ + content: { + // No commands directory + 'configuring-npm': { + 'install.md': `--- +title: Install +section: 5 +description: Download and install node and npm +---`, + }, + }, + 'nav.yml': '', + }) + + const contentPath = join(testDir, 'content') + const navPath = join(testDir, 'nav.yml') + + await generateNav(contentPath, navPath) + + const navContent = await fs.readFile(navPath, 'utf-8') + const navData = yaml.parse(navContent) + + // Should NOT have CLI Commands section since commands dir doesn't exist + const commandsSection = navData.find(s => s.title === 'CLI Commands') + t.notOk(commandsSection, 'no CLI Commands section when commands directory is missing') + + // Should still have configuring-npm section + const configuringSection = navData.find(s => s.title === 'Configuring npm') + t.ok(configuringSection, 'has configuring-npm section') + }) + + t.test('command title fallback to name', async t => { + // Tests line 72: (attributes.title || name).replace(/^npm-/, 'npm ') + // When command doc has no title, should use filename as title + const testDir = t.testdir({ + content: { + commands: { + // Command doc WITHOUT title in frontmatter - should use name + 'npm-test-cmd.md': `--- +section: 1 +description: A test command +--- + +Content here`, + }, + }, + 'nav.yml': '', + }) + + const contentPath = join(testDir, 'content') + const navPath = join(testDir, 'nav.yml') + + await generateNav(contentPath, navPath) + + const navContent = await fs.readFile(navPath, 'utf-8') + const navData = yaml.parse(navContent) + + const commandsSection = navData.find(s => s.title === 'CLI Commands') + t.ok(commandsSection, 'has CLI Commands section') + + const testCmdEntry = commandsSection.children.find(c => c.url === '/commands/npm-test-cmd') + t.ok(testCmdEntry, 'has test-cmd entry') + // Should use name ('npm-test-cmd') and replace 'npm-' with 'npm ' + t.equal(testCmdEntry.title, 'npm test-cmd', 'uses name with npm- replaced to npm space') + }) + + t.test('command description fallback to empty string', async t => { + // Tests line 77: description: attributes.description || '' + // When command doc has no description, should use empty string + const testDir = t.testdir({ + content: { + commands: { + // Command doc WITHOUT description in frontmatter - should use '' + 'npm-no-desc.md': `--- +title: npm-no-desc +section: 1 +--- + +Content here`, + }, + }, + 'nav.yml': '', + }) + + const contentPath = join(testDir, 'content') + const navPath = join(testDir, 'nav.yml') + + await generateNav(contentPath, navPath) + + const navContent = await fs.readFile(navPath, 'utf-8') + const navData = yaml.parse(navContent) + + const commandsSection = navData.find(s => s.title === 'CLI Commands') + t.ok(commandsSection, 'has CLI Commands section') + + const noDescEntry = commandsSection.children.find(c => c.url === '/commands/npm-no-desc') + t.ok(noDescEntry, 'has no-desc entry') + // Should use empty string since no description in frontmatter + t.equal(noDescEntry.description, '', 'uses empty string when no description') + }) +}) + +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/], + }) + }) + + t.test('command with subcommands and aliases (trust)', async t => { + // Tests subcommand code path including line 184 (aliases in subcommand definitions) + // npm trust has subcommands with definitions that include aliases (repo, env) + await testCommandDoc(t, 'npm-trust', 'Create a trusted relationship between a package and a OIDC provider', { + match: [/Aliases/, /--repo/, /--env/], + }) + }) +}) +// Test harness for injecting custom commands to test edge cases +t.test('command injection test harness', async t => { + // Clear the registry after each test + t.afterEach(() => { + clearCommandRegistry() + }) + + t.test('command without description', async t => { + // Register a command without a description + registerCommand('testcmd-nodesc', { + usage: [''], + params: ['registry'], + }) + + const doc = createCommandDoc('npm-testcmd-nodesc', 'Test command without description') + const { html } = await testBuildDocs(t, { + content: { + commands: { 'npm-testcmd-nodesc.md': doc }, + }, + }) + + const htmlContent = await readHtmlDoc(html, 'npm-testcmd-nodesc') + t.ok(htmlContent.length > 0, 'generates HTML for command without description') + t.match(htmlContent, /registry/, 'includes registry param') + }) + + t.test('command without usage', async t => { + // Register a command without usage - should default to [''] + registerCommand('testcmd-nousage', { + params: ['registry'], + }) + + const doc = createCommandDoc('npm-testcmd-nousage', 'Test command without usage') + const { html } = await testBuildDocs(t, { + content: { + commands: { 'npm-testcmd-nousage.md': doc }, + }, + }) + + const htmlContent = await readHtmlDoc(html, 'npm-testcmd-nousage') + t.ok(htmlContent.length > 0, 'generates HTML for command without usage') + t.match(htmlContent, /npm testcmd-nousage/, 'includes command name in usage') + }) + + t.test('command without params (no definitions)', async t => { + // Register a command without params - should not have config section content + registerCommand('testcmd-noparams', { + usage: [''], + // No params specified + }) + + // Use a doc without the config placeholder since this command has no params + const doc = `--- +title: npm-testcmd-noparams +section: 1 +description: Test command without params +--- + +### Synopsis + + +` + + const { html } = await testBuildDocs(t, { + content: { + commands: { 'npm-testcmd-noparams.md': doc }, + }, + }) + + const htmlContent = await readHtmlDoc(html, 'npm-testcmd-noparams') + t.ok(htmlContent.length > 0, 'generates HTML for command without params') + t.match(htmlContent, /npm testcmd-noparams/, 'includes command name') + }) + + t.test('command with one definition with short flag', async t => { + // Register a command with a custom definition that has a short flag + registerCommand('testcmd-short', { + usage: [''], + params: ['custom-flag'], + definitions: { + 'custom-flag': { + key: 'custom-flag', + default: false, + type: Boolean, + short: 'c', + description: 'A custom flag with a short version', + describe: () => '#### `custom-flag`\n\n* Default: false\n* Type: Boolean\n\nA custom flag with a short version', + }, + }, + }) + + const doc = createCommandDoc('npm-testcmd-short', 'Test command with short flag') + const { html } = await testBuildDocs(t, { + content: { + commands: { 'npm-testcmd-short.md': doc }, + }, + }) + + const htmlContent = await readHtmlDoc(html, 'npm-testcmd-short') + t.ok(htmlContent.length > 0, 'generates HTML for command with short flag') + t.match(htmlContent, /custom-flag/, 'includes custom flag') + t.match(htmlContent, /-c/, 'includes short flag') + }) + + t.test('command with definition with aliases', async t => { + // Register a command with a definition that has aliases + registerCommand('testcmd-alias', { + usage: [''], + params: ['aliased-flag'], + definitions: { + 'aliased-flag': { + key: 'aliased-flag', + default: '', + type: String, + alias: ['af', 'alias-flag'], + description: 'A flag with aliases', + describe: () => '#### `aliased-flag`\n\n* Default: ""\n* Type: String\n\nA flag with aliases', + }, + }, + }) + + const doc = createCommandDoc('npm-testcmd-alias', 'Test command with aliased flag') + const { html } = await testBuildDocs(t, { + content: { + commands: { 'npm-testcmd-alias.md': doc }, + }, + }) + + const htmlContent = await readHtmlDoc(html, 'npm-testcmd-alias') + t.ok(htmlContent.length > 0, 'generates HTML for command with aliases') + t.match(htmlContent, /aliased-flag/, 'includes aliased flag') + t.match(htmlContent, /Aliases/, 'includes aliases section') + t.match(htmlContent, /--af/, 'includes first alias') + t.match(htmlContent, /--alias-flag/, 'includes second alias') + }) + + t.test('command with subcommands', async t => { + // Register a command with subcommands + class SubA { + static description = 'Subcommand A description' + static usage = [''] + static definitions = { + 'sub-a-flag': { + key: 'sub-a-flag', + default: false, + type: Boolean, + describe: () => '#### `sub-a-flag`\n\n* Default: false\n* Type: Boolean\n\nFlag for subcommand A', + }, + } + } + + class SubB { + static description = 'Subcommand B description' + static usage = ['[options]'] + static params = ['registry'] + } + + registerCommand('testcmd-subs', { + usage: [''], + params: null, + subcommands: { + 'sub-a': SubA, + 'sub-b': SubB, + }, + }) + + const doc = createCommandDoc('npm-testcmd-subs', 'Test command with subcommands') + const { html } = await testBuildDocs(t, { + content: { + commands: { 'npm-testcmd-subs.md': doc }, + }, + }) + + const htmlContent = await readHtmlDoc(html, 'npm-testcmd-subs') + t.ok(htmlContent.length > 0, 'generates HTML for command with subcommands') + t.match(htmlContent, /npm testcmd-subs sub-a/, 'includes sub-a subcommand') + t.match(htmlContent, /npm testcmd-subs sub-b/, 'includes sub-b subcommand') + t.match(htmlContent, /Subcommand A description/, 'includes sub-a description') + t.match(htmlContent, /sub-a-flag/, 'includes sub-a specific flag') + }) + + t.test('command with exclusive params', async t => { + // Register a command with exclusive params that expand + registerCommand('testcmd-exclusive', { + usage: [''], + // save has exclusive params (save-dev, save-optional, etc) + params: ['save'], + }) + + const doc = createCommandDoc('npm-testcmd-exclusive', 'Test command with exclusive params') + const { html } = await testBuildDocs(t, { + content: { + commands: { 'npm-testcmd-exclusive.md': doc }, + }, + }) + + const htmlContent = await readHtmlDoc(html, 'npm-testcmd-exclusive') + t.ok(htmlContent.length > 0, 'generates HTML for command with exclusive params') + // The exclusive params should be expanded from 'save' + t.match(htmlContent, /save/, 'includes save param') + }) + + t.test('command without workspaces', async t => { + // Register a command that is not workspace-aware + registerCommand('testcmd-noworkspaces', { + usage: [''], + params: ['registry'], + workspaces: false, + }) + + const doc = createCommandDoc('npm-testcmd-noworkspaces', 'Test command without workspaces') + const { html } = await testBuildDocs(t, { + content: { + commands: { 'npm-testcmd-noworkspaces.md': doc }, + }, + }) + + const htmlContent = await readHtmlDoc(html, 'npm-testcmd-noworkspaces') + t.ok(htmlContent.length > 0, 'generates HTML for command without workspaces') + t.match(htmlContent, /unaware of workspaces/, 'includes workspaces note') + }) + + t.test('command with workspaces enabled', async t => { + // Register a command that IS workspace-aware + registerCommand('testcmd-workspaces', { + usage: [''], + params: ['registry'], + workspaces: true, + }) + + const doc = createCommandDoc('npm-testcmd-workspaces', 'Test command with workspaces') + const { html } = await testBuildDocs(t, { + content: { + commands: { 'npm-testcmd-workspaces.md': doc }, + }, + }) + + const htmlContent = await readHtmlDoc(html, 'npm-testcmd-workspaces') + t.ok(htmlContent.length > 0, 'generates HTML for command with workspaces') + t.notMatch(htmlContent, /unaware of workspaces/, 'does NOT include workspaces note') + }) + + t.test('subcommand without description', async t => { + // Register a command with a subcommand that has no description + class SubNoDesc { + static usage = [''] + static definitions = { + flag: { + key: 'flag', + default: false, + type: Boolean, + describe: () => '#### `flag`\n\n* Default: false\n* Type: Boolean\n\nA flag', + }, + } + } + + registerCommand('testcmd-sub-nodesc', { + usage: [''], + params: null, + subcommands: { + mysub: SubNoDesc, + }, + }) + + const doc = createCommandDoc('npm-testcmd-sub-nodesc', 'Test command with subcommand without description') + const { html } = await testBuildDocs(t, { + content: { + commands: { 'npm-testcmd-sub-nodesc.md': doc }, + }, + }) + + const htmlContent = await readHtmlDoc(html, 'npm-testcmd-sub-nodesc') + t.ok(htmlContent.length > 0, 'generates HTML for subcommand without description') + t.match(htmlContent, /npm testcmd-sub-nodesc mysub/, 'includes subcommand') + }) + + t.test('subcommand without usage', async t => { + // Register a command with a subcommand that has no usage + class SubNoUsage { + static description = 'Subcommand without usage' + static definitions = { + flag: { + key: 'flag', + default: false, + type: Boolean, + describe: () => '#### `flag`\n\n* Default: false\n* Type: Boolean\n\nA flag', + }, + } + } + + registerCommand('testcmd-sub-nousage', { + usage: [''], + params: null, + subcommands: { + mysub: SubNoUsage, + }, + }) + + const doc = createCommandDoc('npm-testcmd-sub-nousage', 'Test command with subcommand without usage') + const { html } = await testBuildDocs(t, { + content: { + commands: { 'npm-testcmd-sub-nousage.md': doc }, + }, + }) + + const htmlContent = await readHtmlDoc(html, 'npm-testcmd-sub-nousage') + t.ok(htmlContent.length > 0, 'generates HTML for subcommand without usage') + t.match(htmlContent, /Subcommand without usage/, 'includes subcommand description') + }) + + t.test('subcommand with short flag and alias', async t => { + // Register a command with a subcommand that has definitions with short and alias + class SubWithShortAlias { + static description = 'Subcommand with short and alias' + static usage = [''] + static definitions = { + 'complex-flag': { + key: 'complex-flag', + default: '', + type: String, + short: 'x', + alias: ['cf', 'cflag'], + describe: () => '#### `complex-flag`\n\n* Default: ""\n* Type: String\n\nA complex flag', + }, + } + } + + registerCommand('testcmd-sub-complex', { + usage: [''], + params: null, + subcommands: { + mysub: SubWithShortAlias, + }, + }) + + const doc = createCommandDoc('npm-testcmd-sub-complex', 'Test command with complex subcommand') + const { html } = await testBuildDocs(t, { + content: { + commands: { 'npm-testcmd-sub-complex.md': doc }, + }, + }) + + const htmlContent = await readHtmlDoc(html, 'npm-testcmd-sub-complex') + t.ok(htmlContent.length > 0, 'generates HTML for subcommand with short and alias') + t.match(htmlContent, /complex-flag/, 'includes complex flag') + t.match(htmlContent, /-x/, 'includes short flag') + t.match(htmlContent, /Aliases/, 'includes aliases') + t.match(htmlContent, /--cf/, 'includes first alias') + t.match(htmlContent, /--cflag/, 'includes second alias') + }) + + t.test('subcommand with explicit params (not derived from definitions)', async t => { + // Register a command with a subcommand that has explicit params array + class SubWithParams { + static description = 'Subcommand with explicit params' + static usage = [''] + static params = ['registry', 'tag'] + } + + registerCommand('testcmd-sub-params', { + usage: [''], + params: null, + subcommands: { + mysub: SubWithParams, + }, + }) + + const doc = createCommandDoc('npm-testcmd-sub-params', 'Test command with subcommand with explicit params') + const { html } = await testBuildDocs(t, { + content: { + commands: { 'npm-testcmd-sub-params.md': doc }, + }, + }) + + const htmlContent = await readHtmlDoc(html, 'npm-testcmd-sub-params') + t.ok(htmlContent.length > 0, 'generates HTML for subcommand with explicit params') + t.match(htmlContent, /registry/, 'includes registry param') + t.match(htmlContent, /tag/, 'includes tag param') + }) + + t.test('command with mixed command-specific and global params', async t => { + // Register a command that has both command-specific definitions AND global params + registerCommand('testcmd-mixed', { + usage: [''], + params: ['custom-only', 'registry'], + definitions: { + 'custom-only': { + key: 'custom-only', + default: false, + type: Boolean, + description: 'A command-specific flag', + describe: () => '#### `custom-only`\n\n* Default: false\n* Type: Boolean\n\nA command-specific flag', + }, + }, + }) + + const doc = createCommandDoc('npm-testcmd-mixed', 'Test command with mixed params') + const { html } = await testBuildDocs(t, { + content: { + commands: { 'npm-testcmd-mixed.md': doc }, + }, + }) + + const htmlContent = await readHtmlDoc(html, 'npm-testcmd-mixed') + t.ok(htmlContent.length > 0, 'generates HTML for command with mixed params') + t.match(htmlContent, /custom-only/, 'includes command-specific flag') + t.match(htmlContent, /registry/, 'includes global registry param') + }) + + t.test('subcommand with global config param that has alias in subDefinitions', async t => { + // This specifically tests the aliasText branch in globalConfigParams (line ~214) + // We need a subcommand that uses a global param but overrides it with an alias in subDefinitions + const { definitions: globalDefs } = require('@npmcli/config/lib/definitions') + + class SubWithGlobalAlias { + static description = 'Subcommand using global param with alias override' + static usage = [''] + static params = ['registry'] + // Define registry in subDefinitions with an alias - this shadows the global definition + static definitions = { + registry: { + ...globalDefs.registry, + alias: ['reg', 'r'], + describe: () => globalDefs.registry.describe(), + }, + } + } + + registerCommand('testcmd-sub-global-alias', { + usage: [''], + params: null, + subcommands: { + mysub: SubWithGlobalAlias, + }, + }) + + const doc = createCommandDoc('npm-testcmd-sub-global-alias', 'Test subcommand with global aliased param') + const { html } = await testBuildDocs(t, { + content: { + commands: { 'npm-testcmd-sub-global-alias.md': doc }, + }, + }) + + const htmlContent = await readHtmlDoc(html, 'npm-testcmd-sub-global-alias') + t.ok(htmlContent.length > 0, 'generates HTML for subcommand with global aliased param') + t.match(htmlContent, /registry/, 'includes registry param') + t.match(htmlContent, /Aliases/, 'includes aliases in global config section') + t.match(htmlContent, /--reg/, 'includes first alias') + t.match(htmlContent, /--r/, 'includes second alias') + }) +}) diff --git a/lib/base-cmd.js b/lib/base-cmd.js index 3e6c4758cbd58..ab089ff18c294 100644 --- a/lib/base-cmd.js +++ b/lib/base-cmd.js @@ -1,4 +1,6 @@ const { log } = require('proc-log') +const { definitions, shorthands } = require('@npmcli/config/lib/definitions') +const nopt = require('nopt') class BaseCommand { // these defaults can be overridden by individual commands @@ -10,23 +12,64 @@ class BaseCommand { static name = null static description = null static params = null + static definitions = null + static subcommands = null + // Number of expected positional arguments (null = unlimited/unchecked) + static positionals = null - // this is a static so that we can read from it without instantiating a command + // this is a static so that we can read =rom it without instantiating a command // which would require loading the config static get describeUsage () { - const { definitions } = require('@npmcli/config/lib/definitions') + return this.getUsage() + } + + static getUsage (parentName = null, includeDescriptions = true) { const { aliases: cmdAliases } = require('./utils/cmd-list') const seenExclusive = new Set() const wrapWidth = 80 - const { description, usage = [''], name, params } = this + let { description, usage = [''], name, params } = this + + let definitionsPool = {} + if (this.definitions) { + definitionsPool = { ...definitions, ...this.definitions } + // Auto-populate params from definitions if not explicitly set + if (!params && this.definitions) { + params = Object.values(this.definitions).map(def => def.key) + } + } else { + // Don't mutate this.definitions - just use definitions directly + definitionsPool = definitions + } + + // If this is a subcommand, prepend parent name + const fullCommandName = parentName ? `${parentName} ${name}` : name const fullUsage = [ `${description}`, '', 'Usage:', - ...usage.map(u => `npm ${name} ${u}`.trim()), + ...usage.map(u => `npm ${fullCommandName} ${u}`.trim()), ] + if (this.subcommands) { + fullUsage.push('') + fullUsage.push('Subcommands:') + const subcommandEntries = Object.entries(this.subcommands) + for (let i = 0; i < subcommandEntries.length; i++) { + const [subName, SubCommand] = subcommandEntries[i] + fullUsage.push(` ${subName}`) + if (SubCommand.description) { + fullUsage.push(` ${SubCommand.description}`) + } + // Add space between subcommands except after the last one + if (i < subcommandEntries.length - 1) { + fullUsage.push('') + } + } + fullUsage.push('') + fullUsage.push(`Run "npm ${name} --help" for more info on a subcommand.`) + } + if (params) { let results = '' let line = '' @@ -35,14 +78,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('|')}` } @@ -56,6 +99,27 @@ class BaseCommand { fullUsage.push('') fullUsage.push('Options:') fullUsage.push([results, line].filter(Boolean).join('\n')) + + // Add flag descriptions + if (params.length > 0 && includeDescriptions) { + fullUsage.push('') + for (const param of params) { + if (seenExclusive.has(param)) { + continue + } + const def = definitionsPool[param] + if (def?.description) { + const desc = def.description.trim().split('\n')[0] + const shortcuts = def.short ? `-${def.short}|` : '' + const aliases = (def.alias || []).map(v => `--${v}`).join('|') + const mainFlag = `--${param}` + const flagName = [shortcuts, mainFlag, aliases].filter(Boolean).join('|') + fullUsage.push(` ${flagName}`) + fullUsage.push(` ${desc}`) + fullUsage.push('') + } + } + } } const aliases = Object.entries(cmdAliases).reduce((p, [k, v]) => { @@ -76,8 +140,9 @@ class BaseCommand { constructor (npm) { this.npm = npm + this.commandArgs = null - const { config } = this.npm + const { config } = this if (!this.constructor.skipConfigValidation) { config.validate() @@ -88,6 +153,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 } @@ -209,6 +279,163 @@ class BaseCommand { this.workspaceNames = [...ws.keys()] this.workspacePaths = [...ws.values()] } + + flags (depth = 1) { + const commandDefinitions = this.constructor.definitions || {} + + // Build types, shorthands, and defaults from definitions + const types = {} + const defaults = {} + const cmdShorthands = {} + const aliasMap = {} // Track which aliases map to which main keys + + for (const def of Object.values(commandDefinitions)) { + defaults[def.key] = def.default + types[def.key] = def.type + + // Handle aliases defined in the definition + if (def.alias && Array.isArray(def.alias)) { + for (const aliasKey of def.alias) { + types[aliasKey] = def.type // Needed for nopt to parse aliases + if (!aliasMap[def.key]) { + aliasMap[def.key] = [] + } + aliasMap[def.key].push(aliasKey) + } + } + + // Handle short options + if (def.short) { + const shorts = Array.isArray(def.short) ? def.short : [def.short] + for (const short of shorts) { + cmdShorthands[short] = [`--${def.key}`] + } + } + } + + // Parse args + let parsed = {} + let remains = [] + const argv = this.config.argv + if (argv && argv.length > 0) { + // config.argv contains the full command line including node, npm, and command names + // Format: ['node', 'npm', 'command', 'subcommand', 'positional', '--flags'] + // depth tells us how many command names to skip (1 for top-level, 2 for subcommand, etc.) + const offset = 2 + depth // Skip 'node', 'npm', and all command/subcommand names + parsed = nopt(types, cmdShorthands, argv, offset) + remains = parsed.argv.remain + delete parsed.argv + } + + // Validate flags - only if command has definitions (new system) + if (this.constructor.definitions && Object.keys(this.constructor.definitions).length > 0) { + this.#validateFlags(parsed, commandDefinitions, remains) + } + + // Check for conflicts between main flags and their aliases + // Also map aliases back to their main keys + for (const [mainKey, aliases] of Object.entries(aliasMap)) { + const providedKeys = [] + if (mainKey in parsed) { + providedKeys.push(mainKey) + } + for (const alias of aliases) { + if (alias in parsed) { + providedKeys.push(alias) + } + } + if (providedKeys.length > 1) { + const flagList = providedKeys.map(k => `--${k}`).join(' or ') + throw new Error(`Please provide only one of ${flagList}`) + } + + // If an alias was provided, map it to the main key + if (providedKeys.length === 1 && providedKeys[0] !== mainKey) { + const aliasKey = providedKeys[0] + parsed[mainKey] = parsed[aliasKey] + delete parsed[aliasKey] + } + } + + // Only include keys that are defined in commandDefinitions (main keys only) + const filtered = {} + for (const def of Object.values(commandDefinitions)) { + if (def.key in parsed) { + filtered[def.key] = parsed[def.key] + } + } + return [{ ...defaults, ...filtered }, remains] + } + + // Validate flags and throw errors for unknown flags or unexpected positionals + #validateFlags (parsed, commandDefinitions, remains) { + // Build a set of all valid flag names (global + command-specific + shorthands) + const validFlags = new Set([ + ...Object.keys(definitions), + ...Object.keys(commandDefinitions), + ...Object.keys(shorthands), // Add global shorthands like 'verbose', 'dd', etc. + ]) + + // Add aliases to valid flags + for (const def of Object.values(commandDefinitions)) { + if (def.alias && Array.isArray(def.alias)) { + for (const alias of def.alias) { + validFlags.add(alias) + } + } + } + + // Check parsed flags against valid flags + const unknownFlags = [] + for (const key of Object.keys(parsed)) { + if (!validFlags.has(key)) { + unknownFlags.push(key) + } + } + + // Throw error if unknown flags were found + if (unknownFlags.length > 0) { + const flagList = unknownFlags.map(f => `--${f}`).join(', ') + throw new Error(`Unknown flag${unknownFlags.length > 1 ? 's' : ''}: ${flagList}`) + } + + // Remove warnings for command-specific definitions that npm's global config + // doesn't know about (these were queued as "unknown" during config.load()) + for (const def of Object.values(commandDefinitions)) { + this.npm.config.removeWarning(def.key) + if (def.alias && Array.isArray(def.alias)) { + for (const alias of def.alias) { + this.npm.config.removeWarning(alias) + } + } + } + + // Remove warnings for unknown positionals that were actually consumed as flag values + // by command-specific definitions (e.g., --id where --id is command-specific) + const remainsSet = new Set(remains) + for (const unknownPos of this.npm.config.getUnknownPositionals()) { + if (!remainsSet.has(unknownPos)) { + // This value was consumed as a flag value, not truly a positional + this.npm.config.removeUnknownPositional(unknownPos) + } + } + + // Warn about extra positional arguments beyond what the command expects + const expectedPositionals = this.constructor.positionals + if (expectedPositionals !== null && remains.length > expectedPositionals) { + const extraPositionals = remains.slice(expectedPositionals) + for (const extra of extraPositionals) { + throw new Error(`Unknown positional argument: ${extra}`) + } + } + + this.npm.config.logWarnings() + } + + async exec () { + // This method should be overridden by commands + // Subcommand routing is handled in npm.js #exec + } } module.exports = BaseCommand diff --git a/lib/commands/cache.js b/lib/commands/cache.js index 35b87bef16c23..c0efb035a69c1 100644 --- a/lib/commands/cache.js +++ b/lib/commands/cache.js @@ -362,7 +362,7 @@ class Cache extends BaseCommand { if (valid) { output.standard(results.join('\n')) } - output.standard('') + output.standard() } } } diff --git a/lib/commands/completion.js b/lib/commands/completion.js index ae459aaaf31ce..85a538f7e0c03 100644 --- a/lib/commands/completion.js +++ b/lib/commands/completion.js @@ -40,13 +40,13 @@ const BaseCommand = require('../base-cmd.js') const fileExists = (file) => fs.stat(file).then(s => s.isFile()).catch(() => false) -const configNames = Object.keys(definitions) -const shorthandNames = Object.keys(shorthands) -const allConfs = configNames.concat(shorthandNames) - class Completion extends BaseCommand { static description = 'Tab Completion for npm' static name = 'completion' + // Completion command uses args differently - they represent the command line + // being completed, not actual arguments to this command, so we use an empty + // definitions object to prevent flag validation + static definitions = {} // completion for the completion command static async completion (opts) { @@ -90,15 +90,17 @@ class Completion extends BaseCommand { // if the point isn't at the end. // ie, tabbing at: npm foo b|ar const w = +COMP_CWORD - const words = args.map(unescape) - const word = words[w] const line = COMP_LINE + // Use COMP_LINE to get words if args doesn't include flags (e.g., in tests) + const hasFlags = line.includes(' -') && !args.some(arg => arg.startsWith('-')) + const words = (hasFlags ? line.split(/\s+/) : args).map(unescape) + const word = words[w] || '' const point = +COMP_POINT const partialLine = line.slice(0, point) const partialWords = words.slice(0, w) // figure out where in that last word the point is. - const partialWordRaw = args[w] + const partialWordRaw = args[w] || '' let i = partialWordRaw.length while (partialWordRaw.slice(0, i) !== partialLine.slice(-1 * i) && i > 0) { i-- @@ -121,33 +123,32 @@ class Completion extends BaseCommand { raw: args, } + // try to find the npm command and subcommand early for flag completion + // this helps with custom command definitions from subcommands + const types = Object.entries(definitions).reduce((acc, [key, def]) => { + acc[key] = def.type + return acc + }, {}) + const parsed = opts.conf = + nopt(types, shorthands, partialWords.slice(0, -1), 0) + const cmd = parsed.argv.remain[1] + const subCmd = parsed.argv.remain[2] + if (partialWords.slice(0, -1).indexOf('--') === -1) { - if (word.charAt(0) === '-') { - return this.wrap(opts, configCompl(opts)) + if (word && word.charAt(0) === '-') { + return this.wrap(opts, configCompl(opts, cmd, subCmd, this.npm)) } if (words[w - 1] && words[w - 1].charAt(0) === '-' && - !isFlag(words[w - 1])) { + !isFlag(words[w - 1], cmd, subCmd, this.npm)) { // awaiting a value for a non-bool config. // don't even try to do this for now return this.wrap(opts, configValueCompl(opts)) } } - // try to find the npm command. - // it's the first thing after all the configs. - // take a little shortcut and use npm's arg parsing logic. - // don't have to worry about the last arg being implicitly - // boolean'ed, since the last block will catch that. - const types = Object.entries(definitions).reduce((acc, [key, def]) => { - acc[key] = def.type - return acc - }, {}) - const parsed = opts.conf = - nopt(types, shorthands, partialWords.slice(0, -1), 0) // check if there's a command already. - const cmd = parsed.argv.remain[1] if (!cmd) { return this.wrap(opts, cmdCompl(opts, this.npm)) } @@ -234,16 +235,66 @@ const dumpScript = async (p) => { const unescape = w => w.charAt(0) === '\'' ? w.replace(/^'|'$/g, '') : w.replace(/\\ /g, ' ') +// Helper to get custom definitions from a command/subcommand +const getCustomDefinitions = (cmd, subCmd) => { + if (!cmd) { + return {} + } + + try { + const command = Npm.cmd(cmd) + + // Check if the command has subcommands + if (subCmd && command.subcommands && command.subcommands[subCmd]) { + const subcommand = command.subcommands[subCmd] + // All subcommands have definitions + return subcommand.definitions + } + + // Check if the command itself has definitions + if (command.definitions) { + return command.definitions + } + } catch { + // Command not found or no definitions + } + + return {} +} + +// Helper to get all config names including aliases from custom definitions +const getCustomConfigNames = (customDefs) => { + const names = new Set() + for (const [name, def] of Object.entries(customDefs)) { + names.add(name) + if (def.alias && Array.isArray(def.alias)) { + def.alias.forEach(a => names.add(a)) + } + } + return [...names] +} + // the current word has a dash. Return the config names, // with the same number of dashes as the current word has. -const configCompl = opts => { +const configCompl = (opts, cmd, subCmd, npm) => { const word = opts.word const split = word.match(/^(-+)((?:no-)*)(.*)$/) const dashes = split[1] const no = split[2] - const flags = configNames.filter(isFlag) - return allConfs.map(c => dashes + c) - .concat(flags.map(f => dashes + (no || 'no-') + f)) + + // Get custom definitions from the command/subcommand + const customDefs = getCustomDefinitions(cmd, subCmd, npm) + const customNames = getCustomConfigNames(customDefs) + + // If there are custom definitions, return only those (new feature) + // Otherwise, return empty array (historical behavior - no global flag completion) + if (customNames.length > 0) { + const flags = customNames.filter(name => isFlag(name, cmd, subCmd, npm)) + return customNames.map(c => dashes + c) + .concat(flags.map(f => dashes + (no || 'no-') + f)) + } + + return [] } // expand with the valid values of various config values. @@ -251,16 +302,37 @@ const configCompl = opts => { const configValueCompl = () => [] // check if the thing is a flag or not. -const isFlag = word => { +const isFlag = (word, cmd, subCmd, npm) => { // shorthands never take args. const split = word.match(/^(-*)((?:no-)+)?(.*)$/) const no = split[2] const conf = split[3] - const { type } = definitions[conf] - return no || - type === Boolean || - (Array.isArray(type) && type.includes(Boolean)) || - shorthands[conf] + + // Check custom definitions first + const customDefs = getCustomDefinitions(cmd, subCmd, npm) + + // Check if conf is in custom definitions or is an alias + let customDef = customDefs[conf] + if (!customDef) { + // Check if conf is an alias for any of the custom definitions + for (const [, def] of Object.entries(customDefs)) { + if (def.alias && Array.isArray(def.alias) && def.alias.includes(conf)) { + customDef = def + break + } + } + } + + if (customDef) { + const { type } = customDef + return no || + type === Boolean || + (Array.isArray(type) && type.includes(Boolean)) + } + + // No custom definitions found, should not reach here in normal flow + // since configCompl returns empty array when no custom defs exist + return false } // complete against the npm commands diff --git a/lib/commands/doctor.js b/lib/commands/doctor.js index a537478bee3fe..0d948da231187 100644 --- a/lib/commands/doctor.js +++ b/lib/commands/doctor.js @@ -27,7 +27,7 @@ const maskLabel = mask => { return label.join(', ') } -const subcommands = [ +const checks = [ { // Ping is left in as a legacy command but is listed as "connection" to // make more sense to more people @@ -100,12 +100,10 @@ class Doctor extends BaseCommand { static name = 'doctor' static params = ['registry'] static ignoreImplicitWorkspace = false - static usage = [`[${subcommands.flatMap(s => s.groups) + static usage = [`[${checks.flatMap(s => s.groups) .filter((value, index, self) => self.indexOf(value) === index && value !== 'ping') .join('] [')}]`] - static subcommands = subcommands - async exec (args) { log.info('doctor', 'Running checkup') let allOk = true @@ -331,7 +329,7 @@ class Doctor extends BaseCommand { } actions (params) { - return this.constructor.subcommands.filter(subcmd => { + return checks.filter(subcmd => { if (process.platform === 'win32' && subcmd.windows === false) { return false } diff --git a/lib/commands/run.js b/lib/commands/run.js index d89cb4d93bb7f..bed82a6a08701 100644 --- a/lib/commands/run.js +++ b/lib/commands/run.js @@ -57,7 +57,7 @@ class RunScript extends BaseCommand { if (!args.length) { const newline = await this.#list(path, { workspace }) if (newline && !last) { - output.standard('') + output.standard() } continue } diff --git a/lib/commands/trust/github.js b/lib/commands/trust/github.js new file mode 100644 index 0000000000000..7ebc8443cc191 --- /dev/null +++ b/lib/commands/trust/github.js @@ -0,0 +1,103 @@ +const Definition = require('@npmcli/config/lib/definitions/definition.js') +const globalDefinitions = require('@npmcli/config/lib/definitions/definitions.js') +const TrustCommand = require('../../trust-cmd.js') +const path = require('node:path') + +class TrustGitHub extends TrustCommand { + static description = 'Create a trusted relationship between a package and GitHub Actions' + static name = 'github' + static positionals = 1 // expects at most 1 positional (package name) + static providerName = 'GitHub Actions' + static providerEntity = 'GitHub repository' + static providerFile = 'GitHub Actions Workflow' + static providerHostname = 'https://github.com' + + // entity means project / repository + static entityKey = 'repository' + + static usage = [ + '[package] --file [--repo|--repository] [--env|--environment] [-y|--yes]', + ] + + static definitions = { + yes: globalDefinitions.yes, + json: globalDefinitions.json, + 'dry-run': globalDefinitions['dry-run'], + file: new Definition('file', { + default: null, + type: String, + description: 'Name of workflow file within a repositories .GitHub folder (must end in yaml, yml)', + }), + repository: new Definition('repository', { + default: null, + type: String, + description: 'Name of the repository in the format owner/repo', + alias: ['repo'], + }), + environment: new Definition('environment', { + default: null, + type: String, + description: 'CI environment name', + alias: ['env'], + }), + } + + getEntityUrl ({ providerHostname, file, entity }) { + if (file) { + return new URL(`${entity}/blob/HEAD/.github/workflows/${file}`, providerHostname).toString() + } + return new URL(entity, providerHostname).toString() + } + + validateEntity (entity) { + if (entity.split('/').length !== 2) { + throw new Error(`${this.providerEntity} must be specified in the format owner/repository`) + } + } + + validateFile (file) { + if (file !== path.basename(file)) { + throw new Error('GitHub Actions workflow must be just a file not a path') + } + } + + static optionsToBody (options) { + const { file, repository, environment } = options + const trustConfig = { + type: 'github', + claims: { + repository, + workflow_ref: { + file, + }, + }, + } + if (environment) { + trustConfig.environment = environment + } + return trustConfig + } + + // Convert API response body to options + static bodyToOptions (body) { + const file = body.claims?.workflow_ref?.file + const repository = body.claims?.repository + const environment = body.environment + return { + ...(body.id) && { id: body.id }, + ...(body.type) && { type: body.type }, + ...(file) && { file }, + ...(repository) && { repository }, + ...(environment) && { environment }, + } + } + + async exec (positionalArgs, flags) { + await this.createConfigCommand({ + positionalArgs, + flags, + }) + } +} + +module.exports = TrustGitHub diff --git a/lib/commands/trust/gitlab.js b/lib/commands/trust/gitlab.js new file mode 100644 index 0000000000000..c8e5c6e76e9b9 --- /dev/null +++ b/lib/commands/trust/gitlab.js @@ -0,0 +1,104 @@ +const Definition = require('@npmcli/config/lib/definitions/definition.js') +const globalDefinitions = require('@npmcli/config/lib/definitions/definitions.js') +const TrustCommand = require('../../trust-cmd.js') +const path = require('node:path') + +class TrustGitLab extends TrustCommand { + static description = 'Create a trusted relationship between a package and GitLab CI/CD' + static name = 'gitlab' + static positionals = 1 // expects at most 1 positional (package name) + static providerName = 'GitLab CI/CD' + static providerEntity = 'GitLab project' + static providerFile = 'GitLab CI/CD Pipeline' + static providerHostname = 'https://gitlab.com' + + // entity means project / repository + static entityKey = 'project' + + static usage = [ + '[package] --file [--project|--repo|--repository] [--env|--environment] [-y|--yes]', + ] + + static definitions = { + yes: globalDefinitions.yes, + json: globalDefinitions.json, + 'dry-run': globalDefinitions['dry-run'], + file: new Definition('file', { + default: null, + type: String, + description: 'Name of pipeline file (e.g., .gitlab-ci.yml)', + }), + project: new Definition('project', { + default: null, + type: String, + description: 'Name of the project in the format group/project or group/subgroup/project', + }), + environment: new Definition('environment', { + default: null, + type: String, + description: 'CI environment name', + alias: ['env'], + }), + } + + getEntityUrl ({ providerHostname, file, entity }) { + if (file) { + return new URL(`${entity}/-/blob/HEAD/${file}`, providerHostname).toString() + } + return new URL(entity, providerHostname).toString() + } + + validateEntity (entity) { + if (entity.split('/').length < 2) { + throw new Error(`${this.providerEntity} must be specified in the format group/project or group/subgroup/project`) + } + } + + validateFile (file) { + if (file !== path.basename(file)) { + throw new Error('GitLab CI/CD pipeline file must be just a file not a path') + } + } + + static optionsToBody (options) { + const { file, project, environment } = options + const trustConfig = { + type: 'gitlab', + claims: { + project_path: project, + // this looks off, but this is correct + /** The ref path to the top-level pipeline definition, for example, gitlab.example.com/my-group/my-project//.gitlab-ci.yml@refs/heads/main. Introduced in GitLab 16.2. This claim is null unless the pipeline definition is located in the same project. */ + ci_config_ref_uri: { + file, + }, + }, + } + if (environment) { + trustConfig.environment = environment + } + return trustConfig + } + + // Convert API response body to options + static bodyToOptions (body) { + const file = body.claims?.ci_config_ref_uri?.file + const project = body.claims?.project_path + const environment = body.environment + return { + ...(body.id) && { id: body.id }, + ...(body.type) && { type: body.type }, + ...(file) && { file }, + ...(project) && { project }, + ...(environment) && { environment }, + } + } + + async exec (positionalArgs, flags) { + await this.createConfigCommand({ + positionalArgs, + flags, + }) + } +} + +module.exports = TrustGitLab diff --git a/lib/commands/trust/index.js b/lib/commands/trust/index.js new file mode 100644 index 0000000000000..cabcfa7c34cb8 --- /dev/null +++ b/lib/commands/trust/index.js @@ -0,0 +1,23 @@ +const BaseCommand = require('../../base-cmd.js') + +class Trust extends BaseCommand { + static description = 'Create a trusted relationship between a package and a OIDC provider' + static name = 'trust' + + static subcommands = { + github: require('./github.js'), + gitlab: require('./gitlab.js'), + list: require('./list.js'), + revoke: require('./revoke.js'), + } + + static async completion (opts) { + const argv = opts.conf.argv.remain + if (argv.length === 2) { + return Object.keys(Trust.subcommands) + } + return [] + } +} + +module.exports = Trust diff --git a/lib/commands/trust/list.js b/lib/commands/trust/list.js new file mode 100644 index 0000000000000..e72da6d409d4d --- /dev/null +++ b/lib/commands/trust/list.js @@ -0,0 +1,61 @@ +const pkgJson = require('@npmcli/package-json') +const { otplease } = require('../../utils/auth.js') +const npmFetch = require('npm-registry-fetch') +const npa = require('npm-package-arg') +const TrustGithub = require('./github.js') +const TrustGitlab = require('./gitlab.js') +const TrustCommand = require('../../trust-cmd.js') +const globalDefinitions = require('@npmcli/config/lib/definitions/definitions.js') +const { output } = require('proc-log') + +// Convert trust config body to options object +function bodyToOptions (body) { + if (body.type === 'github') { + return TrustGithub.bodyToOptions(body) + } else if (body.type === 'gitlab') { + return TrustGitlab.bodyToOptions(body) + } + return TrustCommand.bodyToOptions(body) +} + +class TrustList extends TrustCommand { + static description = 'List trusted relationships for a package' + static name = 'list' + static positionals = 1 // expects at most 1 positional (package name) + + static usage = [ + '[package]', + ] + + static definitions = { + json: globalDefinitions.json, + } + + async exec (args) { + const { content } = await pkgJson.normalize(this.npm.prefix) + const packageName = args[0] || content.name + if (!packageName) { + throw new Error('Package name must be specified either as an argument or in the package.json file') + } + const spec = npa(packageName) + const uri = `/-/package/${spec.escapedName}/trust` + const res = await otplease(this.npm, this.npm.flatOptions, opts => npmFetch.json(uri, { + ...opts, + method: 'GET', + })) + + if (!res || res.length === 0) { + this.dialogue`No trust configurations found for package (${packageName})` + return + } + this.dialogue`Trust configurations for package (${packageName}):` + for (const config of res) { + const values = bodyToOptions(config) + output.standard() + this.logOptions({ values }, false) + } + output.standard() + } +} + +module.exports = TrustList diff --git a/lib/commands/trust/revoke.js b/lib/commands/trust/revoke.js new file mode 100644 index 0000000000000..1a074b19139a7 --- /dev/null +++ b/lib/commands/trust/revoke.js @@ -0,0 +1,52 @@ +const globalDefinitions = require('@npmcli/config/lib/definitions/definitions.js') +const Definition = require('@npmcli/config/lib/definitions/definition.js') +const pkgJson = require('@npmcli/package-json') +const { otplease } = require('../../utils/auth.js') +const npmFetch = require('npm-registry-fetch') +const npa = require('npm-package-arg') +const TrustCommand = require('../../trust-cmd.js') + +class TrustRevoke extends TrustCommand { + static description = 'Revoke a trusted relationship for a package' + static name = 'revoke' + static positionals = 1 // expects at most 1 positional (package name) + + static usage = [ + '[package] --id=', + ] + + static definitions = { + 'dry-run': globalDefinitions['dry-run'], + id: new Definition('id', { + default: null, + type: String, + description: 'ID of the trusted relationship to revoke', + }), + } + + async exec (positionalArgs, flags) { + const dryRun = this.config.get('dry-run') + const { content } = await pkgJson.normalize(this.npm.prefix) + const pkgName = positionalArgs[0] || content.name + if (!pkgName) { + throw new Error('Package name must be specified either as an argument or in the package.json file') + } + const { id } = flags + if (!id) { + throw new Error('ID of the trusted relationship to revoke must be specified with the --id option') + } + this.dialogue`Attempting to revoke trusted configuration for package ${pkgName} with id ${id}` + if (dryRun) { + return + } + const spec = npa(pkgName) + const uri = `/-/package/${spec.escapedName}/trust/${encodeURIComponent(id)}` + await otplease(this.npm, this.npm.flatOptions, opts => npmFetch(uri, { + ...opts, + method: 'DELETE', + })) + this.dialogue`Revoked trusted configuration for package ${pkgName} with id ${id}` + } +} + +module.exports = TrustRevoke diff --git a/lib/npm.js b/lib/npm.js index c635f3e05a7b3..355fb4c7467c5 100644 --- a/lib/npm.js +++ b/lib/npm.js @@ -26,7 +26,7 @@ class Npm { command: c, }) } - return require(`./commands/${command}.js`) + return require(`./commands/${command}`) } unrefPromises = [] @@ -37,6 +37,8 @@ class Npm { #runId = new Date().toISOString().replace(/[.:]/g, '_') #title = 'npm' #argvClean = [] + #argv = undefined + #excludeNpmCwd = undefined #npmRoot = null #display = null @@ -72,6 +74,7 @@ class Npm { shorthands, argv: [...process.argv, ...argv], excludeNpmCwd, + warn: false, }) } @@ -227,8 +230,64 @@ class Npm { process.env.npm_command = this.command } + // Only log warnings for legacy commands without definitions or subcommands + // Commands with definitions will handle warnings in base-cmd flags() + // Commands with subcommands will delegate to the subcommand to handle warnings + if (!Command.definitions && !Command.subcommands) { + this.config.logWarnings() + } + + // this needs to be rest after because some commands run + // this.npm.config.checkUnknown('publishConfig', key) + this.config.warn = true + + return this.execCommandClass(command, args, [cmd]) + } + + // Unified command execution for both top-level commands and subcommands + // Supports n-depth subcommands, workspaces, and definitions + async execCommandClass (commandInstance, args, commandPath = []) { + const Command = commandInstance.constructor + const commandName = commandPath.join(':') + + // Handle subcommands if present + if (Command.subcommands) { + const subcommandName = args[0] + + // If help is requested without a subcommand, show main command help + if (this.config.get('usage') && !subcommandName) { + return output.standard(commandInstance.usage) + } + + // If no subcommand provided, show usage error + if (!subcommandName) { + throw commandInstance.usageError() + } + + // Check if the subcommand exists + const SubCommand = Command.subcommands[subcommandName] + if (!SubCommand) { + throw commandInstance.usageError(`Unknown subcommand: ${subcommandName}`) + } + + // Check if help is requested for the subcommand + if (this.config.get('usage')) { + const parentName = commandPath[0] + return output.standard(SubCommand.getUsage(parentName)) + } + + // Create subcommand instance and recurse + const subcommandInstance = new SubCommand(this) + const subcommandArgs = args.slice(1) // Remove subcommand name from args + const subcommandPath = [...commandPath, subcommandName] + + return time.start(`command:${subcommandPath.join(':')}`, () => + this.execCommandClass(subcommandInstance, subcommandArgs, subcommandPath)) + } + + // No subcommands - execute this command if (this.config.get('usage')) { - return output.standard(command.usage) + return output.standard(commandInstance.usage) } let execWorkspaces = false @@ -248,12 +307,26 @@ class Npm { execWorkspaces = true } - if (command.checkDevEngines && !this.global) { - await command.checkDevEngines() + // Check dev engines if needed + if (commandInstance.checkDevEngines && !this.global) { + await commandInstance.checkDevEngines() } - return time.start(`command:${cmd}`, () => - execWorkspaces ? command.execWorkspaces(args) : command.exec(args)) + // Execute command with or without definitions + if (Command.definitions) { + // config.argv contains the full argv with flags (set by Config in production, by MockNpm in tests) + // Pass depth so flags() knows how many command names to skip + const [flags, positionalArgs] = commandInstance.flags(commandPath.length) + return time.start(`command:${commandName}`, () => + execWorkspaces + ? commandInstance.execWorkspaces(positionalArgs, flags) + : commandInstance.exec(positionalArgs, flags)) + } else { + // Legacy commands without definitions + this.config.logWarnings() + return time.start(`command:${commandName}`, () => + execWorkspaces ? commandInstance.execWorkspaces(args) : commandInstance.exec(args)) + } } // This gets called at the end of the exit handler and diff --git a/lib/trust-cmd.js b/lib/trust-cmd.js new file mode 100644 index 0000000000000..80af50edae3ce --- /dev/null +++ b/lib/trust-cmd.js @@ -0,0 +1,260 @@ +const BaseCommand = require('./base-cmd.js') +const { otplease } = require('./utils/auth.js') +const npmFetch = require('npm-registry-fetch') +const npa = require('npm-package-arg') +const { read: _read } = require('read') +const { input, output, log } = require('proc-log') +const gitinfo = require('hosted-git-info') +const pkgJson = require('@npmcli/package-json') + +const NPM_FRONTEND = 'https://www.npmjs.com' + +class TrustCommand extends BaseCommand { + // Helper to format template strings with color + // Blue text with reset color for interpolated values + warnString (strings, ...values) { + const chalk = this.npm.chalk + const message = strings.reduce((result, str, i) => { + return result + chalk.blue(str) + (values[i] ? chalk.reset(values[i]) : '') + }, '') + return message + } + + // Log a warning message with blue formatting + warn (strings, ...values) { + log.warn('trust', this.warnString(strings, ...values)) + } + + // dialogue is non-log text that is different from our usual npm prefix logging + // it should always show to the user unless --json is specified + // it's not controled by log levels + dialogue (strings, ...values) { + const json = this.config.get('json') + if (!json) { + output.standard(this.warnString(strings, ...values)) + } + } + + createConfig (pkg, body) { + const spec = npa(pkg) + const uri = `/-/package/${spec.escapedName}/trust` + return otplease(this.npm, this.npm.flatOptions, opts => npmFetch(uri, { + ...opts, + method: 'POST', + body: body, + })) + } + + logOptions (options, pad = true) { + const { values, warnings, fromPackageJson, urls } = { warnings: [], ...options } + if (warnings && warnings.length > 0) { + for (const warningMsg of warnings) { + log.warn('trust', warningMsg) + } + } + + const json = this.config.get('json') + if (json) { + output.standard(JSON.stringify(options.values, null, 2)) + return + } + + const chalk = this.npm.chalk + const { type, id, ...rest } = values || {} + + if (values) { + const lines = [] + if (type) { + lines.push(`type: ${chalk.green(type)}`) + } + if (id) { + lines.push(`id: ${chalk.green(id)}`) + } + for (const [key, value] of Object.entries(rest)) { + if (value !== null && value !== undefined) { + const parts = [ + `${chalk.reset(key)}: ${chalk.green(value)}`, + ] + if (fromPackageJson && fromPackageJson[key]) { + parts.push(`(${chalk.yellow(`from package.json`)})`) + } + if (urls && urls[key]) { + parts.push(`(${chalk.blue(urls[key])})`) + } + lines.push(parts.join(' ')) + } + } + if (pad) { + output.standard() + } + output.standard(lines.join('\n')) + if (pad) { + output.standard() + } + } + } + + async confirmOperation (yes) { + // Ask for confirmation unless --yes flag is set + if (yes === true) { + return + } + if (yes === false) { + throw new Error('User cancelled operation') + } + const confirm = await input.read( + () => _read({ prompt: 'Do you want to proceed? (y/N) ', default: 'n' }) + ) + const normalized = confirm.toLowerCase() + if (['y', 'yes'].includes(normalized)) { + return + } + throw new Error('User cancelled operation') + } + + getFrontendUrl ({ pkgName }) { + if (this.registryIsDefault) { + return new URL(`/package/${pkgName}`, NPM_FRONTEND).toString() + } + return null + } + + getRepositoryFromPackageJson (pkg) { + const info = gitinfo.fromUrl(pkg.repository?.url || pkg?.repository) + if (!info) { + return null + } + const repository = info.user + '/' + info.project + const type = info.type + return { repository, type } + } + + async optionalPkgJson () { + try { + const { content } = await pkgJson.normalize(this.npm.prefix) + return content + } catch (err) { + return {} + } + } + + get registryIsDefault () { + return this.npm.config.defaults.registry === this.npm.config.get('registry') + } + + // generic + static bodyToOptions (body) { + return { + ...(body.id) && { id: body.id }, + ...(body.type) && { type: body.type }, + } + } + + async createConfigCommand ({ positionalArgs, flags }) { + const { providerName, providerEntity, providerHostname } = this.constructor + const dryRun = this.config.get('dry-run') + const yes = this.config.get('yes') // deep-lore this allows for --no-yes + const options = await this.flagsToOptions({ positionalArgs, flags, providerHostname }) + this.dialogue`Establishing trust between ${options.values.package} package and ${providerName}` + this.dialogue`Anyone with ${providerEntity} write access can publish to ${options.values.package}` + this.dialogue`Two-factor authentication is required for this operation` + if (!this.registryIsDefault) { + this.warn`Registry ${this.npm.config.get('registry')} may not support trusted publishing` + } + this.logOptions(options) + if (dryRun) { + return + } + await this.confirmOperation(yes) + const trustConfig = this.constructor.optionsToBody(options.values) + const response = await this.createConfig(options.values.package, [trustConfig]) + const body = await response.json(yes) + this.dialogue`Trust configuration created successfully with the following settings:` + const values = this.constructor.bodyToOptions(body) + this.logOptions({ values }) + } + + async flagsToOptions ({ positionalArgs, flags, providerHostname }) { + const { entityKey, name, providerEntity, providerFile } = this.constructor + const content = await this.optionalPkgJson() + const pkgPositional = positionalArgs[0] + const pkgJsonName = content.name + const git = this.getRepositoryFromPackageJson(content) + // the provided positional matches package.json name or no positional provided + const matchPkg = (!pkgPositional || pkgPositional === pkgJsonName) + const pkgName = pkgPositional || pkgJsonName + const usedPkgNameFromPkgJson = !pkgPositional && Boolean(pkgJsonName) + const invalidPkgJsonProviderType = matchPkg && git && git?.type !== name + + let entity + let entitySource + + if (flags[entityKey]) { + entity = flags[entityKey] + entitySource = 'flag' + } else if (!invalidPkgJsonProviderType && git?.repository) { + entity = git.repository + entitySource = 'package.json' + } + const mismatchPkgJsonRepository = matchPkg && git && entity !== git.repository + const usedRepositoryInPkgJson = entitySource === 'package.json' + + const warnings = [] + if (!pkgName) { + throw new Error('Package name must be specified either as an argument or in package.json file') + } + + if (!flags.file) { + throw new Error(`${providerFile} must be specified with the file option`) + } + if (!flags.file.endsWith('.yml') && !flags.file.endsWith('.yaml')) { + throw new Error(`${providerFile} must end in .yml or .yaml`) + } + + this.validateFile?.(flags.file) + + if (invalidPkgJsonProviderType) { + const message = this.warnString`Repository in package.json is not a ${providerEntity}` + if (!flags[entityKey]) { + throw new Error(message) + } else { + warnings.push(message) + } + } else { + if (mismatchPkgJsonRepository) { + warnings.push(this.warnString`Repository in package.json (${git.repository}) differs from provided ${providerEntity} (${entity})`) + } + } + + if (!entity && matchPkg) { + throw new Error(`${providerEntity} must be specified with ${entityKey} option or inferred from the package.json repository field`) + } + if (!entity) { + throw new Error(`${providerEntity} must be specified with ${entityKey} option`) + } + + this.validateEntity(entity) + + return { + values: { + package: pkgName, + file: flags.file, + [entityKey]: entity, + ...(flags.environment && { environment: flags.environment }), + }, + fromPackageJson: { + [entityKey]: usedRepositoryInPkgJson, + package: usedPkgNameFromPkgJson, + }, + warnings: warnings, + urls: { + package: this.getFrontendUrl({ pkgName }), + [entityKey]: this.getEntityUrl({ providerHostname, entity }), + file: this.getEntityUrl({ providerHostname, entity, file: flags.file }), + }, + } + } +} + +module.exports = TrustCommand +module.exports.NPM_FRONTEND = NPM_FRONTEND diff --git a/lib/utils/auth.js b/lib/utils/auth.js index a617ab9430b2a..0111ba94f2aa5 100644 --- a/lib/utils/auth.js +++ b/lib/utils/auth.js @@ -12,7 +12,7 @@ const otplease = async (npm, opts, fn) => { } // web otp - if (err.code === 'EOTP' && err.body?.authUrl && err.body?.doneUrl) { + if ((err.code === 'EOTP' || err.code === 'E401') && err.body?.authUrl && err.body?.doneUrl) { const { token: otp } = await webAuthOpener( createOpener(npm, 'Authenticate your account at'), err.body.authUrl, diff --git a/lib/utils/cmd-list.js b/lib/utils/cmd-list.js index 61f64f77678e2..ec1d50dcc0c56 100644 --- a/lib/utils/cmd-list.js +++ b/lib/utils/cmd-list.js @@ -62,6 +62,7 @@ const commands = [ 'team', 'test', 'token', + 'trust', 'undeprecate', 'uninstall', 'unpublish', diff --git a/lib/utils/display.js b/lib/utils/display.js index dff16ceebef6f..122a7f6e8c577 100644 --- a/lib/utils/display.js +++ b/lib/utils/display.js @@ -337,7 +337,7 @@ class Display { log.resume() // For silent prompts (like password), add newline to preserve output if (meta?.silent) { - output.standard('') + output.standard() } output.flush() this.#progress.resume() @@ -360,7 +360,7 @@ class Display { }) .catch((error) => { // If user hits ctrl+c, add newline to preserve output. - output.standard('') + output.standard() input.end() rej(error) }) diff --git a/lib/utils/npm-usage.js b/lib/utils/npm-usage.js index 1bd790ca601bc..e01a1956e1054 100644 --- a/lib/utils/npm-usage.js +++ b/lib/utils/npm-usage.js @@ -62,7 +62,7 @@ const cmdUsages = (Npm) => { let maxLen = 0 const set = [] for (const c of commands) { - set.push([c, Npm.cmd(c).describeUsage.split('\n')]) + set.push([c, Npm.cmd(c).getUsage(null, false).split('\n')]) maxLen = Math.max(maxLen, c.length) } diff --git a/lib/utils/reify-output.js b/lib/utils/reify-output.js index f4a8442e9427f..48a5525d87f80 100644 --- a/lib/utils/reify-output.js +++ b/lib/utils/reify-output.js @@ -215,7 +215,7 @@ const packagesFundingMessage = (npm, { funding }) => { return } - output.standard('') + output.standard() const pkg = funding === 1 ? 'package' : 'packages' const is = funding === 1 ? 'is' : 'are' output.standard(`${funding} ${pkg} ${is} looking for funding`) diff --git a/lib/utils/verify-signatures.js b/lib/utils/verify-signatures.js index baadb2b1227d9..2130c847b60ec 100644 --- a/lib/utils/verify-signatures.js +++ b/lib/utils/verify-signatures.js @@ -70,7 +70,7 @@ class VerifySignatures { const timing = `audited ${this.auditedWithKeysCount} package${auditedPlural} in ` + `${Math.floor(Number(elapsed) / 1e9)}s` output.standard(timing) - output.standard('') + output.standard() const verifiedBold = this.npm.chalk.bold('verified') if (this.verifiedSignatureCount) { @@ -79,7 +79,7 @@ class VerifySignatures { } else { output.standard(`${this.verifiedSignatureCount} packages have ${verifiedBold} registry signatures`) } - output.standard('') + output.standard() } if (this.verifiedAttestationCount) { @@ -88,7 +88,7 @@ class VerifySignatures { } else { output.standard(`${this.verifiedAttestationCount} packages have ${verifiedBold} attestations`) } - output.standard('') + output.standard() } if (missing.length) { @@ -98,7 +98,7 @@ class VerifySignatures { } else { output.standard(`${missing.length} packages have ${missingClr} registry signatures but the registry is providing signing keys:`) } - output.standard('') + output.standard() missing.map(m => output.standard(`${this.npm.chalk.red(`${m.name}@${m.version}`)} (${m.registry})`) ) @@ -106,7 +106,7 @@ class VerifySignatures { if (invalid.length) { if (missing.length) { - output.standard('') + output.standard() } const invalidClr = this.npm.chalk.redBright('invalid') // We can have either invalid signatures or invalid provenance @@ -117,11 +117,11 @@ class VerifySignatures { } else { output.standard(`${invalidSignatures.length} packages have ${invalidClr} registry signatures:`) } - output.standard('') + output.standard() invalidSignatures.map(i => output.standard(`${this.npm.chalk.red(`${i.name}@${i.version}`)} (${i.registry})`) ) - output.standard('') + output.standard() } const invalidAttestations = this.invalid.filter(i => i.code === 'EATTESTATIONVERIFY') @@ -131,11 +131,11 @@ class VerifySignatures { } else { output.standard(`${invalidAttestations.length} packages have ${invalidClr} attestations:`) } - output.standard('') + output.standard() invalidAttestations.map(i => output.standard(`${this.npm.chalk.red(`${i.name}@${i.version}`)} (${i.registry})`) ) - output.standard('') + output.standard() } if (invalid.length === 1) { @@ -143,7 +143,7 @@ class VerifySignatures { } else { output.standard(`Someone might have tampered with these packages since they were published on the registry!`) } - output.standard('') + output.standard() } } diff --git a/mock-registry/lib/index.js b/mock-registry/lib/index.js index 9b14cd46d8937..b99dfe6b156aa 100644 --- a/mock-registry/lib/index.js +++ b/mock-registry/lib/index.js @@ -648,6 +648,26 @@ class MockRegistry { .matchHeader('authorization', `Bearer ${idToken}`) .reply(statusCode, body || {}) } + + // Trust API methods + trustList ({ packageName, responseCode = 200, body = [] }) { + const spec = npa(packageName) + this.nock = this.nock.get(this.fullPath(`/-/package/${spec.escapedName}/trust`)) + .reply(responseCode, body) + } + + trustCreate ({ packageName, responseCode = 200, body = { ok: true } }) { + const spec = npa(packageName) + this.nock = this.nock.post(this.fullPath(`/-/package/${spec.escapedName}/trust`)) + .reply(responseCode, body) + } + + trustRevoke ({ packageName, id, responseCode = 200, body = { ok: true } }) { + const spec = npa(packageName) + const encodedId = encodeURIComponent(id) + this.nock = this.nock.delete(this.fullPath(`/-/package/${spec.escapedName}/trust/${encodedId}`)) + .reply(responseCode, body) + } } module.exports = MockRegistry diff --git a/smoke-tests/tap-snapshots/test/index.js.test.cjs b/smoke-tests/tap-snapshots/test/index.js.test.cjs index b9b287f3ad18d..767f0f51f204a 100644 --- a/smoke-tests/tap-snapshots/test/index.js.test.cjs +++ b/smoke-tests/tap-snapshots/test/index.js.test.cjs @@ -28,8 +28,9 @@ All commands: link, ll, login, logout, ls, org, outdated, owner, pack, ping, pkg, prefix, profile, prune, publish, query, rebuild, repo, restart, root, run, sbom, search, set, shrinkwrap, - star, stars, start, stop, team, test, token, undeprecate, - uninstall, unpublish, unstar, update, version, view, whoami + star, stars, start, stop, team, test, token, trust, + undeprecate, uninstall, unpublish, unstar, update, version, + view, whoami Specify configs in the ini-formatted file: {NPM}/{TESTDIR}/home/.npmrc @@ -62,6 +63,55 @@ npm error [--no-bin-links] [--no-fund] [--dry-run] npm error [-w|--workspace [-w|--workspace ...]] npm error [--workspaces] [--include-workspace-root] [--install-links] npm error +npm error --install-strategy +npm error Sets the strategy for installing packages in node_modules. +npm error +npm error --legacy-bundling +npm error Instead of hoisting package installs in \`node_modules\`, install packages +npm error +npm error --global-style +npm error Only install direct dependencies in the top level \`node_modules\`, +npm error +npm error --omit +npm error Dependency types to omit from the installation tree on disk. +npm error +npm error --include +npm error Option that allows for defining which types of dependencies to install. +npm error +npm error --strict-peer-deps +npm error If set to \`true\`, and \`--legacy-peer-deps\` is not set, then _any_ +npm error +npm error --foreground-scripts +npm error Run all build scripts (ie, \`preinstall\`, \`install\`, and +npm error +npm error --ignore-scripts +npm error If true, npm does not run scripts specified in package.json files. +npm error +npm error --audit +npm error When "true" submit audit reports alongside the current npm command to the +npm error +npm error --bin-links +npm error Tells npm to create symlinks (or \`.cmd\` shims on Windows) for package +npm error +npm error --fund +npm error When "true" displays the message at the end of each \`npm install\` +npm error +npm error --dry-run +npm error Indicates that you don't want npm to make any changes and that it should +npm error +npm error -w||--workspace +npm error Enable running a command in the context of the configured workspaces of the +npm error +npm error --workspaces +npm error Set to true to run the command in the context of **all** configured +npm error +npm error --include-workspace-root +npm error Include the workspace root when workspaces are enabled for a command. +npm error +npm error --install-links +npm error When set file: protocol dependencies will be packed and installed as +npm error +npm error npm error aliases: clean-install, ic, install-clean, isntall-clean npm error npm error Run "npm help ci" for more info diff --git a/tap-snapshots/test/lib/commands/completion.js.test.cjs b/tap-snapshots/test/lib/commands/completion.js.test.cjs index 64759ec6ef9cf..50b21f56f3642 100644 --- a/tap-snapshots/test/lib/commands/completion.js.test.cjs +++ b/tap-snapshots/test/lib/commands/completion.js.test.cjs @@ -6,18 +6,17 @@ */ 'use strict' exports[`test/lib/commands/completion.js TAP completion --no- flags > flags 1`] = ` -Array [ - String( - --no-version - --no-versions - ), -] +Array [] ` exports[`test/lib/commands/completion.js TAP completion commands with no completion > no results 1`] = ` Array [] ` +exports[`test/lib/commands/completion.js TAP completion completion after custom definition flag requiring value > custom definition non-Boolean flag handled 1`] = ` +Array [] +` + exports[`test/lib/commands/completion.js TAP completion completion cannot complete options that take a value in mid-command > does not try to complete option arguments in the middle of a command 1`] = ` Array [] ` @@ -38,134 +37,16 @@ exports[`test/lib/commands/completion.js TAP completion completion of invalid co Array [] ` +exports[`test/lib/commands/completion.js TAP completion completion with double-dash escape in command line > double-dash escape handled 1`] = ` +Array [] +` + +exports[`test/lib/commands/completion.js TAP completion completion with non-flag word > non-flag word completion 1`] = ` +Array [] +` + exports[`test/lib/commands/completion.js TAP completion double dashes escape from flag completion > full command list 1`] = ` -Array [ - String( - access - adduser - audit - bugs - cache - ci - completion - config - dedupe - deprecate - diff - dist-tag - docs - doctor - edit - exec - explain - explore - find-dupes - fund - get - help - help-search - init - install - install-ci-test - install-test - link - ll - login - logout - ls - org - outdated - owner - pack - ping - pkg - prefix - profile - prune - publish - query - rebuild - repo - restart - root - run - sbom - search - set - shrinkwrap - star - stars - start - stop - team - test - token - undeprecate - uninstall - unpublish - unstar - update - version - view - whoami - author - home - issues - info - show - find - add - unlink - remove - rm - r - un - rb - list - ln - create - i - it - cit - up - c - s - se - tst - t - ddp - v - run-script - clean-install - clean-install-test - x - why - la - verison - ic - innit - in - ins - inst - insta - instal - isnt - isnta - isntal - isntall - install-clean - isntall-clean - hlep - dist-tags - upgrade - udpate - rum - sit - urn - ogr - add-user - ), -] +Array [] ` exports[`test/lib/commands/completion.js TAP completion filtered subcommands > filtered subcommands 1`] = ` @@ -173,15 +54,7 @@ Array [] ` exports[`test/lib/commands/completion.js TAP completion flags > flags 1`] = ` -Array [ - String( - --version - --versions - --viewer - --verbose - --v - ), -] +Array [] ` exports[`test/lib/commands/completion.js TAP completion multiple command names > multiple command names 1`] = ` @@ -215,6 +88,61 @@ Array [ ] ` +exports[`test/lib/commands/completion.js TAP completion trust filtered subcommands > trust filtered subcommands 1`] = ` +Array [ + String( + github + gitlab + ), +] +` + +exports[`test/lib/commands/completion.js TAP completion trust github flags > trust github flags with custom definitions 1`] = ` +Array [ + String( + --yes + --json + --dry-run + --file + --repository + --repo + --environment + --env + --no-yes + --no-json + --no-dry-run + ), +] +` + +exports[`test/lib/commands/completion.js TAP completion trust gitlab flags > trust gitlab flags with custom definitions 1`] = ` +Array [ + String( + --yes + --json + --dry-run + --file + --project + --environment + --env + --no-yes + --no-json + --no-dry-run + ), +] +` + +exports[`test/lib/commands/completion.js TAP completion trust subcommands > trust subcommands 1`] = ` +Array [ + String( + github + gitlab + list + revoke + ), +] +` + exports[`test/lib/commands/completion.js TAP windows without bash > no output 1`] = ` Array [] ` diff --git a/tap-snapshots/test/lib/commands/install.js.test.cjs b/tap-snapshots/test/lib/commands/install.js.test.cjs index 3c9fa9bbec447..30331b77af13b 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:251:27) +verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:312:7) +verbose stack at MockNpm.exec ({CWD}/lib/npm.js:211: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:251:27) +verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:312:7) +verbose stack at MockNpm.exec ({CWD}/lib/npm.js:211: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:251:27) +verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:312:7) +verbose stack at MockNpm.exec ({CWD}/lib/npm.js:211: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..e7507118a28f5 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", @@ -208,6 +211,7 @@ Object { "man/man1/npm-team.1", "man/man1/npm-test.1", "man/man1/npm-token.1", + "man/man1/npm-trust.1", "man/man1/npm-undeprecate.1", "man/man1/npm-uninstall.1", "man/man1/npm-unpublish.1", diff --git a/tap-snapshots/test/lib/docs.js.test.cjs b/tap-snapshots/test/lib/docs.js.test.cjs index ba3a537259c59..702a50a07a532 100644 --- a/tap-snapshots/test/lib/docs.js.test.cjs +++ b/tap-snapshots/test/lib/docs.js.test.cjs @@ -155,6 +155,7 @@ Array [ "team", "test", "token", + "trust", "undeprecate", "uninstall", "unpublish", @@ -2754,6 +2755,16 @@ npm access revoke [] Options: [--json] [--otp ] [--registry ] + --json + Whether or not to output JSON data, rather than the normal output. + + --otp + This is a one-time password from a two-factor authenticator. It's needed + + --registry + The base URL of the npm registry. + + Run "npm help access" for more info \`\`\`bash @@ -2782,6 +2793,16 @@ npm adduser Options: [--registry ] [--scope <@scope>] [--auth-type ] + --registry + The base URL of the npm registry. + + --scope + Associate an operation with a scope for a scoped registry. + + --auth-type + What authentication strategy to use with \`login\`. + + alias: add-user Run "npm help adduser" for more info @@ -2814,6 +2835,49 @@ Options: [-w|--workspace [-w|--workspace ...]] [--workspaces] [--include-workspace-root] [--install-links] + --audit-level + The minimum level of vulnerability for \`npm audit\` to exit with + + --dry-run + Indicates that you don't want npm to make any changes and that it should + + -f||--force + Removes various protections against unfortunate side effects, common + + --json + Whether or not to output JSON data, rather than the normal output. + + --package-lock-only + If set to true, the current operation will only use the \`package-lock.json\`, + + --package-lock + If set to false, then ignore \`package-lock.json\` files when installing. + + --omit + Dependency types to omit from the installation tree on disk. + + --include + Option that allows for defining which types of dependencies to install. + + --foreground-scripts + Run all build scripts (ie, \`preinstall\`, \`install\`, and + + --ignore-scripts + If true, npm does not run scripts specified in package.json files. + + -w||--workspace + Enable running a command in the context of the configured workspaces of the + + --workspaces + Set to true to run the command in the context of **all** configured + + --include-workspace-root + Include the workspace root when workspaces are enabled for a command. + + --install-links + When set file: protocol dependencies will be packed and installed as + + Run "npm help audit" for more info \`\`\`bash @@ -2847,6 +2911,22 @@ Options: [-w|--workspace [-w|--workspace ...]] [--workspaces] [--include-workspace-root] + --browser + The browser that is called by npm commands to open websites. + + --registry + The base URL of the npm registry. + + -w||--workspace + Enable running a command in the context of the configured workspaces of the + + --workspaces + Set to true to run the command in the context of **all** configured + + --include-workspace-root + Include the workspace root when workspaces are enabled for a command. + + alias: issues Run "npm help bugs" for more info @@ -2879,6 +2959,10 @@ npm cache npx info ... Options: [--cache ] + --cache + The location of npm's cache directory. + + Run "npm help cache" for more info \`\`\`bash @@ -2911,6 +2995,55 @@ Options: [-w|--workspace [-w|--workspace ...]] [--workspaces] [--include-workspace-root] [--install-links] + --install-strategy + Sets the strategy for installing packages in node_modules. + + --legacy-bundling + Instead of hoisting package installs in \`node_modules\`, install packages + + --global-style + Only install direct dependencies in the top level \`node_modules\`, + + --omit + Dependency types to omit from the installation tree on disk. + + --include + Option that allows for defining which types of dependencies to install. + + --strict-peer-deps + If set to \`true\`, and \`--legacy-peer-deps\` is not set, then _any_ + + --foreground-scripts + Run all build scripts (ie, \`preinstall\`, \`install\`, and + + --ignore-scripts + If true, npm does not run scripts specified in package.json files. + + --audit + When "true" submit audit reports alongside the current npm command to the + + --bin-links + Tells npm to create symlinks (or \`.cmd\` shims on Windows) for package + + --fund + When "true" displays the message at the end of each \`npm install\` + + --dry-run + Indicates that you don't want npm to make any changes and that it should + + -w||--workspace + Enable running a command in the context of the configured workspaces of the + + --workspaces + Set to true to run the command in the context of **all** configured + + --include-workspace-root + Include the workspace root when workspaces are enabled for a command. + + --install-links + When set file: protocol dependencies will be packed and installed as + + aliases: clean-install, ic, install-clean, isntall-clean Run "npm help ci" for more info @@ -2945,6 +3078,9 @@ Tab Completion for npm Usage: npm completion +Options: + + Run "npm help completion" for more info \`\`\`bash @@ -2971,6 +3107,22 @@ Options: [--json] [-g|--global] [--editor ] [-L|--location ] [-l|--long] + --json + Whether or not to output JSON data, rather than the normal output. + + -g||--global + Operates in "global" mode, so that packages are installed into the + + --editor + The command to run for \`npm edit\` and \`npm config edit\`. + + -L||--location + When passed to \`npm config\` this refers to which config file to use. + + -l||--long + Show extended information in \`ls\`, \`search\`, and \`help-search\`. + + alias: c Run "npm help config" for more info @@ -3010,6 +3162,55 @@ Options: [-w|--workspace [-w|--workspace ...]] [--workspaces] [--include-workspace-root] [--install-links] + --install-strategy + Sets the strategy for installing packages in node_modules. + + --legacy-bundling + Instead of hoisting package installs in \`node_modules\`, install packages + + --global-style + Only install direct dependencies in the top level \`node_modules\`, + + --strict-peer-deps + If set to \`true\`, and \`--legacy-peer-deps\` is not set, then _any_ + + --package-lock + If set to false, then ignore \`package-lock.json\` files when installing. + + --omit + Dependency types to omit from the installation tree on disk. + + --include + Option that allows for defining which types of dependencies to install. + + --ignore-scripts + If true, npm does not run scripts specified in package.json files. + + --audit + When "true" submit audit reports alongside the current npm command to the + + --bin-links + Tells npm to create symlinks (or \`.cmd\` shims on Windows) for package + + --fund + When "true" displays the message at the end of each \`npm install\` + + --dry-run + Indicates that you don't want npm to make any changes and that it should + + -w||--workspace + Enable running a command in the context of the configured workspaces of the + + --workspaces + Set to true to run the command in the context of **all** configured + + --include-workspace-root + Include the workspace root when workspaces are enabled for a command. + + --install-links + When set file: protocol dependencies will be packed and installed as + + alias: ddp Run "npm help dedupe" for more info @@ -3047,6 +3248,16 @@ npm deprecate Options: [--registry ] [--otp ] [--dry-run] + --registry + The base URL of the npm registry. + + --otp + This is a one-time password from a two-factor authenticator. It's needed + + --dry-run + Indicates that you don't want npm to make any changes and that it should + + Run "npm help deprecate" for more info \`\`\`bash @@ -3074,6 +3285,46 @@ Options: [-w|--workspace [-w|--workspace ...]] [--workspaces] [--include-workspace-root] + --diff + Define arguments to compare in \`npm diff\`. + + --diff-name-only + Prints only filenames when using \`npm diff\`. + + --diff-unified + The number of lines of context to print in \`npm diff\`. + + --diff-ignore-all-space + Ignore whitespace when comparing lines in \`npm diff\`. + + --diff-no-prefix + Do not show any source or destination prefix in \`npm diff\` output. + + --diff-src-prefix + Source prefix to be used in \`npm diff\` output. + + --diff-dst-prefix + Destination prefix to be used in \`npm diff\` output. + + --diff-text + Treat all files as text in \`npm diff\`. + + -g||--global + Operates in "global" mode, so that packages are installed into the + + --tag + If you ask npm to install a package and don't tell it a specific version, + + -w||--workspace + Enable running a command in the context of the configured workspaces of the + + --workspaces + Set to true to run the command in the context of **all** configured + + --include-workspace-root + Include the workspace root when workspaces are enabled for a command. + + Run "npm help diff" for more info \`\`\`bash @@ -3107,6 +3358,16 @@ Options: [-w|--workspace [-w|--workspace ...]] [--workspaces] [--include-workspace-root] + -w||--workspace + Enable running a command in the context of the configured workspaces of the + + --workspaces + Set to true to run the command in the context of **all** configured + + --include-workspace-root + Include the workspace root when workspaces are enabled for a command. + + alias: dist-tags Run "npm help dist-tag" for more info @@ -3135,6 +3396,22 @@ Options: [-w|--workspace [-w|--workspace ...]] [--workspaces] [--include-workspace-root] + --browser + The browser that is called by npm commands to open websites. + + --registry + The base URL of the npm registry. + + -w||--workspace + Enable running a command in the context of the configured workspaces of the + + --workspaces + Set to true to run the command in the context of **all** configured + + --include-workspace-root + Include the workspace root when workspaces are enabled for a command. + + alias: home Run "npm help docs" for more info @@ -3161,6 +3438,10 @@ npm doctor [connection] [registry] [versions] [environment] [permissions] [cache Options: [--registry ] + --registry + The base URL of the npm registry. + + Run "npm help doctor" for more info \`\`\`bash @@ -3181,6 +3462,10 @@ npm edit [/...] Options: [--editor ] + --editor + The command to run for \`npm edit\` and \`npm config edit\`. + + Run "npm help edit" for more info \`\`\`bash @@ -3206,6 +3491,22 @@ Options: [-w|--workspace [-w|--workspace ...]] [--workspaces] [--include-workspace-root] + --package + The package or packages to install for [\`npm exec\`](/commands/npm-exec) + + -c||--call + Optional companion option for \`npm exec\`, \`npx\` that allows for + + -w||--workspace + Enable running a command in the context of the configured workspaces of the + + --workspaces + Set to true to run the command in the context of **all** configured + + --include-workspace-root + Include the workspace root when workspaces are enabled for a command. + + alias: x Run "npm help exec" for more info @@ -3235,6 +3536,13 @@ npm explain Options: [--json] [-w|--workspace [-w|--workspace ...]] + --json + Whether or not to output JSON data, rather than the normal output. + + -w||--workspace + Enable running a command in the context of the configured workspaces of the + + alias: why Run "npm help explain" for more info @@ -3258,6 +3566,10 @@ npm explore [ -- ] Options: [--shell ] + --shell + The shell to run for the \`npm explore\` command. + + Run "npm help explore" for more info \`\`\`bash @@ -3284,6 +3596,52 @@ Options: [-w|--workspace [-w|--workspace ...]] [--workspaces] [--include-workspace-root] [--install-links] + --install-strategy + Sets the strategy for installing packages in node_modules. + + --legacy-bundling + Instead of hoisting package installs in \`node_modules\`, install packages + + --global-style + Only install direct dependencies in the top level \`node_modules\`, + + --strict-peer-deps + If set to \`true\`, and \`--legacy-peer-deps\` is not set, then _any_ + + --package-lock + If set to false, then ignore \`package-lock.json\` files when installing. + + --omit + Dependency types to omit from the installation tree on disk. + + --include + Option that allows for defining which types of dependencies to install. + + --ignore-scripts + If true, npm does not run scripts specified in package.json files. + + --audit + When "true" submit audit reports alongside the current npm command to the + + --bin-links + Tells npm to create symlinks (or \`.cmd\` shims on Windows) for package + + --fund + When "true" displays the message at the end of each \`npm install\` + + -w||--workspace + Enable running a command in the context of the configured workspaces of the + + --workspaces + Set to true to run the command in the context of **all** configured + + --include-workspace-root + Include the workspace root when workspaces are enabled for a command. + + --install-links + When set file: protocol dependencies will be packed and installed as + + Run "npm help find-dupes" for more info \`\`\`bash @@ -3318,6 +3676,22 @@ Options: [-w|--workspace [-w|--workspace ...]] [--which ] + --json + Whether or not to output JSON data, rather than the normal output. + + --browser + The browser that is called by npm commands to open websites. + + --unicode + When set to true, npm uses unicode characters in the tree output. When + + -w||--workspace + Enable running a command in the context of the configured workspaces of the + + --which + If there are multiple funding sources, which 1-indexed source URL to open. + + Run "npm help fund" for more info \`\`\`bash @@ -3340,6 +3714,10 @@ npm get [ ...] (See \`npm config\`) Options: [-l|--long] + -l||--long + Show extended information in \`ls\`, \`search\`, and \`help-search\`. + + Run "npm help get" for more info \`\`\`bash @@ -3360,6 +3738,10 @@ npm help [] Options: [--viewer ] + --viewer + The program to use to view help content. + + alias: hlep Run "npm help help" for more info @@ -3384,6 +3766,10 @@ npm help-search Options: [-l|--long] + -l||--long + Show extended information in \`ls\`, \`search\`, and \`help-search\`. + + Run "npm help help-search" for more info \`\`\`bash @@ -3409,6 +3795,49 @@ Options: [-w|--workspace [-w|--workspace ...]] [--workspaces] [--no-workspaces-update] [--include-workspace-root] + --init-author-name + The value \`npm init\` should use by default for the package author's name. + + --init-author-url + The value \`npm init\` should use by default for the package author's homepage. + + --init-license + The value \`npm init\` should use by default for the package license. + + --init-module + A module that will be loaded by the \`npm init\` command. See the + + --init-type + The value that \`npm init\` should use by default for the package.json type field. + + --init-version + The value that \`npm init\` should use by default for the package + + --init-private + The value \`npm init\` should use by default for the package's private flag. + + -y||--yes + Automatically answer "yes" to any prompts that npm might print on + + -f||--force + Removes various protections against unfortunate side effects, common + + --scope + Associate an operation with a scope for a scoped registry. + + -w||--workspace + Enable running a command in the context of the configured workspaces of the + + --workspaces + Set to true to run the command in the context of **all** configured + + --workspaces-update + If set to true, the npm cli will run an update after operations that may + + --include-workspace-root + Include the workspace root when workspaces are enabled for a command. + + aliases: create, innit Run "npm help init" for more info @@ -3455,6 +3884,85 @@ Options: [-w|--workspace [-w|--workspace ...]] [--workspaces] [--include-workspace-root] [--install-links] + -S||--save + Save installed packages to a \`package.json\` file as dependencies. + + -E||--save-exact + Dependencies saved to package.json will be configured with an exact + + -g||--global + Operates in "global" mode, so that packages are installed into the + + --install-strategy + Sets the strategy for installing packages in node_modules. + + --legacy-bundling + Instead of hoisting package installs in \`node_modules\`, install packages + + --global-style + Only install direct dependencies in the top level \`node_modules\`, + + --omit + Dependency types to omit from the installation tree on disk. + + --include + Option that allows for defining which types of dependencies to install. + + --strict-peer-deps + If set to \`true\`, and \`--legacy-peer-deps\` is not set, then _any_ + + --prefer-dedupe + Prefer to deduplicate packages if possible, rather than + + --package-lock + If set to false, then ignore \`package-lock.json\` files when installing. + + --package-lock-only + If set to true, the current operation will only use the \`package-lock.json\`, + + --foreground-scripts + Run all build scripts (ie, \`preinstall\`, \`install\`, and + + --ignore-scripts + If true, npm does not run scripts specified in package.json files. + + --audit + When "true" submit audit reports alongside the current npm command to the + + --before + If passed to \`npm install\`, will rebuild the npm tree such that only + + --bin-links + Tells npm to create symlinks (or \`.cmd\` shims on Windows) for package + + --fund + When "true" displays the message at the end of each \`npm install\` + + --dry-run + Indicates that you don't want npm to make any changes and that it should + + --cpu + Override CPU architecture of native modules to install. + + --os + Override OS of native modules to install. + + --libc + Override libc of native modules to install. + + -w||--workspace + Enable running a command in the context of the configured workspaces of the + + --workspaces + Set to true to run the command in the context of **all** configured + + --include-workspace-root + Include the workspace root when workspaces are enabled for a command. + + --install-links + When set file: protocol dependencies will be packed and installed as + + aliases: add, i, in, ins, inst, insta, instal, isnt, isnta, isntal, isntall Run "npm help install" for more info @@ -3508,6 +4016,55 @@ Options: [-w|--workspace [-w|--workspace ...]] [--workspaces] [--include-workspace-root] [--install-links] + --install-strategy + Sets the strategy for installing packages in node_modules. + + --legacy-bundling + Instead of hoisting package installs in \`node_modules\`, install packages + + --global-style + Only install direct dependencies in the top level \`node_modules\`, + + --omit + Dependency types to omit from the installation tree on disk. + + --include + Option that allows for defining which types of dependencies to install. + + --strict-peer-deps + If set to \`true\`, and \`--legacy-peer-deps\` is not set, then _any_ + + --foreground-scripts + Run all build scripts (ie, \`preinstall\`, \`install\`, and + + --ignore-scripts + If true, npm does not run scripts specified in package.json files. + + --audit + When "true" submit audit reports alongside the current npm command to the + + --bin-links + Tells npm to create symlinks (or \`.cmd\` shims on Windows) for package + + --fund + When "true" displays the message at the end of each \`npm install\` + + --dry-run + Indicates that you don't want npm to make any changes and that it should + + -w||--workspace + Enable running a command in the context of the configured workspaces of the + + --workspaces + Set to true to run the command in the context of **all** configured + + --include-workspace-root + Include the workspace root when workspaces are enabled for a command. + + --install-links + When set file: protocol dependencies will be packed and installed as + + aliases: cit, clean-install-test, sit Run "npm help install-ci-test" for more info @@ -3555,6 +4112,85 @@ Options: [-w|--workspace [-w|--workspace ...]] [--workspaces] [--include-workspace-root] [--install-links] + -S||--save + Save installed packages to a \`package.json\` file as dependencies. + + -E||--save-exact + Dependencies saved to package.json will be configured with an exact + + -g||--global + Operates in "global" mode, so that packages are installed into the + + --install-strategy + Sets the strategy for installing packages in node_modules. + + --legacy-bundling + Instead of hoisting package installs in \`node_modules\`, install packages + + --global-style + Only install direct dependencies in the top level \`node_modules\`, + + --omit + Dependency types to omit from the installation tree on disk. + + --include + Option that allows for defining which types of dependencies to install. + + --strict-peer-deps + If set to \`true\`, and \`--legacy-peer-deps\` is not set, then _any_ + + --prefer-dedupe + Prefer to deduplicate packages if possible, rather than + + --package-lock + If set to false, then ignore \`package-lock.json\` files when installing. + + --package-lock-only + If set to true, the current operation will only use the \`package-lock.json\`, + + --foreground-scripts + Run all build scripts (ie, \`preinstall\`, \`install\`, and + + --ignore-scripts + If true, npm does not run scripts specified in package.json files. + + --audit + When "true" submit audit reports alongside the current npm command to the + + --before + If passed to \`npm install\`, will rebuild the npm tree such that only + + --bin-links + Tells npm to create symlinks (or \`.cmd\` shims on Windows) for package + + --fund + When "true" displays the message at the end of each \`npm install\` + + --dry-run + Indicates that you don't want npm to make any changes and that it should + + --cpu + Override CPU architecture of native modules to install. + + --os + Override OS of native modules to install. + + --libc + Override libc of native modules to install. + + -w||--workspace + Enable running a command in the context of the configured workspaces of the + + --workspaces + Set to true to run the command in the context of **all** configured + + --include-workspace-root + Include the workspace root when workspaces are enabled for a command. + + --install-links + When set file: protocol dependencies will be packed and installed as + + alias: it Run "npm help install-test" for more info @@ -3610,6 +4246,64 @@ Options: [-w|--workspace [-w|--workspace ...]] [--workspaces] [--include-workspace-root] [--install-links] + -S||--save + Save installed packages to a \`package.json\` file as dependencies. + + -E||--save-exact + Dependencies saved to package.json will be configured with an exact + + -g||--global + Operates in "global" mode, so that packages are installed into the + + --install-strategy + Sets the strategy for installing packages in node_modules. + + --legacy-bundling + Instead of hoisting package installs in \`node_modules\`, install packages + + --global-style + Only install direct dependencies in the top level \`node_modules\`, + + --strict-peer-deps + If set to \`true\`, and \`--legacy-peer-deps\` is not set, then _any_ + + --package-lock + If set to false, then ignore \`package-lock.json\` files when installing. + + --omit + Dependency types to omit from the installation tree on disk. + + --include + Option that allows for defining which types of dependencies to install. + + --ignore-scripts + If true, npm does not run scripts specified in package.json files. + + --audit + When "true" submit audit reports alongside the current npm command to the + + --bin-links + Tells npm to create symlinks (or \`.cmd\` shims on Windows) for package + + --fund + When "true" displays the message at the end of each \`npm install\` + + --dry-run + Indicates that you don't want npm to make any changes and that it should + + -w||--workspace + Enable running a command in the context of the configured workspaces of the + + --workspaces + Set to true to run the command in the context of **all** configured + + --include-workspace-root + Include the workspace root when workspaces are enabled for a command. + + --install-links + When set file: protocol dependencies will be packed and installed as + + alias: ln Run "npm help link" for more info @@ -3655,6 +4349,52 @@ Options: [-w|--workspace [-w|--workspace ...]] [--workspaces] [--include-workspace-root] [--install-links] + -a||--all + When running \`npm outdated\` and \`npm ls\`, setting \`--all\` will show + + --json + Whether or not to output JSON data, rather than the normal output. + + -l||--long + Show extended information in \`ls\`, \`search\`, and \`help-search\`. + + -p||--parseable + Output parseable results from commands that write to standard output. For + + -g||--global + Operates in "global" mode, so that packages are installed into the + + --depth + The depth to go when recursing packages for \`npm ls\`. + + --omit + Dependency types to omit from the installation tree on disk. + + --include + Option that allows for defining which types of dependencies to install. + + --link + Used with \`npm ls\`, limiting output to only those packages that are + + --package-lock-only + If set to true, the current operation will only use the \`package-lock.json\`, + + --unicode + When set to true, npm uses unicode characters in the tree output. When + + -w||--workspace + Enable running a command in the context of the configured workspaces of the + + --workspaces + Set to true to run the command in the context of **all** configured + + --include-workspace-root + Include the workspace root when workspaces are enabled for a command. + + --install-links + When set file: protocol dependencies will be packed and installed as + + alias: la Run "npm help ll" for more info @@ -3691,6 +4431,16 @@ npm login Options: [--registry ] [--scope <@scope>] [--auth-type ] + --registry + The base URL of the npm registry. + + --scope + Associate an operation with a scope for a scoped registry. + + --auth-type + What authentication strategy to use with \`login\`. + + Run "npm help login" for more info \`\`\`bash @@ -3713,6 +4463,13 @@ npm logout Options: [--registry ] [--scope <@scope>] + --registry + The base URL of the npm registry. + + --scope + Associate an operation with a scope for a scoped registry. + + Run "npm help logout" for more info \`\`\`bash @@ -3739,6 +4496,52 @@ Options: [-w|--workspace [-w|--workspace ...]] [--workspaces] [--include-workspace-root] [--install-links] + -a||--all + When running \`npm outdated\` and \`npm ls\`, setting \`--all\` will show + + --json + Whether or not to output JSON data, rather than the normal output. + + -l||--long + Show extended information in \`ls\`, \`search\`, and \`help-search\`. + + -p||--parseable + Output parseable results from commands that write to standard output. For + + -g||--global + Operates in "global" mode, so that packages are installed into the + + --depth + The depth to go when recursing packages for \`npm ls\`. + + --omit + Dependency types to omit from the installation tree on disk. + + --include + Option that allows for defining which types of dependencies to install. + + --link + Used with \`npm ls\`, limiting output to only those packages that are + + --package-lock-only + If set to true, the current operation will only use the \`package-lock.json\`, + + --unicode + When set to true, npm uses unicode characters in the tree output. When + + -w||--workspace + Enable running a command in the context of the configured workspaces of the + + --workspaces + Set to true to run the command in the context of **all** configured + + --include-workspace-root + Include the workspace root when workspaces are enabled for a command. + + --install-links + When set file: protocol dependencies will be packed and installed as + + alias: list Run "npm help ls" for more info @@ -3798,6 +4601,19 @@ npm org ls orgname [] Options: [--registry ] [--otp ] [--json] [-p|--parseable] + --registry + The base URL of the npm registry. + + --otp + This is a one-time password from a two-factor authenticator. It's needed + + --json + Whether or not to output JSON data, rather than the normal output. + + -p||--parseable + Output parseable results from commands that write to standard output. For + + alias: ogr Run "npm help org" for more info @@ -3829,6 +4645,28 @@ Options: [-w|--workspace [-w|--workspace ...]] [--before ] + -a||--all + When running \`npm outdated\` and \`npm ls\`, setting \`--all\` will show + + --json + Whether or not to output JSON data, rather than the normal output. + + -l||--long + Show extended information in \`ls\`, \`search\`, and \`help-search\`. + + -p||--parseable + Output parseable results from commands that write to standard output. For + + -g||--global + Operates in "global" mode, so that packages are installed into the + + -w||--workspace + Enable running a command in the context of the configured workspaces of the + + --before + If passed to \`npm install\`, will rebuild the npm tree such that only + + Run "npm help outdated" for more info \`\`\`bash @@ -3857,6 +4695,19 @@ Options: [-w|--workspace [-w|--workspace ...]] [--workspaces] + --registry + The base URL of the npm registry. + + --otp + This is a one-time password from a two-factor authenticator. It's needed + + -w||--workspace + Enable running a command in the context of the configured workspaces of the + + --workspaces + Set to true to run the command in the context of **all** configured + + alias: author Run "npm help owner" for more info @@ -3886,6 +4737,28 @@ Options: [-w|--workspace [-w|--workspace ...]] [--workspaces] [--include-workspace-root] [--ignore-scripts] + --dry-run + Indicates that you don't want npm to make any changes and that it should + + --json + Whether or not to output JSON data, rather than the normal output. + + --pack-destination + Directory in which \`npm pack\` will save tarballs. + + -w||--workspace + Enable running a command in the context of the configured workspaces of the + + --workspaces + Set to true to run the command in the context of **all** configured + + --include-workspace-root + Include the workspace root when workspaces are enabled for a command. + + --ignore-scripts + If true, npm does not run scripts specified in package.json files. + + Run "npm help pack" for more info \`\`\`bash @@ -3910,6 +4783,10 @@ npm ping Options: [--registry ] + --registry + The base URL of the npm registry. + + Run "npm help ping" for more info \`\`\`bash @@ -3937,6 +4814,19 @@ Options: [-w|--workspace [-w|--workspace ...]] [--workspaces] + -f||--force + Removes various protections against unfortunate side effects, common + + --json + Whether or not to output JSON data, rather than the normal output. + + -w||--workspace + Enable running a command in the context of the configured workspaces of the + + --workspaces + Set to true to run the command in the context of **all** configured + + Run "npm help pkg" for more info \`\`\`bash @@ -3963,6 +4853,10 @@ npm prefix Options: [-g|--global] + -g||--global + Operates in "global" mode, so that packages are installed into the + + Run "npm help prefix" for more info \`\`\`bash @@ -3986,6 +4880,19 @@ npm profile set Options: [--registry ] [--json] [-p|--parseable] [--otp ] + --registry + The base URL of the npm registry. + + --json + Whether or not to output JSON data, rather than the normal output. + + -p||--parseable + Output parseable results from commands that write to standard output. For + + --otp + This is a one-time password from a two-factor authenticator. It's needed + + Run "npm help profile" for more info \`\`\`bash @@ -4016,6 +4923,37 @@ Options: [-w|--workspace [-w|--workspace ...]] [--workspaces] [--include-workspace-root] [--install-links] + --omit + Dependency types to omit from the installation tree on disk. + + --include + Option that allows for defining which types of dependencies to install. + + --dry-run + Indicates that you don't want npm to make any changes and that it should + + --json + Whether or not to output JSON data, rather than the normal output. + + --foreground-scripts + Run all build scripts (ie, \`preinstall\`, \`install\`, and + + --ignore-scripts + If true, npm does not run scripts specified in package.json files. + + -w||--workspace + Enable running a command in the context of the configured workspaces of the + + --workspaces + Set to true to run the command in the context of **all** configured + + --include-workspace-root + Include the workspace root when workspaces are enabled for a command. + + --install-links + When set file: protocol dependencies will be packed and installed as + + Run "npm help prune" for more info \`\`\`bash @@ -4045,6 +4983,28 @@ Options: [-w|--workspace [-w|--workspace ...]] [--workspaces] [--include-workspace-root] [--provenance|--provenance-file ] + --tag + If you ask npm to install a package and don't tell it a specific version, + + --access + If you do not want your scoped package to be publicly viewable (and + + --dry-run + Indicates that you don't want npm to make any changes and that it should + + --otp + This is a one-time password from a two-factor authenticator. It's needed + + -w||--workspace + Enable running a command in the context of the configured workspaces of the + + --workspaces + Set to true to run the command in the context of **all** configured + + --include-workspace-root + Include the workspace root when workspaces are enabled for a command. + + Run "npm help publish" for more info \`\`\`bash @@ -4074,6 +5034,22 @@ Options: [--workspaces] [--include-workspace-root] [--package-lock-only] [--expect-results|--expect-result-count ] + -g||--global + Operates in "global" mode, so that packages are installed into the + + -w||--workspace + Enable running a command in the context of the configured workspaces of the + + --workspaces + Set to true to run the command in the context of **all** configured + + --include-workspace-root + Include the workspace root when workspaces are enabled for a command. + + --package-lock-only + If set to true, the current operation will only use the \`package-lock.json\`, + + Run "npm help query" for more info \`\`\`bash @@ -4100,6 +5076,31 @@ Options: [-w|--workspace [-w|--workspace ...]] [--workspaces] [--include-workspace-root] [--install-links] + -g||--global + Operates in "global" mode, so that packages are installed into the + + --bin-links + Tells npm to create symlinks (or \`.cmd\` shims on Windows) for package + + --foreground-scripts + Run all build scripts (ie, \`preinstall\`, \`install\`, and + + --ignore-scripts + If true, npm does not run scripts specified in package.json files. + + -w||--workspace + Enable running a command in the context of the configured workspaces of the + + --workspaces + Set to true to run the command in the context of **all** configured + + --include-workspace-root + Include the workspace root when workspaces are enabled for a command. + + --install-links + When set file: protocol dependencies will be packed and installed as + + alias: rb Run "npm help rebuild" for more info @@ -4131,6 +5132,22 @@ Options: [-w|--workspace [-w|--workspace ...]] [--workspaces] [--include-workspace-root] + --browser + The browser that is called by npm commands to open websites. + + --registry + The base URL of the npm registry. + + -w||--workspace + Enable running a command in the context of the configured workspaces of the + + --workspaces + Set to true to run the command in the context of **all** configured + + --include-workspace-root + Include the workspace root when workspaces are enabled for a command. + + Run "npm help repo" for more info \`\`\`bash @@ -4153,6 +5170,13 @@ npm restart [-- ] Options: [--ignore-scripts] [--script-shell ] + --ignore-scripts + If true, npm does not run scripts specified in package.json files. + + --script-shell + The shell to use for scripts run with the \`npm exec\`, + + Run "npm help restart" for more info \`\`\`bash @@ -4172,6 +5196,10 @@ npm root Options: [-g|--global] + -g||--global + Operates in "global" mode, so that packages are installed into the + + Run "npm help root" for more info \`\`\`bash @@ -4194,6 +5222,28 @@ Options: [--workspaces] [--include-workspace-root] [--if-present] [--ignore-scripts] [--foreground-scripts] [--script-shell ] + -w||--workspace + Enable running a command in the context of the configured workspaces of the + + --workspaces + Set to true to run the command in the context of **all** configured + + --include-workspace-root + Include the workspace root when workspaces are enabled for a command. + + --if-present + If true, npm will not exit with an error code when \`run\` is + + --ignore-scripts + If true, npm does not run scripts specified in package.json files. + + --foreground-scripts + Run all build scripts (ie, \`preinstall\`, \`install\`, and + + --script-shell + The shell to use for scripts run with the \`npm exec\`, + + aliases: run-script, rum, urn Run "npm help run" for more info @@ -4226,6 +5276,25 @@ Options: [-w|--workspace [-w|--workspace ...]] [--workspaces] + --omit + Dependency types to omit from the installation tree on disk. + + --package-lock-only + If set to true, the current operation will only use the \`package-lock.json\`, + + --sbom-format + SBOM format to use when generating SBOMs. + + --sbom-type + The type of package described by the generated SBOM. For SPDX, this is the + + -w||--workspace + Enable running a command in the context of the configured workspaces of the + + --workspaces + Set to true to run the command in the context of **all** configured + + Run "npm help sbom" for more info \`\`\`bash @@ -4252,6 +5321,40 @@ Options: [--searchexclude ] [--registry ] [--prefer-online] [--prefer-offline] [--offline] + --json + Whether or not to output JSON data, rather than the normal output. + + --color + If false, never shows colors. If \`"always"\` then always shows colors. + + -p||--parseable + Output parseable results from commands that write to standard output. For + + --description + Show the description in \`npm search\` + + --searchlimit + Number of items to limit search results to. Will not apply at all to + + --searchopts + Space-separated options that are always passed to search. + + --searchexclude + Space-separated options that limit the results from search. + + --registry + The base URL of the npm registry. + + --prefer-online + If true, staleness checks for cached data will be forced, making the CLI + + --prefer-offline + If true, staleness checks for cached data will be bypassed, but missing + + --offline + Force offline mode: no network requests will be done during install. To allow + + aliases: find, s, se Run "npm help search" for more info @@ -4286,6 +5389,13 @@ npm set = [= ...] (See \`npm config\`) Options: [-g|--global] [-L|--location ] + -g||--global + Operates in "global" mode, so that packages are installed into the + + -L||--location + When passed to \`npm config\` this refers to which config file to use. + + Run "npm help set" for more info \`\`\`bash @@ -4324,6 +5434,16 @@ npm star [...] Options: [--registry ] [--unicode] [--otp ] + --registry + The base URL of the npm registry. + + --unicode + When set to true, npm uses unicode characters in the tree output. When + + --otp + This is a one-time password from a two-factor authenticator. It's needed + + Run "npm help star" for more info \`\`\`bash @@ -4346,6 +5466,10 @@ npm stars [] Options: [--registry ] + --registry + The base URL of the npm registry. + + Run "npm help stars" for more info \`\`\`bash @@ -4366,6 +5490,13 @@ npm start [-- ] Options: [--ignore-scripts] [--script-shell ] + --ignore-scripts + If true, npm does not run scripts specified in package.json files. + + --script-shell + The shell to use for scripts run with the \`npm exec\`, + + Run "npm help start" for more info \`\`\`bash @@ -4385,6 +5516,13 @@ npm stop [-- ] Options: [--ignore-scripts] [--script-shell ] + --ignore-scripts + If true, npm does not run scripts specified in package.json files. + + --script-shell + The shell to use for scripts run with the \`npm exec\`, + + Run "npm help stop" for more info \`\`\`bash @@ -4408,6 +5546,19 @@ npm team ls | Options: [--registry ] [--otp ] [-p|--parseable] [--json] + --registry + The base URL of the npm registry. + + --otp + This is a one-time password from a two-factor authenticator. It's needed + + -p||--parseable + Output parseable results from commands that write to standard output. For + + --json + Whether or not to output JSON data, rather than the normal output. + + Run "npm help team" for more info \`\`\`bash @@ -4435,6 +5586,13 @@ npm test [-- ] Options: [--ignore-scripts] [--script-shell ] + --ignore-scripts + If true, npm does not run scripts specified in package.json files. + + --script-shell + The shell to use for scripts run with the \`npm exec\`, + + aliases: tst, t Run "npm help test" for more info @@ -4466,6 +5624,52 @@ Options: [--cidr [--cidr ...]] [--bypass-2fa] [--password ] [--registry ] [--otp ] [--read-only] + --name + When creating a Granular Access Token with \`npm token create\`, + + --token-description + Description text for the token when using \`npm token create\`. + + --expires + When creating a Granular Access Token with \`npm token create\`, + + --packages + When creating a Granular Access Token with \`npm token create\`, + + --packages-all + When creating a Granular Access Token with \`npm token create\`, + + --scopes + When creating a Granular Access Token with \`npm token create\`, + + --orgs + When creating a Granular Access Token with \`npm token create\`, + + --packages-and-scopes-permission + When creating a Granular Access Token with \`npm token create\`, + + --orgs-permission + When creating a Granular Access Token with \`npm token create\`, + + --cidr + This is a list of CIDR address to be used when configuring limited access + + --bypass-2fa + When creating a Granular Access Token with \`npm token create\`, + + --password + Password for authentication. Can be provided via command line when + + --registry + The base URL of the npm registry. + + --otp + This is a one-time password from a two-factor authenticator. It's needed + + --read-only + This is used to mark a token as unable to publish when configuring + + Run "npm help token" for more info \`\`\`bash @@ -4493,6 +5697,60 @@ Note: This command is unaware of workspaces. #### \`read-only\` ` +exports[`test/lib/docs.js TAP usage trust > must match snapshot 1`] = ` +Create a trusted relationship between a package and a OIDC provider + +Usage: +npm trust + +Subcommands: + github + Create a trusted relationship between a package and GitHub Actions + + gitlab + Create a trusted relationship between a package and GitLab CI/CD + + list + List trusted relationships for a package + + revoke + Revoke a trusted relationship for a package + +Run "npm trust --help" for more info on a subcommand. + +Run "npm help trust" for more info + +\`\`\`bash +npm trust +\`\`\` + +Note: This command is unaware of workspaces. + +#### Synopsis +#### Flags +#### \`file\` +#### \`repository\` +#### \`environment\` +#### \`yes\` +#### \`json\` +#### \`dry-run\` +#### Synopsis +#### Flags +#### \`file\` +#### \`project\` +#### \`environment\` +#### \`yes\` +#### \`json\` +#### \`dry-run\` +#### Synopsis +#### Configuration +#### \`json\` +#### Synopsis +#### Flags +#### \`id\` +#### \`dry-run\` +` + exports[`test/lib/docs.js TAP usage undeprecate > must match snapshot 1`] = ` Undeprecate a version of a package @@ -4502,6 +5760,16 @@ npm undeprecate Options: [--registry ] [--otp ] [--dry-run] + --registry + The base URL of the npm registry. + + --otp + This is a one-time password from a two-factor authenticator. It's needed + + --dry-run + Indicates that you don't want npm to make any changes and that it should + + Run "npm help undeprecate" for more info \`\`\`bash @@ -4527,6 +5795,25 @@ Options: [-w|--workspace [-w|--workspace ...]] [--workspaces] [--include-workspace-root] [--install-links] + -S||--save + Save installed packages to a \`package.json\` file as dependencies. + + -g||--global + Operates in "global" mode, so that packages are installed into the + + -w||--workspace + Enable running a command in the context of the configured workspaces of the + + --workspaces + Set to true to run the command in the context of **all** configured + + --include-workspace-root + Include the workspace root when workspaces are enabled for a command. + + --install-links + When set file: protocol dependencies will be packed and installed as + + aliases: unlink, remove, rm, r, un Run "npm help uninstall" for more info @@ -4556,6 +5843,19 @@ Options: [-w|--workspace [-w|--workspace ...]] [--workspaces] + --dry-run + Indicates that you don't want npm to make any changes and that it should + + -f||--force + Removes various protections against unfortunate side effects, common + + -w||--workspace + Enable running a command in the context of the configured workspaces of the + + --workspaces + Set to true to run the command in the context of **all** configured + + Run "npm help unpublish" for more info \`\`\`bash @@ -4577,6 +5877,16 @@ npm unstar [...] Options: [--registry ] [--unicode] [--otp ] + --registry + The base URL of the npm registry. + + --unicode + When set to true, npm uses unicode characters in the tree output. When + + --otp + This is a one-time password from a two-factor authenticator. It's needed + + Run "npm help unstar" for more info \`\`\`bash @@ -4608,6 +5918,67 @@ Options: [-w|--workspace [-w|--workspace ...]] [--workspaces] [--include-workspace-root] [--install-links] + -S||--save + Save installed packages to a \`package.json\` file as dependencies. + + -g||--global + Operates in "global" mode, so that packages are installed into the + + --install-strategy + Sets the strategy for installing packages in node_modules. + + --legacy-bundling + Instead of hoisting package installs in \`node_modules\`, install packages + + --global-style + Only install direct dependencies in the top level \`node_modules\`, + + --omit + Dependency types to omit from the installation tree on disk. + + --include + Option that allows for defining which types of dependencies to install. + + --strict-peer-deps + If set to \`true\`, and \`--legacy-peer-deps\` is not set, then _any_ + + --package-lock + If set to false, then ignore \`package-lock.json\` files when installing. + + --foreground-scripts + Run all build scripts (ie, \`preinstall\`, \`install\`, and + + --ignore-scripts + If true, npm does not run scripts specified in package.json files. + + --audit + When "true" submit audit reports alongside the current npm command to the + + --before + If passed to \`npm install\`, will rebuild the npm tree such that only + + --bin-links + Tells npm to create symlinks (or \`.cmd\` shims on Windows) for package + + --fund + When "true" displays the message at the end of each \`npm install\` + + --dry-run + Indicates that you don't want npm to make any changes and that it should + + -w||--workspace + Enable running a command in the context of the configured workspaces of the + + --workspaces + Set to true to run the command in the context of **all** configured + + --include-workspace-root + Include the workspace root when workspaces are enabled for a command. + + --install-links + When set file: protocol dependencies will be packed and installed as + + aliases: up, upgrade, udpate Run "npm help update" for more info @@ -4654,6 +6025,43 @@ Options: [--workspaces] [--no-workspaces-update] [--include-workspace-root] [--ignore-scripts] + --allow-same-version + Prevents throwing an error when \`npm version\` is used to set the new + + --commit-hooks + Run git commit hooks when using the \`npm version\` command. + + --git-tag-version + Tag the commit when using the \`npm version\` command. Setting this to + + --json + Whether or not to output JSON data, rather than the normal output. + + --preid + The "prerelease identifier" to use as a prefix for the "prerelease" part + + --sign-git-tag + If set to true, then the \`npm version\` command will tag the version + + -S||--save + Save installed packages to a \`package.json\` file as dependencies. + + -w||--workspace + Enable running a command in the context of the configured workspaces of the + + --workspaces + Set to true to run the command in the context of **all** configured + + --workspaces-update + If set to true, the npm cli will run an update after operations that may + + --include-workspace-root + Include the workspace root when workspaces are enabled for a command. + + --ignore-scripts + If true, npm does not run scripts specified in package.json files. + + alias: verison Run "npm help version" for more info @@ -4688,6 +6096,19 @@ Options: [--json] [-w|--workspace [-w|--workspace ...]] [--workspaces] [--include-workspace-root] + --json + Whether or not to output JSON data, rather than the normal output. + + -w||--workspace + Enable running a command in the context of the configured workspaces of the + + --workspaces + Set to true to run the command in the context of **all** configured + + --include-workspace-root + Include the workspace root when workspaces are enabled for a command. + + aliases: info, show, v Run "npm help view" for more info @@ -4713,6 +6134,10 @@ npm whoami Options: [--registry ] + --registry + The base URL of the npm registry. + + Run "npm help whoami" for more info \`\`\`bash diff --git a/tap-snapshots/test/lib/npm.js.test.cjs b/tap-snapshots/test/lib/npm.js.test.cjs index ca42f13356278..888af047882fa 100644 --- a/tap-snapshots/test/lib/npm.js.test.cjs +++ b/tap-snapshots/test/lib/npm.js.test.cjs @@ -38,8 +38,9 @@ All commands: link, ll, login, logout, ls, org, outdated, owner, pack, ping, pkg, prefix, profile, prune, publish, query, rebuild, repo, restart, root, run, sbom, search, set, shrinkwrap, - star, stars, start, stop, team, test, token, undeprecate, - uninstall, unpublish, unstar, update, version, view, whoami + star, stars, start, stop, team, test, token, trust, + undeprecate, uninstall, unpublish, unstar, update, version, + view, whoami Specify configs in the ini-formatted file: {USERCONFIG} @@ -88,10 +89,11 @@ All commands: sbom, search, set, shrinkwrap, star, stars, start, stop, team, test, - token, undeprecate, - uninstall, unpublish, - unstar, update, version, - view, whoami + token, trust, + undeprecate, uninstall, + unpublish, unstar, + update, version, view, + whoami Specify configs in the ini-formatted file: {USERCONFIG} @@ -140,10 +142,11 @@ All commands: sbom, search, set, shrinkwrap, star, stars, start, stop, team, test, - token, undeprecate, - uninstall, unpublish, - unstar, update, version, - view, whoami + token, trust, + undeprecate, uninstall, + unpublish, unstar, + update, version, view, + whoami Specify configs in the ini-formatted file: {USERCONFIG} @@ -178,8 +181,9 @@ All commands: link, ll, login, logout, ls, org, outdated, owner, pack, ping, pkg, prefix, profile, prune, publish, query, rebuild, repo, restart, root, run, sbom, search, set, shrinkwrap, - star, stars, start, stop, team, test, token, undeprecate, - uninstall, unpublish, unstar, update, version, view, whoami + star, stars, start, stop, team, test, token, trust, + undeprecate, uninstall, unpublish, unstar, update, version, + view, whoami Specify configs in the ini-formatted file: {USERCONFIG} @@ -228,10 +232,11 @@ All commands: sbom, search, set, shrinkwrap, star, stars, start, stop, team, test, - token, undeprecate, - uninstall, unpublish, - unstar, update, version, - view, whoami + token, trust, + undeprecate, uninstall, + unpublish, unstar, + update, version, view, + whoami Specify configs in the ini-formatted file: {USERCONFIG} @@ -280,10 +285,11 @@ All commands: sbom, search, set, shrinkwrap, star, stars, start, stop, team, test, - token, undeprecate, - uninstall, unpublish, - unstar, update, version, - view, whoami + token, trust, + undeprecate, uninstall, + unpublish, unstar, + update, version, view, + whoami Specify configs in the ini-formatted file: {USERCONFIG} @@ -330,7 +336,7 @@ All commands: restart, root, run, sbom, search, set, shrinkwrap, star, stars, start, stop, - team, test, token, + team, test, token, trust, undeprecate, uninstall, unpublish, unstar, update, version, view, @@ -369,9 +375,9 @@ All commands: link, ll, login, logout, ls, org, outdated, owner, pack, ping, pkg, prefix, profile, prune, publish, query, rebuild, repo, restart, root, run, sbom, search, set, shrinkwrap, - star, stars, start, stop, team, test, token, undeprecate, - uninstall, unpublish, unstar, update, version, view, - whoami + star, stars, start, stop, team, test, token, trust, + undeprecate, uninstall, unpublish, unstar, update, version, + view, whoami Specify configs in the ini-formatted file: {USERCONFIG} @@ -406,8 +412,9 @@ All commands: link, ll, login, logout, ls, org, outdated, owner, pack, ping, pkg, prefix, profile, prune, publish, query, rebuild, repo, restart, root, run, sbom, search, set, shrinkwrap, - star, stars, start, stop, team, test, token, undeprecate, - uninstall, unpublish, unstar, update, version, view, whoami + star, stars, start, stop, team, test, token, trust, + undeprecate, uninstall, unpublish, unstar, update, version, + view, whoami Specify configs in the ini-formatted file: {USERCONFIG} @@ -442,8 +449,9 @@ All commands: link, ll, login, logout, ls, org, outdated, owner, pack, ping, pkg, prefix, profile, prune, publish, query, rebuild, repo, restart, root, run, sbom, search, set, shrinkwrap, - star, stars, start, stop, team, test, token, undeprecate, - uninstall, unpublish, unstar, update, version, view, whoami + star, stars, start, stop, team, test, token, trust, + undeprecate, uninstall, unpublish, unstar, update, version, + view, whoami Specify configs in the ini-formatted file: {USERCONFIG} diff --git a/test/fixtures/mock-npm.js b/test/fixtures/mock-npm.js index bac95964c9306..0b29fc934d84c 100644 --- a/test/fixtures/mock-npm.js +++ b/test/fixtures/mock-npm.js @@ -83,6 +83,16 @@ const getMockNpm = async (t, { mocks, init, load, npm: npmOpts }) => { await Promise.all(this.unrefPromises) return res } + + async exec (cmd, args = this.argv) { + // In tests, when exec is called with args, update config.argv to include them + // This mimics production where config.argv contains the full command line + if (args && args !== this.argv) { + // Build full argv: ['node', 'npm', cmd, ...args] + this.config.argv = [process.argv[0], process.argv[1], cmd, ...args] + } + return super.exec(cmd, args) + } } const npm = init ? new MockNpm() : null diff --git a/test/lib/base-cmd.js b/test/lib/base-cmd.js new file mode 100644 index 0000000000000..1d4b77c2539f3 --- /dev/null +++ b/test/lib/base-cmd.js @@ -0,0 +1,678 @@ +const t = require('tap') +const { load: loadMockNpm } = require('../fixtures/mock-npm') +const BaseCommand = require('../../lib/base-cmd.js') +const Definition = require('@npmcli/config/lib/definitions/definition.js') + +t.test('flags() method with command definitions', async t => { + const { npm } = await loadMockNpm(t, { + config: { + mountain: 'kilimanjaro', + }, + }) + + class TestCommand extends BaseCommand { + static name = 'test-command' + static description = 'Test command' + static params = ['mountain'] + + static definitions = { + mountain: new Definition('mountain', { + type: String, + default: 'everest', + description: 'Your favorite mountain', + usage: '--mountain=', + }), + } + + async exec () { + return this.flags() + } + } + + const command = new TestCommand(npm) + const [flags] = await command.exec() + + t.ok(flags, 'flags() returns an object') + t.equal(flags.mountain, 'kilimanjaro', 'includes config value when set') +}) + +t.test('flags() method with default values', async t => { + const { npm } = await loadMockNpm(t) + + class TestCommand extends BaseCommand { + static name = 'test-command' + static description = 'Test command' + static params = ['mountain'] + + static definitions = { + mountain: new Definition('mountain', { + type: String, + default: 'everest', + description: 'Your favorite mountain', + usage: '--mountain=', + }), + } + + async exec () { + return this.flags() + } + } + + const command = new TestCommand(npm) + const [flags] = await command.exec() + + t.equal(flags.mountain, 'everest', 'uses default value when not set') +}) + +t.test('flags() method filters unknown options', async t => { + const { npm } = await loadMockNpm(t, { + // npm.config.argv would have both known and unknown flags parsed + config: { + mountain: 'denali', + }, + }) + + class TestCommand extends BaseCommand { + static name = 'test-command' + static description = 'Test command' + static params = ['mountain'] + + static definitions = { + mountain: new Definition('mountain', { + type: String, + default: 'everest', + description: 'Your favorite mountain', + usage: '--mountain=', + }), + } + + async exec () { + return this.flags() + } + } + + const command = new TestCommand(npm) + const [flags] = await command.exec() + + t.equal(flags.mountain, 'denali', 'includes known flag') + t.notOk(flags.bug, 'filters out unknown flags') + t.same(Object.keys(flags), ['mountain'], 'only includes defined keys') +}) + +t.test('flags() method with no definitions', async t => { + const { npm } = await loadMockNpm(t) + + class TestCommand extends BaseCommand { + static name = 'test-command' + static description = 'Test command' + + async exec () { + return this.flags() + } + } + + const command = new TestCommand(npm) + const [flags] = await command.exec() + + t.same(flags, {}, 'returns empty object when no definitions') +}) + +t.test('flags() throws error for unknown flags', async t => { + const { npm } = await loadMockNpm(t) + + class TestCommand extends BaseCommand { + static name = 'test-command' + static description = 'Test command' + static params = ['mountain'] + + static definitions = { + mountain: new Definition('mountain', { + type: String, + default: 'everest', + description: 'Your favorite mountain', + usage: '--mountain=', + }), + } + + async exec () { + return this.flags() + } + } + + // Manually set config.argv to simulate command-line with unknown flag + npm.config.argv = ['node', 'npm', 'test-command', '--unknown-flag'] + + const command = new TestCommand(npm) + await t.rejects( + command.exec(), + { message: /Unknown flag.*--unknown-flag/ }, + 'throws error for unknown flag' + ) +}) + +t.test('flags() maps alias to main key', async t => { + const { npm } = await loadMockNpm(t) + + class TestCommand extends BaseCommand { + static name = 'test-command' + static description = 'Test command' + static params = ['mountain'] + + static definitions = { + mountain: new Definition('mountain', { + type: String, + default: 'everest', + description: 'Your favorite mountain', + usage: '--mountain=', + alias: ['peak'], + }), + } + + async exec () { + return this.flags() + } + } + + // Use the alias --peak instead of --mountain + npm.config.argv = ['node', 'npm', 'test-command', '--peak=denali'] + + const command = new TestCommand(npm) + const [flags] = await command.exec() + + t.equal(flags.mountain, 'denali', 'alias value is mapped to main key') + t.notOk('peak' in flags, 'alias key is not present in flags') +}) + +t.test('flags() throws error when both main key and alias are provided', async t => { + const { npm } = await loadMockNpm(t) + + class TestCommand extends BaseCommand { + static name = 'test-command' + static description = 'Test command' + static params = ['mountain'] + + static definitions = { + mountain: new Definition('mountain', { + type: String, + default: 'everest', + description: 'Your favorite mountain', + usage: '--mountain=', + alias: ['peak'], + }), + } + + async exec () { + return this.flags() + } + } + + // Provide both --mountain and --peak (its alias) + npm.config.argv = ['node', 'npm', 'test-command', '--mountain=everest', '--peak=denali'] + + const command = new TestCommand(npm) + await t.rejects( + command.exec(), + { message: /Please provide only one of --mountain or --peak/ }, + 'throws error when main key and alias are both provided' + ) +}) + +t.test('getUsage() with no params and no definitions', async t => { + class TestCommand extends BaseCommand { + static name = 'test-command' + static description = 'Test command description' + } + + const usage = TestCommand.describeUsage + + t.ok(usage.includes('Test command description'), 'includes description') + t.ok(usage.includes('npm test-command'), 'includes usage line') + t.notOk(usage.includes('Options:'), 'does not include Options section') +}) + +t.test('getUsage() with both params and definitions', async t => { + class TestCommand extends BaseCommand { + static name = 'test-command' + static description = 'Test command description' + static params = ['mountain', 'river'] + + static definitions = { + mountain: new Definition('mountain', { + type: String, + default: 'everest', + description: 'Your favorite mountain', + usage: '--mountain=', + }), + river: new Definition('river', { + type: String, + default: 'nile', + description: 'Your favorite river', + usage: '--river=', + }), + } + } + + const usage = TestCommand.describeUsage + + t.ok(usage.includes('Test command description'), 'includes description') + t.ok(usage.includes('Options:'), 'includes Options section') + t.ok(usage.includes('--mountain'), 'includes mountain flag') + t.ok(usage.includes('--river'), 'includes river flag') +}) + +t.test('getUsage() with subcommand without description', async t => { + class SubCommandWithDesc extends BaseCommand { + static name = 'with-desc' + static description = 'Subcommand with description' + } + + class SubCommandNoDesc extends BaseCommand { + static name = 'no-desc' + // No description + } + + class TestCommand extends BaseCommand { + static name = 'test-command' + static description = 'Test command description' + static subcommands = { + 'with-desc': SubCommandWithDesc, + 'no-desc': SubCommandNoDesc, + } + } + + const usage = TestCommand.describeUsage + + t.ok(usage.includes('Subcommands:'), 'includes Subcommands section') + t.ok(usage.includes('with-desc'), 'includes subcommand with description') + t.ok(usage.includes('Subcommand with description'), 'includes the description text') + t.ok(usage.includes('no-desc'), 'includes subcommand without description') +}) + +t.test('getUsage() with definition without description', async t => { + class TestCommand extends BaseCommand { + static name = 'test-command' + static description = 'Test command description' + static params = ['mountain', 'river'] + + static definitions = { + mountain: new Definition('mountain', { + type: String, + default: 'everest', + description: 'Your favorite mountain', + usage: '--mountain=', + }), + river: new Definition('river', { + type: String, + default: 'nile', + description: '', // Empty description + usage: '--river=', + }), + } + } + + const usage = TestCommand.describeUsage + + t.ok(usage.includes('Options:'), 'includes Options section') + t.ok(usage.includes('--mountain'), 'includes mountain flag in options') + t.ok(usage.includes('Your favorite mountain'), 'includes mountain description') + t.ok(usage.includes('[--river=]'), 'includes river in usage line') + t.notOk(usage.includes(' --river'), 'does not include river flag description section') +}) + +t.test('flags() handles definition with multiple aliases', async t => { + const { npm } = await loadMockNpm(t) + + class TestCommand extends BaseCommand { + static name = 'test-command' + static description = 'Test command' + + static definitions = { + mountain: new Definition('mountain', { + type: String, + default: 'everest', + description: 'Your favorite mountain', + usage: '--mountain=', + alias: ['peak', 'summit'], // Multiple aliases + }), + } + + async exec () { + return this.flags() + } + } + + // Use the second alias --summit + npm.config.argv = ['node', 'npm', 'test-command', '--summit=denali'] + + const command = new TestCommand(npm) + const [flags] = await command.exec() + + t.equal(flags.mountain, 'denali', 'second alias value is mapped to main key') + t.notOk('summit' in flags, 'alias key is not present in flags') + t.notOk('peak' in flags, 'other alias key is not present in flags') +}) + +t.test('flags() handles definition with short as array', async t => { + const { npm } = await loadMockNpm(t) + + class TestCommand extends BaseCommand { + static name = 'test-command' + static description = 'Test command' + + static definitions = { + mountain: new Definition('mountain', { + type: String, + default: 'everest', + description: 'Your favorite mountain', + usage: '--mountain=', + short: ['m', 'M'], // Short as array + }), + } + + async exec () { + return this.flags() + } + } + + // Use the short flag -m + npm.config.argv = ['node', 'npm', 'test-command', '-m', 'denali'] + + const command = new TestCommand(npm) + const [flags] = await command.exec() + + t.equal(flags.mountain, 'denali', 'short flag value is parsed correctly') +}) + +t.test('flags() returns defaults when argv is empty', async t => { + const { npm } = await loadMockNpm(t) + + class TestCommand extends BaseCommand { + static name = 'test-command' + static description = 'Test command' + + static definitions = { + mountain: new Definition('mountain', { + type: String, + default: 'everest', + description: 'Your favorite mountain', + usage: '--mountain=', + }), + } + + async exec () { + return this.flags() + } + } + + // Set argv to empty array + npm.config.argv = [] + + const command = new TestCommand(npm) + const [flags, remains] = await command.exec() + + t.equal(flags.mountain, 'everest', 'returns default value when argv is empty') + t.same(remains, [], 'remains is empty array') +}) + +t.test('flags() throws error for multiple unknown flags with pluralization', async t => { + const { npm } = await loadMockNpm(t) + + class TestCommand extends BaseCommand { + static name = 'test-command' + static description = 'Test command' + + static definitions = { + mountain: new Definition('mountain', { + type: String, + default: 'everest', + description: 'Your favorite mountain', + usage: '--mountain=', + }), + } + + async exec () { + return this.flags() + } + } + + // Provide multiple unknown flags + npm.config.argv = ['node', 'npm', 'test-command', '--unknown-one', '--unknown-two'] + + const command = new TestCommand(npm) + await t.rejects( + command.exec(), + { message: /Unknown flags:.*--unknown-one.*--unknown-two/ }, + 'throws error with pluralized "flags" for multiple unknown flags' + ) +}) + +t.test('base exec() method returns undefined', async t => { + const { npm } = await loadMockNpm(t) + + class TestCommand extends BaseCommand { + static name = 'test-command' + static description = 'Test command' + // Intentionally not overriding exec() to test the base implementation + } + + const command = new TestCommand(npm) + const result = await command.exec() + + t.equal(result, undefined, 'base exec() returns undefined') +}) + +t.test('flags() removes unknown positional warning when value is consumed by command definition', async t => { + // Pass raw argv to loadMockNpm so warnings are generated during config.load() + // The global config sees --id as unknown (boolean), so "abc123" becomes a positional + // and queues a warning. But command-specific definitions should consume it. + const { npm, logs } = await loadMockNpm(t, { + argv: ['--id', 'abc123'], + }) + + class TestCommand extends BaseCommand { + static name = 'test-command' + static description = 'Test command' + static params = ['id'] + + static definitions = { + id: new Definition('id', { + type: String, + default: null, + description: 'An identifier', + usage: '--id=', + }), + } + + async exec () { + return this.flags() + } + } + + // Set up argv for command execution (mock-npm prepends the command) + npm.config.argv = ['node', 'npm', 'test-command', '--id', 'abc123'] + + const command = new TestCommand(npm) + const [flags, remains] = await command.exec() + + // The flag should be properly parsed + t.equal(flags.id, 'abc123', 'id flag is properly parsed') + t.same(remains, [], 'no remaining positionals') + + // Check that no warning about "abc123" being parsed as positional was logged + const warningLogs = logs.warn + const positionalWarnings = warningLogs.filter(msg => + msg.includes('abc123') && msg.includes('parsed as a normal command line argument') + ) + t.equal(positionalWarnings.length, 0, 'no warning about abc123 being a positional') +}) + +t.test('flags() keeps unknown positional warning when multiple values follow unknown flag', async t => { + // Pass raw argv to loadMockNpm so warnings are generated during config.load() + // Both "abc123" and "def456" are seen as positionals by global config because --id is unknown + // nopt only warns about "abc123" (the immediate next value after unknown flag) + // Command definition consumes "abc123" for --id, "def456" remains as true positional + const { npm, logs } = await loadMockNpm(t, { + argv: ['--id', 'abc123', 'def456'], + }) + + class TestCommand extends BaseCommand { + static name = 'test-command' + static description = 'Test command' + static params = ['id'] + + static definitions = { + id: new Definition('id', { + type: String, + default: null, + description: 'An identifier', + usage: '--id=', + }), + } + + async exec () { + return this.flags() + } + } + + // Set up argv for command execution + npm.config.argv = ['node', 'npm', 'test-command', '--id', 'abc123', 'def456'] + + const command = new TestCommand(npm) + const [flags, remains] = await command.exec() + + // The flag should be properly parsed + t.equal(flags.id, 'abc123', 'id flag is properly parsed') + t.same(remains, ['def456'], 'def456 remains as positional') + + // Check that warning about "abc123" was removed (consumed by --id) + const warningLogs = logs.warn + const abc123Warnings = warningLogs.filter(msg => + msg.includes('abc123') && msg.includes('parsed as a normal command line argument') + ) + t.equal(abc123Warnings.length, 0, 'no warning about abc123 (consumed by --id)') +}) + +t.test('flags() does not remove unknown positional warning when value is in remains', async t => { + // This tests the else branch where remainsSet.has(unknownPos) is true + // When a value is a true positional (in remains), we should NOT remove its warning + // The warning should be logged (not suppressed) + const { npm, logs } = await loadMockNpm(t, { + argv: ['truepositional'], + }) + + class TestCommand extends BaseCommand { + static name = 'test-command' + static description = 'Test command' + static params = ['id'] + + static definitions = { + id: new Definition('id', { + type: String, + default: null, + description: 'An identifier', + usage: '--id=', + }), + } + + async exec () { + return this.flags() + } + } + + // Manually queue a warning for a value that will be in remains + npm.config.queueWarning('unknown:truepositional', 'config', 'truepositional was parsed as positional') + + // Set up argv for command execution with the value as a true positional + npm.config.argv = ['node', 'npm', 'test-command', 'truepositional'] + + const command = new TestCommand(npm) + const [flags, remains] = await command.exec() + + // The positional should remain + t.same(remains, ['truepositional'], 'truepositional is in remains') + t.equal(flags.id, null, 'id flag uses default') + + // Check that the warning WAS logged (not removed before logWarnings()) + // Because the value is in remains, it's a true positional and should warn + const warningLogs = logs.warn + const positionalWarnings = warningLogs.filter(msg => + msg.includes('truepositional') && msg.includes('parsed as positional') + ) + t.equal(positionalWarnings.length, 1, 'warning for truepositional was logged') +}) + +t.test('flags() throws error for extra positional arguments beyond expected count', async t => { + // When a command specifies static positionals = N, extra positionals should throw an error + const { npm } = await loadMockNpm(t, { + argv: ['pkg1', 'extra1', 'extra2'], + }) + + class TestCommand extends BaseCommand { + static name = 'test-command' + static description = 'Test command' + static positionals = 1 // expects only 1 positional + static params = ['id'] + + static definitions = { + id: new Definition('id', { + type: String, + default: null, + description: 'An identifier', + usage: '--id=', + }), + } + + async exec () { + return this.flags() + } + } + + // Set up argv for command execution with multiple positionals + npm.config.argv = ['node', 'npm', 'test-command', 'pkg1', 'extra1', 'extra2'] + + const command = new TestCommand(npm) + + // Should throw error for extra positional + await t.rejects( + command.exec(), + { message: 'Unknown positional argument: extra1' }, + 'throws error for first extra positional' + ) +}) + +t.test('flags() does not throw when positionals is null (unlimited)', async t => { + // When static positionals is null, any number of positionals is allowed without error + const { npm } = await loadMockNpm(t, { + argv: ['pkg1', 'extra1', 'extra2'], + }) + + class TestCommand extends BaseCommand { + static name = 'test-command' + static description = 'Test command' + static positionals = null // unlimited/unchecked + static params = ['id'] + + static definitions = { + id: new Definition('id', { + type: String, + default: null, + description: 'An identifier', + usage: '--id=', + }), + } + + async exec () { + return this.flags() + } + } + + // Set up argv for command execution with multiple positionals + npm.config.argv = ['node', 'npm', 'test-command', 'pkg1', 'extra1', 'extra2'] + + const command = new TestCommand(npm) + const [flags, remains] = await command.exec() + + // All positionals should remain - no error thrown + t.same(remains, ['pkg1', 'extra1', 'extra2'], 'all positionals are in remains') + t.equal(flags.id, null, 'id flag uses default') +}) diff --git a/test/lib/commands/completion.js b/test/lib/commands/completion.js index e9ed95929fc34..f3a2c4e12ff8f 100644 --- a/test/lib/commands/completion.js +++ b/test/lib/commands/completion.js @@ -186,6 +186,141 @@ t.test('completion', async t => { await completion.exec(['npm', '--registry', 'install']) t.matchSnapshot(outputs, 'does not try to complete option arguments in the middle of a command') }) + + // Test custom definition flag that requires a value (non-Boolean) + t.test('completion after custom definition flag requiring value', async t => { + const { outputs, completion } = await loadMockCompletionComp(t, 4, 'npm trust github --file value') + + await completion.exec(['npm', 'trust', 'github', '--file', 'value']) + t.matchSnapshot(outputs, 'custom definition non-Boolean flag handled') + }) + + t.test('trust subcommands', async t => { + const { outputs, completion } = await loadMockCompletionComp(t, 2, 'npm trust ') + + await completion.exec(['npm', 'trust', '']) + t.matchSnapshot(outputs, 'trust subcommands') + }) + + t.test('trust filtered subcommands', async t => { + const { outputs, completion } = await loadMockCompletionComp(t, 2, 'npm trust g') + + await completion.exec(['npm', 'trust', 'g']) + t.matchSnapshot(outputs, 'trust filtered subcommands') + }) + + t.test('trust github flags', async t => { + const { outputs, completion } = await loadMockCompletionComp(t, 3, 'npm trust github --re') + + await completion.exec(['npm', 'trust', 'github', '--re']) + t.matchSnapshot(outputs, 'trust github flags with custom definitions') + }) + + t.test('trust gitlab flags', async t => { + const { outputs, completion } = await loadMockCompletionComp(t, 3, 'npm trust gitlab --pro') + + await completion.exec(['npm', 'trust', 'gitlab', '--pro']) + t.matchSnapshot(outputs, 'trust gitlab flags with custom definitions') + }) + + // Test to ensure custom definition aliases are recognized + t.test('custom definition with alias', async t => { + const { completion } = await loadMockCompletionComp(t, 3, 'npm trust github --repo') + await completion.exec(['npm', 'trust', 'github', '--repo']) + t.pass('custom alias handled') + }) + + // Test to ensure the code handles undefined word gracefully + t.test('completion with undefined current word', async t => { + const { completion } = await loadMockCompletion(t, { + globals: { + 'process.env.COMP_CWORD': '3', + 'process.env.COMP_LINE': 'npm trust github ', + 'process.env.COMP_POINT': '19', + }, + }) + await completion.exec(['npm', 'trust', 'github']) + t.pass('undefined word handled') + }) + + // Test custom definition Boolean type checking (covers isFlag with custom defs) + t.test('completion after Boolean flag from custom definitions', async t => { + const { completion } = await loadMockCompletionComp(t, 4, 'npm trust github --yes ') + await completion.exec(['npm', 'trust', 'github', '--yes', '']) + t.pass('Boolean custom definition handled') + }) + + // Test custom definition non-Boolean type (requires value) + t.test('completion after non-Boolean custom definition flag', async t => { + const { completion } = await loadMockCompletionComp(t, 4, 'npm trust github --file ') + await completion.exec(['npm', 'trust', 'github', '--file', '']) + t.pass('non-Boolean custom definition handled') + }) + + // Test to trigger isFlag with custom definition alias + t.test('completion after custom definition flag with alias', async t => { + const { completion } = await loadMockCompletionComp(t, 4, 'npm trust github --repo ') + await completion.exec(['npm', 'trust', 'github', '--repo', '']) + t.pass('custom definition alias handled in isFlag') + }) + + // Test to cover shorthand fallback in isFlag (line 345) + t.test('completion with unknown flag', async t => { + const { completion } = await loadMockCompletionComp(t, 3, 'npm install --unknown ') + await completion.exec(['npm', 'install', '--unknown', '']) + t.pass('unknown flag handled via shorthand fallback') + }) + + // Test to cover line 110 - cursor in middle of word + t.test('completion with cursor in middle of word', async t => { + const { completion } = await loadMockCompletion(t, { + globals: { + 'process.env.COMP_CWORD': '1', + 'process.env.COMP_LINE': 'npm install', + 'process.env.COMP_POINT': '7', // cursor after "npm ins" + }, + }) + await completion.exec(['npm', 'ins']) + t.pass('cursor in middle of word handled') + }) + + // Test to cover line 110 - with escaped/quoted word + t.test('completion with escaped word', async t => { + const { completion } = await loadMockCompletion(t, { + globals: { + 'process.env.COMP_CWORD': '1', + 'process.env.COMP_LINE': 'npm inst', + 'process.env.COMP_POINT': '8', // cursor after "npm inst" + }, + }) + await completion.exec(['npm', 'install']) // args has full word but COMP_LINE is partial + t.pass('escaped word handled') + }) + + // Test to cover line 261 - command with definitions (not subcommand) + t.test('completion for command with definitions', async t => { + const { completion } = await loadMockCompletionComp(t, 2, 'npm completion --') + await completion.exec(['npm', 'completion', '--']) + t.pass('command with definitions handled') + }) + + // Test to cover line 141 - false branch where '--' IS in partialWords + t.test('completion with double-dash escape in command line', async t => { + // This tests the false branch at line 141 where partialWords contains '--' + // The '--' escape prevents flag completion + // COMP_CWORD should point to the word AFTER '--' + const { outputs, completion } = await loadMockCompletionComp(t, 3, 'npm install -- pkg') + await completion.exec(['npm', 'install', '--', 'pkg']) + t.matchSnapshot(outputs, 'double-dash escape handled') + }) + + // Test to cover line 142 - false branch where word doesn't start with '-' + t.test('completion with non-flag word', async t => { + // Inside the outer if (no '--' in partialWords) but word doesn't start with '-' + const { outputs, completion } = await loadMockCompletionComp(t, 2, 'npm install pack') + await completion.exec(['npm', 'install', 'pack']) + t.matchSnapshot(outputs, 'non-flag word completion') + }) }) t.test('windows without bash', async t => { diff --git a/test/lib/commands/trust/github.js b/test/lib/commands/trust/github.js new file mode 100644 index 0000000000000..76c61a12c584c --- /dev/null +++ b/test/lib/commands/trust/github.js @@ -0,0 +1,153 @@ +const t = require('tap') +const { load: loadMockNpm } = require('../../../fixtures/mock-npm.js') +const MockRegistry = require('@npmcli/mock-registry') +const realProcLog = require('proc-log') + +const packageName = '@npmcli/test-package' + +t.test('github with all options provided', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + mocks: { + 'proc-log': { + ...realProcLog, + input: { + ...realProcLog.input, + read: async () => 'y', + }, + }, + }, + }) + + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: 'test-auth-token', + }) + + registry.trustCreate({ packageName }) + + await npm.exec('trust', ['github', packageName, '--yes', '--file', 'workflow.yml', '--repository', 'owner/repo', '--environment', 'production']) +}) + +t.test('github with invalid repository format', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + mocks: { + 'proc-log': { + ...realProcLog, + input: { + ...realProcLog.input, + read: async () => 'y', + }, + }, + }, + }) + + await t.rejects( + npm.exec('trust', ['github', packageName, '--yes', '--file', 'workflow.yml', '--repository', 'invalid']), + { message: /must be specified in the format owner\/repository/ } + ) +}) + +t.test('github with file as path', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + mocks: { + 'proc-log': { + ...realProcLog, + input: { + ...realProcLog.input, + read: async () => 'y', + }, + }, + }, + }) + + await t.rejects( + npm.exec('trust', ['github', packageName, '--yes', '--file', '.github/workflows/ci.yml', '--repository', 'owner/repo']), + { message: /must be just a file not a path/ } + ) +}) + +t.test('github without environment', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + mocks: { + 'proc-log': { + ...realProcLog, + input: { + ...realProcLog.input, + read: async () => 'y', + }, + }, + }, + }) + + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: 'test-auth-token', + }) + + registry.trustCreate({ packageName }) + + await npm.exec('trust', ['github', packageName, '--yes', '--file', 'workflow.yml', '--repository', 'owner/repo']) +}) + +t.test('bodyToOptions with all fields', t => { + const TrustGitHub = require('../../../../lib/commands/trust/github.js') + + const body = { + id: 'test-id', + type: 'github', + claims: { + repository: 'owner/repo', + workflow_ref: { + file: 'test.yml', + }, + }, + environment: 'prod', + } + + const options = TrustGitHub.bodyToOptions(body) + + t.equal(options.id, 'test-id', 'id should be set') + t.equal(options.type, 'github', 'type should be set') + t.equal(options.file, 'test.yml', 'file should be set') + t.equal(options.repository, 'owner/repo', 'repository should be set') + t.equal(options.environment, 'prod', 'environment should be set') + t.end() +}) diff --git a/test/lib/commands/trust/gitlab.js b/test/lib/commands/trust/gitlab.js new file mode 100644 index 0000000000000..c74ac59c7eb39 --- /dev/null +++ b/test/lib/commands/trust/gitlab.js @@ -0,0 +1,153 @@ +const t = require('tap') +const { load: loadMockNpm } = require('../../../fixtures/mock-npm.js') +const MockRegistry = require('@npmcli/mock-registry') +const realProcLog = require('proc-log') + +const packageName = '@npmcli/test-package' + +t.test('gitlab with all options provided', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + mocks: { + 'proc-log': { + ...realProcLog, + input: { + ...realProcLog.input, + read: async () => 'y', + }, + }, + }, + }) + + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: 'test-auth-token', + }) + + registry.trustCreate({ packageName }) + + await npm.exec('trust', ['gitlab', packageName, '--yes', '--file', '.gitlab-ci.yml', '--project', 'group/subgroup/repo', '--environment', 'production']) +}) + +t.test('gitlab with invalid project format', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + mocks: { + 'proc-log': { + ...realProcLog, + input: { + ...realProcLog.input, + read: async () => 'y', + }, + }, + }, + }) + + await t.rejects( + npm.exec('trust', ['gitlab', packageName, '--yes', '--file', '.gitlab-ci.yml', '--project', 'invalid']), + { message: /must be specified in the format/ } + ) +}) + +t.test('gitlab with file as path', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + mocks: { + 'proc-log': { + ...realProcLog, + input: { + ...realProcLog.input, + read: async () => 'y', + }, + }, + }, + }) + + await t.rejects( + npm.exec('trust', ['gitlab', packageName, '--yes', '--file', '.gitlab/ci.yml', '--project', 'group/repo']), + { message: /must be just a file not a path/ } + ) +}) + +t.test('gitlab without environment', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + mocks: { + 'proc-log': { + ...realProcLog, + input: { + ...realProcLog.input, + read: async () => 'y', + }, + }, + }, + }) + + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: 'test-auth-token', + }) + + registry.trustCreate({ packageName }) + + await npm.exec('trust', ['gitlab', packageName, '--yes', '--file', '.gitlab-ci.yml', '--project', 'group/repo']) +}) + +t.test('bodyToOptions with all fields', t => { + const TrustGitLab = require('../../../../lib/commands/trust/gitlab.js') + + const body = { + id: 'test-id', + type: 'gitlab', + claims: { + project_path: 'group/repo', + ci_config_ref_uri: { + file: '.gitlab-ci.yml', + }, + }, + environment: 'prod', + } + + const options = TrustGitLab.bodyToOptions(body) + + t.equal(options.id, 'test-id', 'id should be set') + t.equal(options.type, 'gitlab', 'type should be set') + t.equal(options.file, '.gitlab-ci.yml', 'file should be set') + t.equal(options.project, 'group/repo', 'project should be set') + t.equal(options.environment, 'prod', 'environment should be set') + t.end() +}) diff --git a/test/lib/commands/trust/list.js b/test/lib/commands/trust/list.js new file mode 100644 index 0000000000000..bd979abfff112 --- /dev/null +++ b/test/lib/commands/trust/list.js @@ -0,0 +1,256 @@ +const t = require('tap') +const { load: loadMockNpm } = require('../../../fixtures/mock-npm.js') +const MockRegistry = require('@npmcli/mock-registry') + +const packageName = '@npmcli/test-package' + +t.test('list with package name argument', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + }) + + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: 'test-auth-token', + }) + + const trustConfigs = [ + { + id: 'test-id-1', + type: 'github', + claims: { + repository: 'owner/repo', + workflow_ref: { + file: 'test.yml', + }, + }, + environment: 'production', + }, + { + id: 'test-id-2', + type: 'gitlab', + claims: { + project_id: '12345', + ref_path: 'refs/heads/main', + pipeline_source: 'push', + }, + }, + ] + + registry.trustList({ packageName, body: trustConfigs }) + + await npm.exec('trust', ['list', packageName]) +}) + +t.test('list without package name (uses package.json)', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + }) + + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: 'test-auth-token', + }) + + const trustConfigs = [ + { + id: 'test-id-1', + type: 'github', + claims: { + repository: 'owner/repo', + workflow_ref: { + file: 'workflow.yml', + }, + }, + }, + ] + + registry.trustList({ packageName, body: trustConfigs }) + + await npm.exec('trust', ['list']) +}) + +t.test('list with no trust configurations', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + }) + + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: 'test-auth-token', + }) + + registry.trustList({ packageName, body: [] }) + + await npm.exec('trust', ['list', packageName]) +}) + +t.test('list without package name and no package.json', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: {}, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + }) + + await t.rejects( + npm.exec('trust', ['list']), + { message: /Could not read package\.json/ } + ) +}) + +t.test('list without package name and no name in package.json', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + version: '1.0.0', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + }) + + await t.rejects( + npm.exec('trust', ['list']), + { message: /Package name must be specified either as an argument or in the package\.json file/ } + ) +}) + +t.test('list with --json flag', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + json: true, + }, + }) + + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: 'test-auth-token', + }) + + const trustConfigs = [ + { + id: 'test-id-1', + type: 'github', + claims: { + repository: 'owner/repo', + workflow_ref: { + file: 'test.yml', + }, + }, + environment: 'production', + }, + ] + + registry.trustList({ packageName, body: trustConfigs }) + + await npm.exec('trust', ['list', packageName]) +}) + +t.test('list with scoped package', async t => { + const scopedPackage = '@scope/package' + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: scopedPackage, + version: '1.0.0', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + }) + + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: 'test-auth-token', + }) + + const trustConfigs = [ + { + id: 'test-id-1', + type: 'github', + claims: { + repository: 'owner/repo', + workflow_ref: { + file: 'test.yml', + }, + }, + }, + ] + + registry.trustList({ packageName: scopedPackage, body: trustConfigs }) + + await npm.exec('trust', ['list', scopedPackage]) +}) + +t.test('list with unknown trust type', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + }) + + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: 'test-auth-token', + }) + + const trustConfigs = [ + { + id: 'test-id-1', + type: 'unknown-type', + claims: { + custom: 'value', + }, + }, + ] + + registry.trustList({ packageName, body: trustConfigs }) + + await npm.exec('trust', ['list', packageName]) +}) diff --git a/test/lib/commands/trust/revoke.js b/test/lib/commands/trust/revoke.js new file mode 100644 index 0000000000000..290fc61897725 --- /dev/null +++ b/test/lib/commands/trust/revoke.js @@ -0,0 +1,319 @@ +const t = require('tap') +const { load: loadMockNpm } = require('../../../fixtures/mock-npm.js') +const MockRegistry = require('@npmcli/mock-registry') + +const packageName = '@npmcli/test-package' + +t.test('revoke with package name argument and id', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + }) + + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: 'test-auth-token', + }) + + const trustId = 'test-id-1' + registry.trustRevoke({ packageName, id: trustId }) + + await npm.exec('trust', ['revoke', packageName, '--id', trustId]) +}) + +t.test('revoke without package name (uses package.json)', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + }) + + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: 'test-auth-token', + }) + + const trustId = 'test-id-2' + registry.trustRevoke({ packageName, id: trustId }) + + await npm.exec('trust', ['revoke', '--id', trustId]) +}) + +t.test('revoke with dry-run flag', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + 'dry-run': true, + }, + }) + + // No registry mock needed since dry-run should not make network requests + const trustId = 'test-id-3' + + await npm.exec('trust', ['revoke', packageName, '--id', trustId]) +}) + +t.test('revoke without package name and no package.json', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: {}, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + }) + + await t.rejects( + npm.exec('trust', ['revoke', '--id', 'test-id']), + { message: /Could not read package\.json/ } + ) +}) + +t.test('revoke without package name and no name in package.json', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + version: '1.0.0', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + }) + + await t.rejects( + npm.exec('trust', ['revoke', '--id', 'test-id']), + { message: /Package name must be specified either as an argument or in the package\.json file/ } + ) +}) + +t.test('revoke without id flag', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + }) + + await t.rejects( + npm.exec('trust', ['revoke', packageName]), + { message: /ID of the trusted relationship to revoke must be specified with the --id option/ } + ) +}) + +t.test('revoke with scoped package', async t => { + const scopedPackage = '@scope/package' + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: scopedPackage, + version: '1.0.0', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + }) + + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: 'test-auth-token', + }) + + const trustId = 'test-id-scoped' + registry.trustRevoke({ packageName: scopedPackage, id: trustId }) + + await npm.exec('trust', ['revoke', scopedPackage, '--id', trustId]) +}) + +t.test('revoke with special characters in id', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + }) + + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: 'test-auth-token', + }) + + const trustId = 'test-id/with:special@chars' + registry.trustRevoke({ packageName, id: trustId }) + + await npm.exec('trust', ['revoke', packageName, '--id', trustId]) +}) + +t.test('revoke with 404 response', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + }) + + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: 'test-auth-token', + }) + + const trustId = 'non-existent-id' + registry.trustRevoke({ + packageName, + id: trustId, + responseCode: 404, + body: { error: 'Not Found' }, + }) + + await t.rejects( + npm.exec('trust', ['revoke', packageName, '--id', trustId]), + { statusCode: 404 } + ) +}) + +t.test('revoke with 500 response', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + }) + + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: 'test-auth-token', + }) + + const trustId = 'test-id-error' + registry.trustRevoke({ + packageName, + id: trustId, + responseCode: 500, + body: { error: 'Internal Server Error' }, + }) + + await t.rejects( + npm.exec('trust', ['revoke', packageName, '--id', trustId]), + { statusCode: 500 } + ) +}) + +t.test('revoke with unscoped package name', async t => { + const unscopedPackage = 'test-package' + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: unscopedPackage, + version: '1.0.0', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + }) + + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: 'test-auth-token', + }) + + const trustId = 'test-id-unscoped' + registry.trustRevoke({ packageName: unscopedPackage, id: trustId }) + + await npm.exec('trust', ['revoke', unscopedPackage, '--id', trustId]) +}) + +t.test('revoke with very long id', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + }) + + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: 'test-auth-token', + }) + + const trustId = 'a'.repeat(100) + registry.trustRevoke({ packageName, id: trustId }) + + await npm.exec('trust', ['revoke', packageName, '--id', trustId]) +}) + +t.test('revoke with UUID id format', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + }) + + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: 'test-auth-token', + }) + + const trustId = '550e8400-e29b-41d4-a716-446655440000' + registry.trustRevoke({ packageName, id: trustId }) + + await npm.exec('trust', ['revoke', packageName, '--id', trustId]) +}) diff --git a/test/lib/docs.js b/test/lib/docs.js index 833e58831ea51..dff238cfe562d 100644 --- a/test/lib/docs.js +++ b/test/lib/docs.js @@ -90,16 +90,27 @@ t.test('basic usage', async t => { t.test('usage', async t => { const readdir = async (dir, ext) => { - const files = await fs.readdir(dir) - return files.filter(f => extname(f) === ext).map(f => basename(f, ext)) + const files = await fs.readdir(dir, { withFileTypes: true }) + return files + .filter(f => { + // Include .js files + if (f.isFile() && extname(f.name) === ext) { + return true + } + // Include directories (which should have an index.js) + if (f.isDirectory()) { + return true + } + return false + }) + .map(f => f.isDirectory() ? f.name : basename(f.name, ext)) } const fsCommands = await readdir(resolve(__dirname, '../../lib/commands'), '.js') 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..fba8d36a34277 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') }) }) @@ -497,6 +497,233 @@ t.test('implicit workspace accept', async t => { await t.rejects(mock.npm.exec('org', []), /.*Usage/) }) +t.test('subcommand handling', async t => { + t.test('no subcommand provided', async t => { + const { npm } = await loadMockNpm(t) + await t.rejects( + npm.exec('trust', []), + /Usage/, + 'throws usage error when no subcommand provided' + ) + }) + + t.test('unknown subcommand', async t => { + const { npm } = await loadMockNpm(t) + await t.rejects( + npm.exec('trust', ['unknown-subcommand']), + /Unknown subcommand: unknown-subcommand/, + 'throws error for unknown subcommand' + ) + }) + + t.test('subcommand help with --usage', async t => { + const { npm, outputs } = await loadMockNpm(t, { + config: { + usage: true, + }, + }) + await npm.exec('trust', ['github']) + t.ok(outputs.length > 0, 'outputs help text') + // Check if output was generated - the format may be different + t.ok(outputs.some(o => o && o[0]), 'has output content') + }) +}) + +t.test('exec edge cases', async t => { + t.test('command calls exec again - covers else branch at line 207', async t => { + const { npm, outputs } = await loadMockNpm(t) + // 'get' command calls npm.exec('config', ['get', ...]) internally + // The first exec() sets #command, then when it re-enters exec(), + // the else branch (line 217) is taken because #command is already set + await npm.exec('get', ['registry']) + t.ok(outputs.length > 0, 'command executed and produced output') + }) + + t.test('exec without args parameter - covers default args branch', async t => { + const Npm = require('../../lib/npm.js') + const npm = new Npm() + await npm.load() + npm.argv = ['test'] + // Call exec without second parameter - should use default args = this.argv + await npm.exec('run') + t.pass('exec called without second argument') + }) + + t.test('--versions flag sets argv to version', async t => { + const { npm } = await loadMockNpm(t, { + config: { versions: true }, + }) + t.equal(npm.argv.length, 0, 'argv is empty after version command runs') + t.equal(npm.config.get('usage'), false, 'usage is set to false') + }) + + t.test('color true sets COLOR env to 1', async t => { + await loadMockNpm(t, { + config: { color: 'always' }, + }) + t.equal(process.env.COLOR, '1', 'COLOR env is set to 1 when color is truthy') + }) + + t.test('command without subcommands', async t => { + const { npm } = await loadMockNpm(t) + // Test a command that doesn't have subcommands (line 249 branch) + await t.rejects(npm.exec('org', []), /Usage/) + }) + + t.test('command with workspaces support', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + packages: { + a: { + 'package.json': JSON.stringify({ + name: 'a', + version: '1.0.0', + scripts: { test: 'echo test' }, + }), + }, + }, + 'package.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + workspaces: ['./packages/a'], + }), + }, + config: { + workspace: ['./packages/a'], + }, + }) + // Test a command that supports workspaces to trigger execWorkspaces path (line 321) + await npm.exec('run', ['test']) + t.pass('executes with workspaces') + }) + + t.test('execCommandClass with default commandPath', async t => { + const { npm } = await loadMockNpm(t) + // Create a simple command instance + const Command = npm.constructor.cmd('version') + const commandInstance = new Command(npm) + + // Call execCommandClass without providing commandPath (using default []) + await npm.execCommandClass(commandInstance, []) + + t.pass('execCommandClass works with default commandPath parameter') + }) + + t.test('command with definitions executes exec() without workspaces', async t => { + const BaseCommand = require('../../lib/base-cmd.js') + const Definition = require('@npmcli/config/lib/definitions/definition.js') + + let execCalled = false + let execArgs = null + let execFlags = null + + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: 'test-pkg', + version: '1.0.0', + }), + }, + }) + + class TestCommand extends BaseCommand { + static name = 'test-cmd' + static description = 'Test command with definitions' + static workspaces = true + static definitions = { + testflag: new Definition('testflag', { + type: String, + default: 'default-value', + description: 'A test flag', + }), + } + + async exec (args, flags) { + execCalled = true + execArgs = args + execFlags = flags + } + + async execWorkspaces () { + throw new Error('execWorkspaces should not be called') + } + } + + const command = new TestCommand(npm) + // Set config.argv so flags() can parse the positional args + npm.config.argv = [process.argv[0], process.argv[1], 'test-cmd', 'arg1', 'arg2'] + await npm.execCommandClass(command, ['arg1', 'arg2'], ['test-cmd']) + + t.equal(execCalled, true, 'exec() was called') + t.same(execArgs, ['arg1', 'arg2'], 'positional args passed correctly') + t.ok(execFlags, 'flags object passed') + t.equal(execFlags.testflag, 'default-value', 'flag has default value') + }) + + t.test('command with definitions executes execWorkspaces() with workspaces', async t => { + const BaseCommand = require('../../lib/base-cmd.js') + const Definition = require('@npmcli/config/lib/definitions/definition.js') + + let execWorkspacesCalled = false + let execArgs = null + let execFlags = null + + const { npm } = await loadMockNpm(t, { + prefixDir: { + packages: { + a: { + 'package.json': JSON.stringify({ + name: 'workspace-a', + version: '1.0.0', + }), + }, + }, + 'package.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + workspaces: ['./packages/a'], + }), + }, + config: { + workspace: ['./packages/a'], + }, + }) + + class TestCommand extends BaseCommand { + static name = 'test-cmd' + static description = 'Test command with definitions' + static workspaces = true + static definitions = { + testflag: new Definition('testflag', { + type: String, + default: 'ws-default', + description: 'A test flag', + }), + } + + async exec () { + throw new Error('exec should not be called') + } + + async execWorkspaces (args, flags) { + execWorkspacesCalled = true + execArgs = args + execFlags = flags + } + } + + const command = new TestCommand(npm) + // Set config.argv so flags() can parse the positional args + npm.config.argv = [process.argv[0], process.argv[1], 'test-cmd', 'wsarg1'] + await npm.execCommandClass(command, ['wsarg1'], ['test-cmd']) + + t.equal(execWorkspacesCalled, true, 'execWorkspaces() was called') + t.same(execArgs, ['wsarg1'], 'positional args passed correctly') + t.ok(execFlags, 'flags object passed') + t.equal(execFlags.testflag, 'ws-default', 'flag has default value') + }) +}) + t.test('usage', async t => { t.test('with browser', async t => { const { npm } = await loadMockNpm(t, { globals: { process: { platform: 'posix' } } }) @@ -559,11 +786,3 @@ t.test('usage', async t => { } }) }) - -t.test('print usage if non-command param provided', async t => { - const { npm, joinedOutput } = await loadMockNpm(t) - - await t.rejects(npm.exec('tset'), { command: 'tset', exitCode: 1 }) - t.match(joinedOutput(), 'Unknown command: "tset"') - t.match(joinedOutput(), 'Did you mean this?') -}) diff --git a/test/lib/trust-cmd.js b/test/lib/trust-cmd.js new file mode 100644 index 0000000000000..d9a58cf2588ff --- /dev/null +++ b/test/lib/trust-cmd.js @@ -0,0 +1,786 @@ +const t = require('tap') +const { load: loadMockNpm } = require('../fixtures/mock-npm.js') +const MockRegistry = require('@npmcli/mock-registry') +const realProcLog = require('proc-log') +const TrustCommand = require('../../lib/trust-cmd.js') + +const packageName = '@npmcli/test-package' + +t.test('trust-cmd via trust github with read function called', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + mocks: { + read: { + read: async () => 'y', + }, + }, + }) + + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: 'test-auth-token', + }) + + registry.trustCreate({ packageName }) + + await npm.exec('trust', ['github', packageName, '--file', 'workflow.yml', '--repository', 'npm/cli']) +}) + +t.test('trust-cmd via trust github with all options', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + yes: true, + }, + }) + + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: 'test-auth-token', + }) + + registry.trustCreate({ packageName }) + + await npm.exec('trust', ['github', packageName, '--file', 'workflow.yml', '--repository', 'npm/cli', '--environment', 'production']) +}) + +t.test('trust-cmd via trust github infers from package.json', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + repository: 'https://github.com/npm/cli', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + mocks: { + 'proc-log': { + ...realProcLog, + input: { + ...realProcLog.input, + read: async () => 'y', + }, + }, + }, + }) + + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: 'test-auth-token', + }) + + registry.trustCreate({ packageName }) + + await npm.exec('trust', ['github', '--yes', '--file', 'workflow.yml']) +}) + +t.test('trust-cmd via trust github with dry-run', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + 'dry-run': true, + }, + }) + + await npm.exec('trust', ['github', packageName, '--file', 'workflow.yml', '--repository', 'npm/cli']) + + t.ok(joinedOutput().includes('Establishing trust'), 'shows notice about establishing trust') +}) + +t.test('trust-cmd via trust github missing package name', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: {}, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + }) + + await t.rejects( + npm.exec('trust', ['github', '--file', 'workflow.yml', '--repository', 'npm/cli']), + { message: /Package name must be specified/ }, + 'throws when no package name' + ) +}) + +t.test('trust-cmd via trust github missing file', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + }) + + await t.rejects( + npm.exec('trust', ['github', packageName, '--repository', 'npm/cli']), + { message: /must be specified with the file option/ }, + 'throws when no file' + ) +}) + +t.test('trust-cmd via trust github invalid file extension', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + }) + + await t.rejects( + npm.exec('trust', ['github', packageName, '--file', 'workflow.txt', '--repository', 'npm/cli']), + { message: /must end in \.yml or \.yaml/ }, + 'throws when file has wrong extension' + ) +}) + +t.test('trust-cmd via trust github missing repository', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + }) + + await t.rejects( + npm.exec('trust', ['github', packageName, '--file', 'workflow.yml']), + { message: /must be specified with repository option/ }, + 'throws when no repository' + ) +}) + +t.test('trust-cmd via trust github with custom registry warning', async t => { + const { npm, logs } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + registry: 'https://custom.registry.com/', + 'dry-run': true, + }, + }) + + await npm.exec('trust', ['github', packageName, '--file', 'workflow.yml', '--repository', 'npm/cli']) + + t.ok(logs.warn.some(l => l.includes('may not support trusted publishing')), 'warns about custom registry') +}) + +t.test('trust-cmd via trust github with --json', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + json: true, + 'dry-run': true, + }, + }) + + await npm.exec('trust', ['github', packageName, '--file', 'workflow.yml', '--repository', 'npm/cli']) + + const output = joinedOutput() + t.ok(output.includes(packageName), 'JSON output includes package name') + t.ok(output.includes('workflow.yml'), 'JSON output includes file') +}) + +t.test('trust-cmd via trust github with user confirmation no', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + mocks: { + 'proc-log': { + ...realProcLog, + input: { + ...realProcLog.input, + read: async () => 'n', + }, + }, + }, + }) + + await t.rejects( + npm.exec('trust', ['github', packageName, '--file', 'workflow.yml', '--repository', 'npm/cli']), + { message: 'User cancelled operation' }, + 'throws when user declines' + ) +}) + +t.test('trust-cmd via trust github with --no-yes', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + yes: false, + }, + }) + + await t.rejects( + npm.exec('trust', ['github', packageName, '--file', 'workflow.yml', '--repository', 'npm/cli']), + { message: 'User cancelled operation' }, + 'throws when --no-yes flag is set' + ) +}) + +t.test('trust-cmd via trust github with invalid answer', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + mocks: { + 'proc-log': { + ...realProcLog, + input: { + ...realProcLog.input, + read: async () => 'maybe', + }, + }, + }, + }) + + await t.rejects( + npm.exec('trust', ['github', packageName, '--file', 'workflow.yml', '--repository', 'npm/cli']), + { message: 'User cancelled operation' }, + 'throws when user gives invalid answer' + ) +}) + +t.test('trust-cmd via trust github with user confirmation Y uppercase', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + mocks: { + 'proc-log': { + ...realProcLog, + input: { + ...realProcLog.input, + read: async () => 'Y', + }, + }, + }, + }) + + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: 'test-auth-token', + }) + + registry.trustCreate({ packageName }) + + await npm.exec('trust', ['github', packageName, '--file', 'workflow.yml', '--repository', 'npm/cli']) +}) + +t.test('trust-cmd via trust github with user enters empty string', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + mocks: { + 'proc-log': { + ...realProcLog, + input: { + ...realProcLog.input, + read: async () => '', + }, + }, + }, + }) + + await t.rejects( + npm.exec('trust', ['github', packageName, '--file', 'workflow.yml', '--repository', 'npm/cli']), + { message: 'User cancelled operation' }, + 'throws when user enters empty string' + ) +}) + +t.test('trust-cmd via trust github with mismatched repo type', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + repository: 'https://gitlab.com/npm/cli', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + }) + + await t.rejects( + npm.exec('trust', ['github', '--file', 'workflow.yml']), + { message: /Repository in package.json is not a GitHub repository/ }, + 'throws when repository type does not match provider' + ) +}) + +t.test('trust-cmd via trust github with mismatched repo type but flag provided', async t => { + const { npm, logs } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + repository: 'https://gitlab.com/owner/old-repo', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + 'dry-run': true, + }, + }) + + await npm.exec('trust', ['github', '--file', 'workflow.yml', '--repository', 'owner/new-repo']) + + t.ok(logs.warn.some(l => l.includes('Repository in package.json is not a GitHub repository')), 'warns about repository type mismatch') +}) + +t.test('trust-cmd via trust github with different repo in package.json', async t => { + const { npm, logs } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + repository: 'https://github.com/owner/old-repo', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + 'dry-run': true, + }, + }) + + await npm.exec('trust', ['github', '--file', 'workflow.yml', '--repository', 'owner/new-repo']) + + t.ok(logs.warn.some(l => l.includes('differs from provided')), 'warns about repository mismatch') +}) + +t.test('trust-cmd via trust github with user confirmation yes spelled out', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + mocks: { + 'proc-log': { + ...realProcLog, + input: { + ...realProcLog.input, + read: async () => 'yes', + }, + }, + }, + }) + + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: 'test-auth-token', + }) + + registry.trustCreate({ packageName }) + + await npm.exec('trust', ['github', packageName, '--file', 'workflow.yml', '--repository', 'npm/cli']) +}) + +t.test('trust-cmd via trust github showing response with id and type', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + mocks: { + read: { + read: async () => 'y', + }, + }, + }) + + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: 'test-auth-token', + }) + + registry.trustCreate({ + packageName, + body: { + id: 'config-id-123', + type: 'github', + claims: { + repository: 'npm/cli', + workflow_ref: { + file: 'workflow.yml', + }, + }, + }, + }) + + await npm.exec('trust', ['github', packageName, '--file', 'workflow.yml', '--repository', 'npm/cli']) + + const output = joinedOutput() + t.ok(output.includes('type:'), 'output shows type field') + t.ok(output.includes('id:'), 'output shows id field') +}) + +t.test('trust-cmd via trust github missing repository when package name differs', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: 'other-package', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + }) + + await t.rejects( + npm.exec('trust', ['github', packageName, '--file', 'workflow.yml']), + { message: /must be specified with repository option/ }, + 'throws when no repository and package name differs' + ) +}) + +t.test('TrustCommand - createConfig', async t => { + const { npm } = await loadMockNpm(t, { + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + }) + + class TestTrustCmd extends TrustCommand { + static name = 'test' + static description = 'Test command' + } + + const cmd = new TestTrustCmd(npm) + + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: 'test-auth-token', + }) + + registry.trustCreate({ packageName }) + + const response = await cmd.createConfig(packageName, [{ type: 'test' }]) + t.ok(response, 'returns a response') +}) + +t.test('TrustCommand - bodyToOptions', t => { + const body = { + id: 'test-id', + type: 'test-type', + other: 'ignored', + } + + const options = TrustCommand.bodyToOptions(body) + + t.equal(options.id, 'test-id', 'includes id') + t.equal(options.type, 'test-type', 'includes type') + t.notOk(options.other, 'does not include other fields') + t.end() +}) + +t.test('TrustCommand - bodyToOptions with missing fields', t => { + const body = {} + + const options = TrustCommand.bodyToOptions(body) + + t.same(options, {}, 'returns empty object when no fields') + t.end() +}) + +t.test('TrustCommand - NPM_FRONTEND constant', t => { + t.equal(TrustCommand.NPM_FRONTEND, 'https://www.npmjs.com', 'exports NPM_FRONTEND constant') + t.end() +}) +t.test('trust-cmd via trust github showing fromPackageJson indicator', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + repository: 'https://github.com/npm/cli', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + mocks: { + read: { + read: async () => 'y', + }, + }, + }) + + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: 'test-auth-token', + }) + + registry.trustCreate({ + packageName, + body: { + id: 'config-id-123', + type: 'github', + claims: { + repository: 'npm/cli', + workflow_ref: { + file: 'workflow.yml', + }, + }, + }, + }) + + await npm.exec('trust', ['github', packageName, '--file', 'workflow.yml']) + + const output = joinedOutput() + t.ok(output.includes('from package.json'), 'output shows fromPackageJson indicator') +}) + +t.test('trust-cmd via trust github showing URLs for fields', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + mocks: { + read: { + read: async () => 'y', + }, + }, + }) + + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: 'test-auth-token', + }) + + registry.trustCreate({ + packageName, + body: { + id: 'config-id-123', + type: 'github', + claims: { + repository: 'npm/cli', + workflow_ref: { + file: 'workflow.yml', + }, + }, + }, + }) + + await npm.exec('trust', ['github', packageName, '--file', 'workflow.yml', '--repository', 'npm/cli']) + + const output = joinedOutput() + t.match(output, /https:\/\/github\.com\/npm\/cli\b/, 'output shows repository URL') +}) + +t.test('trust-cmd via trust github with yes=false flag', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + yes: false, + }, + }) + + await t.rejects( + npm.exec('trust', ['github', packageName, '--file', 'workflow.yml', '--repository', 'npm/cli']), + { message: /User cancelled operation/ }, + 'throws when yes is explicitly false' + ) +}) + +t.test('TrustCommand - logOptions with no values', async t => { + const { npm } = await loadMockNpm(t, { + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + }) + + class TestTrustCmd extends TrustCommand { + static name = 'test' + static description = 'Test command' + } + + const cmd = new TestTrustCmd(npm) + + // Call logOptions with no values object + cmd.logOptions({}) + t.pass('logOptions handles missing values object') +}) + +t.test('TrustCommand - logOptions with falsey value', async t => { + const { npm } = await loadMockNpm(t, { + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + }) + + class TestTrustCmd extends TrustCommand { + static name = 'test' + static description = 'Test command' + } + + const cmd = new TestTrustCmd(npm) + + // Call logOptions with a falsey but not null/undefined value + cmd.logOptions({ values: { type: 'test', falseyField: 0, anotherFalsey: false, emptyString: '' } }) + t.pass('logOptions handles falsey values that are not null/undefined') +}) + +t.test('TrustCommand - logOptions with null and undefined values', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + }) + + class TestTrustCmd extends TrustCommand { + static name = 'test' + static description = 'Test command' + } + + const cmd = new TestTrustCmd(npm) + + // Call logOptions with null and undefined values that should be skipped + cmd.logOptions({ values: { type: 'test', id: 'test-id', nullField: null, undefinedField: undefined, validField: 'value' } }) + const output = joinedOutput() + t.ok(output.includes('validField'), 'shows valid field') + t.notOk(output.includes('nullField'), 'skips null field') + t.notOk(output.includes('undefinedField'), 'skips undefined field') +}) + +t.test('TrustCommand - logOptions with fromPackageJson and urls', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + }) + + class TestTrustCmd extends TrustCommand { + static name = 'test' + static description = 'Test command' + } + + const cmd = new TestTrustCmd(npm) + + // Call logOptions with fromPackageJson and urls objects + cmd.logOptions({ + values: { + type: 'github', + id: 'test-id', + repository: 'npm/cli', + file: 'workflow.yml', + }, + fromPackageJson: { + repository: true, + }, + urls: { + repository: 'https://github.com/npm/cli', + file: 'https://github.com/npm/cli/-/blob/HEAD/workflow.yml', + }, + }) + const output = joinedOutput() + t.ok(output.includes('from package.json'), 'shows fromPackageJson indicator') + t.match(output, /https:\/\/github\.com\/npm\/cli\b/, 'shows URL') +}) diff --git a/workspaces/config/lib/definitions/definition.js b/workspaces/config/lib/definitions/definition.js index 26ba0c0bc14b9..9e70da6256eb7 100644 --- a/workspaces/config/lib/definitions/definition.js +++ b/workspaces/config/lib/definitions/definition.js @@ -22,6 +22,7 @@ const allowed = [ 'typeDescription', 'usage', 'envExport', + 'alias', ] const { diff --git a/workspaces/config/lib/index.js b/workspaces/config/lib/index.js index 0ad716ccb069f..bcddfc3cc70ce 100644 --- a/workspaces/config/lib/index.js +++ b/workspaces/config/lib/index.js @@ -59,6 +59,7 @@ class Config { #flatten // populated the first time we flatten the object #flatOptions = null + #warnings = [] static get typeDefs () { return typeDefs @@ -78,20 +79,13 @@ class Config { execPath = process.execPath, cwd = process.cwd(), excludeNpmCwd = false, + warn = true, }) { 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 } = getTypesFromDefinitions(definitions) + this.deprecated = deprecated this.#flatten = flatten this.types = types @@ -137,6 +131,7 @@ class Config { } this.#loaded = false + this.warn = warn } get list () { @@ -369,7 +364,7 @@ class Config { } nopt.invalidHandler = (k, val, type) => this.invalidHandler(k, val, type, 'command line options', 'cli') - nopt.unknownHandler = this.unknownHandler + nopt.unknownHandler = (k, next) => this.unknownHandler(k, next) nopt.abbrevHandler = this.abbrevHandler const conf = nopt(this.types, this.shorthands, this.argv) nopt.invalidHandler = null @@ -545,7 +540,7 @@ class Config { unknownHandler (key, next) { if (next) { - log.warn(`"${next}" is being parsed as a normal command line argument.`) + this.queueWarning(`unknown:${next}`, `"${next}" is being parsed as a normal command line argument.`) } } @@ -614,12 +609,12 @@ class Config { return } if (!key.includes(':')) { - log.warn(`Unknown ${where} config "${where === 'cli' ? '--' : ''}${key}". This will stop working in the next major version of npm.`) + this.queueWarning(key, `Unknown ${where} config "${where === 'cli' ? '--' : ''}${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.queueWarning(baseKey, `Unknown ${where} config "${baseKey}" (${key}). This will stop working in the next major version of npm.`) } } } @@ -923,6 +918,35 @@ class Config { setEnvs () { setEnvs(this) } + + removeWarning (key) { + this.#warnings = this.#warnings.filter(w => w.type !== key) + } + + getUnknownPositionals () { + return this.#warnings + .filter(w => w.type.startsWith('unknown:')) + .map(w => w.type.slice('unknown:'.length)) + } + + removeUnknownPositional (value) { + this.removeWarning(`unknown:${value}`) + } + + queueWarning (type, ...args) { + if (!this.warn) { + this.#warnings.push({ type, args }) + } else { + log.warn(...args) + } + } + + logWarnings () { + for (const warning of this.#warnings) { + log.warn(...warning.args) + } + this.#warnings = [] + } } const _loadError = Symbol('loadError') @@ -980,4 +1004,21 @@ class ConfigData { } } +const getTypesFromDefinitions = (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 } +} + module.exports = Config +module.exports.getTypesFromDefinitions = getTypesFromDefinitions diff --git a/workspaces/config/test/index.js b/workspaces/config/test/index.js index f60070d419bfd..08598937ed2ee 100644 --- a/workspaces/config/test/index.js +++ b/workspaces/config/test/index.js @@ -988,6 +988,7 @@ t.test('setting basic auth creds and email', async t => { const opts = { shorthands: {}, argv: ['node', __filename, `--userconfig=${path}/.npmrc`], + env: {}, definitions: { registry: { default: registry }, }, @@ -1024,6 +1025,7 @@ t.test('setting username/password/email individually', async t => { const opts = { shorthands: {}, argv: ['node', __filename, `--userconfig=${path}/.npmrc`], + env: {}, definitions: { registry: { default: registry }, }, @@ -1144,7 +1146,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 +1589,192 @@ 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.queueWarning('test-type', 'test warning 1') + config.queueWarning('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('warn false with invalid flag and warning removal', 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, '--invalid-flag', 'value'], + cwd: path, + shorthands, + definitions, + nerfDarts, + }) + + config.warn = false + await config.load() + + // First logWarnings call - should log the queued warning + const logsBeforeFirst = logs.filter(l => l[0] === 'warn').length + config.logWarnings() + const logsAfterFirst = logs.filter(l => l[0] === 'warn') + + // Check we have warnings and the invalid-flag warning is there + t.ok(logsAfterFirst.length > logsBeforeFirst, 'warnings were logged') + const invalidFlagWarnings = logsAfterFirst.filter(w => w[1] && w[1].includes('invalid-flag')) + t.ok(invalidFlagWarnings.length > 0, 'invalid-flag warning present') + + // Trigger the same warning again + config.checkUnknown('cli', 'invalid-flag') + + // Remove the warning + config.removeWarning('invalid-flag') + + // Call logWarnings again - should not add the invalid-flag warning since we removed it + const beforeSecondLog = logs.filter(l => l[0] === 'warn').length + config.logWarnings() + const afterSecondLog = logs.filter(l => l[0] === 'warn') + t.equal(afterSecondLog.length, beforeSecondLog, 'no new warnings after removal and logWarnings') +}) + +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') +}) + +t.test('getUnknownPositionals and removeUnknownPositional', async t => { + const path = t.testdir() + const config = new Config({ + npmPath: `${path}/npm`, + env: {}, + // Pass unknown flags with values - the values become "unknown positionals" + argv: [process.execPath, __filename, '--unknown-flag1', 'positional1', '--unknown-flag2', 'positional2'], + cwd: path, + shorthands, + definitions, + nerfDarts, + warn: false, // Queue warnings instead of logging them + }) + + await config.load() + + // Get the unknown positionals (values after unknown flags) + const unknownPositionals = config.getUnknownPositionals() + t.ok(unknownPositionals.includes('positional1'), 'positional1 is in unknown positionals') + t.ok(unknownPositionals.includes('positional2'), 'positional2 is in unknown positionals') + + // Remove one positional + config.removeUnknownPositional('positional1') + + // Verify it was removed + const afterRemoval = config.getUnknownPositionals() + t.notOk(afterRemoval.includes('positional1'), 'positional1 was removed') + t.ok(afterRemoval.includes('positional2'), 'positional2 still exists') + + // Remove the second positional + config.removeUnknownPositional('positional2') + + // Verify all are removed + const afterSecondRemoval = config.getUnknownPositionals() + t.equal(afterSecondRemoval.length, 0, 'no unknown positionals remain') +})