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..54792cddc618b --- /dev/null +++ b/docs/lib/content/commands/npm-trust.md @@ -0,0 +1,43 @@ +--- +title: npm-trust +section: 1 +description: Manage trusted publishing relationships between packages and CI/CD providers +--- + +### Synopsis + + + +### Description + +Configure trust relationships between npm packages and CI/CD providers using OpenID Connect (OIDC). This is the command-line equivalent of managing trusted publisher configurations on the npm website. + +For a comprehensive overview of trusted publishing, see the [npm trusted publishers documentation](https://docs.npmjs.com/trusted-publishers). + +Two-factor authentication is required for all trust commands. Even if it's not currently enabled on your account, you must enable two-factor authentication in order to use these commands. Additionally, Granular Access Tokens (GAT) with the bypass 2FA option and legacy basic auth tokens will not work for trust commands. + +The `[package]` argument specifies the package name. If omitted, npm will use the name from the `package.json` in the current directory. + +Each trust relationship has its own set of configuration options and flags based on the OIDC claims provided by that provider. OIDC claims come from the CI/CD provider and include information such as repository name, workflow file, or environment. Since each provider's claims differ, the available flags and configuration keys are not universal—npm matches the claims supported by each provider's OIDC configuration. For specific details on which claims and flags are supported for a given provider, use `npm trust --help`. + +The required options depend on the CI/CD provider you're configuring. Detailed information about each option is available in the [managing trusted publisher configurations](https://docs.npmjs.com/trusted-publishers#managing-trusted-publisher-configurations) section of the npm documentation. If a provider is repository-based and the option is not provided, npm will use the `repository.url` field from your `package.json`, if available. + +### Bulk Usage + +For maintainers managing a large number of packages, you can configure trusted publishing in bulk using bash scripting. Create a loop that iterates through package names and their corresponding configuration details, executing the `npm trust ` command with the `--yes` flag for each package. + +The first request will require two-factor authentication. During two-factor authentication, you'll see an option on the npm website to skip two-factor authentication for the next 5 minutes. Enabling this option will allow subsequent `npm trust ` commands to proceed without two-factor authentication, streamlining the bulk configuration process. + +We recommend adding a 2-second sleep between each call to avoid rate limiting. With this approach, you can configure approximately 80 packages within the 5-minute two-factor authentication skip window. + +### Configuration + + + +### See Also + +* [npm publish](/commands/npm-publish) +* [npm token](/commands/npm-token) +* [npm access](/commands/npm-access) +* [npm config](/commands/npm-config) +* [npm registry](/using-npm/registry) 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..58bbeeb71f893 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,10 @@ - title: npm token url: /commands/npm-token description: Manage your authentication tokens + - title: npm trust + url: /commands/npm-trust + description: Manage trusted publishing relationships between packages and CI/CD + providers - title: npm undeprecate url: /commands/npm-undeprecate description: Undeprecate a version of a package @@ -191,7 +204,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 +216,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 +239,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..6832f33090966 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,168 @@ 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) +// Helper to generate a markdown table from definition parameters +const generateFlagsTable = (paramNames, definitionPool) => { + const rows = paramNames.map((n) => { + const def = definitionPool[n] + const flags = [`\`--${def.key}\``] + if (def.alias) { + flags.push(...def.alias.map(a => `\`--${a}\``)) + } + if (def.short) { + flags.push(`\`-${def.short}\``) + } + const flagsStr = flags.join(', ') + let defaultVal = def.defaultDescription + if (!defaultVal) { + defaultVal = String(def.default) + } + const typeVal = def.typeDescription || String(def.type) + const desc = (def.description || '').replace(/\n/g, ' ').trim() + return `| ${flagsStr} | ${defaultVal} | ${typeVal} | ${desc} |` + }) + + return [ + '| Flag | Default | Type | Description |', + '| --- | --- | --- | --- |', + ...rows, + ].join('\n') +} + +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) + } + } + + // Merge all definitions for table generation + const allDefinitions = { ...definitions, ...subDefinitions } + const allParams = [...commandSpecificParams, ...globalConfigParams] + + // Add flags section with all parameters combined + if (allParams.length > 0) { + parts.push('#### Flags', '') + parts.push(generateFlagsTable(allParams, allDefinitions), '') + } + + 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 = [] + + 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}` + }) - return src.replace(replacer, paramsConfig.join('\n\n')) + /* 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 +372,11 @@ module.exports = { manPath: manPath, md: transformMd, html: transformHTML, + // Testing utilities for command injection + testing: { + registerCommand, + unregisterCommand, + clearCommandRegistry, + getCommandByDoc, + }, } diff --git a/docs/lib/template.html b/docs/lib/template.html index 622dc327046ee..7736e8f82a115 100644 --- a/docs/lib/template.html +++ b/docs/lib/template.html @@ -125,6 +125,51 @@ margin: 3em 0 4em 0; padding-top: 2em; } + +table { + width: 100%; + margin: 1em 0; + border-radius: 6px; + border: 1px solid #e1e4e8; + overflow: hidden; + border-collapse: separate; + border-spacing: 0; +} + +table thead { + background-color: #f6f8fa; +} + +table tbody { + background-color: #ffffff; +} + +table th, +table td { + padding: 0.75em; + text-align: left; + border-right: 1px solid #e1e4e8; + border-bottom: 1px solid #e1e4e8; +} + +table th:last-child, +table td:last-child { + border-right: none; +} + +table tbody tr:last-child td { + border-bottom: none; +} + +table th { + font-weight: 600; + background-color: #f6f8fa; +} + +table code { + white-space: nowrap; +} + diff --git a/docs/test/index.js b/docs/test/index.js index be537e68b2a18..38feae3173091 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,1120 @@ 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: [/--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, /--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, /--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 command-specific and global params', async t => { + // Register a subcommand that has both command-specific definitions AND global params + class SubMixed { + static description = 'Subcommand with mixed params' + static usage = [''] + static params = ['sub-flag', 'registry'] + static definitions = { + 'sub-flag': { + key: 'sub-flag', + default: false, + type: Boolean, + description: 'A subcommand-specific flag', + describe: () => '#### `sub-flag`\n\n* Default: false\n* Type: Boolean\n\nA subcommand-specific flag', + }, + } + } + + registerCommand('testcmd-sub-mixed', { + usage: [''], + params: null, + subcommands: { + mysub: SubMixed, + }, + }) + + const doc = createCommandDoc('npm-testcmd-sub-mixed', 'Test command with subcommand with mixed params') + const { html } = await testBuildDocs(t, { + content: { + commands: { 'npm-testcmd-sub-mixed.md': doc }, + }, + }) + + const htmlContent = await readHtmlDoc(html, 'npm-testcmd-sub-mixed') + t.ok(htmlContent.length > 0, 'generates HTML for subcommand with mixed params') + t.match(htmlContent, /sub-flag/, 'includes subcommand-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, /--reg/, 'includes first alias') + t.match(htmlContent, /--r/, 'includes second alias') + }) + + t.test('subcommand with flags without describe method (fallback table format)', async t => { + // Register a subcommand that has definitions without describe() method + // This tests the fallback table format path + class SubWithoutDescribe { + static description = 'Subcommand with simple flags' + static usage = [''] + static definitions = { + 'simple-flag': { + key: 'simple-flag', + default: false, + type: Boolean, + description: 'A simple flag without describe method', + }, + 'aliased-flag': { + key: 'aliased-flag', + default: 'default-val', + type: String, + description: 'A flag with aliases and short form', + alias: ['af', 'alias-f'], + short: 'a', + }, + 'custom-default': { + key: 'custom-default', + default: 'value', + type: String, + defaultDescription: 'custom description of default', + typeDescription: 'Custom Type', + description: 'A flag with custom descriptions', + }, + 'falsy-default-desc': { + key: 'falsy-default-desc', + default: 'another-value', + type: String, + defaultDescription: '', // Explicitly falsy + description: 'A flag with empty defaultDescription', + }, + } + } + + registerCommand('testcmd-sub-nodescribe', { + usage: [''], + params: null, + subcommands: { + mysub: SubWithoutDescribe, + }, + }) + + const doc = createCommandDoc('npm-testcmd-sub-nodescribe', 'Test command with subcommand without describe') + const { html } = await testBuildDocs(t, { + content: { + commands: { 'npm-testcmd-sub-nodescribe.md': doc }, + }, + }) + + const htmlContent = await readHtmlDoc(html, 'npm-testcmd-sub-nodescribe') + t.ok(htmlContent.length > 0, 'generates HTML for subcommand with simple flags') + t.match(htmlContent, /simple-flag/, 'includes simple flag') + t.match(htmlContent, /aliased-flag/, 'includes aliased flag') + t.match(htmlContent, /-a/, 'includes short form') + t.match(htmlContent, /af/, 'includes alias') + t.match(htmlContent, /custom description of default/, 'includes custom default description') + t.match(htmlContent, /Custom Type/, 'includes custom type description') + t.match(htmlContent, /falsy-default-desc/, 'includes flag with falsy defaultDescription') + }) + + t.test('subcommand with command-specific params and table format (no describe)', async t => { + // Register a subcommand with command-specific params but no describe method + // This tests the table format path for command-specific params + class SubMixedNoDescribe { + static description = 'Subcommand with command-specific params but no describe' + static usage = [''] + static params = ['sub-custom', 'registry'] + static definitions = { + 'sub-custom': { + key: 'sub-custom', + default: 'default-val', + type: String, + description: 'A command-specific flag without describe', + }, + } + } + + registerCommand('testcmd-sub-mixed-nodesc', { + usage: [''], + params: null, + subcommands: { + mysub: SubMixedNoDescribe, + }, + }) + + const doc = createCommandDoc('npm-testcmd-sub-mixed-nodesc', 'Test command with subcommand with mixed params no describe') + const { html } = await testBuildDocs(t, { + content: { + commands: { 'npm-testcmd-sub-mixed-nodesc.md': doc }, + }, + }) + + const htmlContent = await readHtmlDoc(html, 'npm-testcmd-sub-mixed-nodesc') + t.ok(htmlContent.length > 0, 'generates HTML for subcommand with command-specific params no describe') + t.match(htmlContent, /sub-custom/, 'includes command-specific flag') + t.match(htmlContent, /registry/, 'includes global registry param') + }) + + t.test('subcommand with no params', async t => { + // Register a subcommand with no params or definitions + // This tests the edge case where allParams.length === 0 + class SubNoParams { + static description = 'Subcommand with no params' + static usage = [''] + } + + registerCommand('testcmd-sub-noparams', { + usage: [''], + params: null, + subcommands: { + mysub: SubNoParams, + }, + }) + + const doc = createCommandDoc('npm-testcmd-sub-noparams', 'Test command with subcommand with no params') + const { html } = await testBuildDocs(t, { + content: { + commands: { 'npm-testcmd-sub-noparams.md': doc }, + }, + }) + + const htmlContent = await readHtmlDoc(html, 'npm-testcmd-sub-noparams') + t.ok(htmlContent.length > 0, 'generates HTML for subcommand with no params') + t.match(htmlContent, /mysub/, 'includes subcommand') + }) +}) diff --git a/lib/base-cmd.js b/lib/base-cmd.js index 3e6c4758cbd58..f55a8ce442c8c 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 // 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 this.usageError(`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 7aaf3564cb9fc..580997b66c6f7 100644 --- a/lib/commands/cache.js +++ b/lib/commands/cache.js @@ -363,7 +363,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/circleci.js b/lib/commands/trust/circleci.js new file mode 100644 index 0000000000000..b3f61771a874e --- /dev/null +++ b/lib/commands/trust/circleci.js @@ -0,0 +1,170 @@ +const Definition = require('@npmcli/config/lib/definitions/definition.js') +const globalDefinitions = require('@npmcli/config/lib/definitions/definitions.js') +const TrustCommand = require('../../trust-cmd.js') + +// UUID validation regex +const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +class TrustCircleCI extends TrustCommand { + static description = 'Create a trusted relationship between a package and CircleCI' + static name = 'circleci' + static positionals = 1 // expects at most 1 positional (package name) + static providerName = 'CircleCI' + static providerEntity = 'CircleCI pipeline' + + static usage = [ + '[package] --org-id --project-id --pipeline-definition-id --vcs-origin [--context-id ...] [-y|--yes]', + ] + + static definitions = { + yes: globalDefinitions.yes, + json: globalDefinitions.json, + 'dry-run': globalDefinitions['dry-run'], + 'org-id': new Definition('org-id', { + default: null, + type: String, + description: 'CircleCI organization UUID', + }), + 'project-id': new Definition('project-id', { + default: null, + type: String, + description: 'CircleCI project UUID', + }), + 'pipeline-definition-id': new Definition('pipeline-definition-id', { + default: null, + type: String, + description: 'CircleCI pipeline definition UUID', + }), + 'vcs-origin': new Definition('vcs-origin', { + default: null, + type: String, + description: "CircleCI repository origin in format 'provider/owner/repo'", + }), + 'context-id': new Definition('context-id', { + default: null, + type: [null, String, Array], + description: 'CircleCI context UUID to match', + }), + } + + validateUuid (value, fieldName) { + if (!UUID_REGEX.test(value)) { + throw new Error(`${fieldName} must be a valid UUID`) + } + } + + validateVcsOrigin (value) { + // Expected format: provider/owner/repo (e.g., github.com/owner/repo, bitbucket.org/owner/repo) + const parts = value.split('/') + if (parts.length < 3) { + throw new Error("vcs-origin must be in format 'provider/owner/repo'") + } + } + + // Generate a URL from vcs-origin (e.g., github.com/npm/repo -> https://github.com/npm/repo) + getVcsOriginUrl (vcsOrigin) { + if (!vcsOrigin) { + return null + } + // vcs-origin format: github.com/owner/repo or bitbucket.org/owner/repo + return `https://${vcsOrigin}` + } + + static optionsToBody (options) { + const { orgId, projectId, pipelineDefinitionId, vcsOrigin, contextIds } = options + const trustConfig = { + type: 'circleci', + claims: { + 'oidc.circleci.com/org-id': orgId, + 'oidc.circleci.com/project-id': projectId, + 'oidc.circleci.com/pipeline-definition-id': pipelineDefinitionId, + 'oidc.circleci.com/vcs-origin': vcsOrigin, + }, + } + if (contextIds && contextIds.length > 0) { + trustConfig.claims['oidc.circleci.com/context-ids'] = contextIds + } + return trustConfig + } + + static bodyToOptions (body) { + return { + ...(body.id) && { id: body.id }, + ...(body.type) && { type: body.type }, + ...(body.claims?.['oidc.circleci.com/org-id']) && { orgId: body.claims['oidc.circleci.com/org-id'] }, + ...(body.claims?.['oidc.circleci.com/project-id']) && { projectId: body.claims['oidc.circleci.com/project-id'] }, + ...(body.claims?.['oidc.circleci.com/pipeline-definition-id']) && { + pipelineDefinitionId: body.claims['oidc.circleci.com/pipeline-definition-id'], + }, + ...(body.claims?.['oidc.circleci.com/vcs-origin']) && { vcsOrigin: body.claims['oidc.circleci.com/vcs-origin'] }, + ...(body.claims?.['oidc.circleci.com/context-ids']) && { contextIds: body.claims['oidc.circleci.com/context-ids'] }, + } + } + + // Override flagsToOptions since CircleCI doesn't use file/entity pattern + async flagsToOptions ({ positionalArgs, flags }) { + const content = await this.optionalPkgJson() + const pkgName = positionalArgs[0] || content.name + + if (!pkgName) { + throw new Error('Package name must be specified either as an argument or in package.json file') + } + + const orgId = flags['org-id'] + const projectId = flags['project-id'] + const pipelineDefinitionId = flags['pipeline-definition-id'] + const vcsOrigin = flags['vcs-origin'] + const contextIds = flags['context-id'] + + // Validate required flags + if (!orgId) { + throw new Error('org-id is required') + } + if (!projectId) { + throw new Error('project-id is required') + } + if (!pipelineDefinitionId) { + throw new Error('pipeline-definition-id is required') + } + if (!vcsOrigin) { + throw new Error('vcs-origin is required') + } + + // Validate formats + this.validateUuid(orgId, 'org-id') + this.validateUuid(projectId, 'project-id') + this.validateUuid(pipelineDefinitionId, 'pipeline-definition-id') + this.validateVcsOrigin(vcsOrigin) + if (contextIds?.length > 0) { + for (const contextId of contextIds) { + this.validateUuid(contextId, 'context-id') + } + } + + return { + values: { + package: pkgName, + orgId, + projectId, + pipelineDefinitionId, + vcsOrigin, + ...(contextIds?.length > 0 && { contextIds }), + }, + fromPackageJson: {}, + warnings: [], + urls: { + package: this.getFrontendUrl({ pkgName }), + vcsOrigin: this.getVcsOriginUrl(vcsOrigin), + }, + } + } + + async exec (positionalArgs, flags) { + await this.createConfigCommand({ + positionalArgs, + flags, + }) + } +} + +module.exports = TrustCircleCI diff --git a/lib/commands/trust/github.js b/lib/commands/trust/github.js new file mode 100644 index 0000000000000..98812894cd5f5 --- /dev/null +++ b/lib/commands/trust/github.js @@ -0,0 +1,102 @@ +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, + registry: globalDefinitions.registry, + '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.constructor.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, + }, + ...(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.claims?.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..b21c35812eb1e --- /dev/null +++ b/lib/commands/trust/gitlab.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 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, + registry: globalDefinitions.registry, + '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.constructor.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, + }, + ...(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.claims?.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..9c3bf070a4ce1 --- /dev/null +++ b/lib/commands/trust/index.js @@ -0,0 +1,24 @@ +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'), + circleci: require('./circleci.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..789e81c443f54 --- /dev/null +++ b/lib/commands/trust/list.js @@ -0,0 +1,47 @@ +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') + +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, + registry: globalDefinitions.registry, + } + + static bodyToOptions (body) { + if (body.type === 'github') { + return TrustGithub.bodyToOptions(body) + } else if (body.type === 'gitlab') { + return TrustGitlab.bodyToOptions(body) + } + return TrustCommand.bodyToOptions(body) + } + + async exec (positionalArgs) { + const packageName = positionalArgs[0] || (await this.optionalPkgJson()).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 body = await otplease(this.npm, this.npm.flatOptions, opts => npmFetch.json(uri, { + ...opts, + method: 'GET', + })) + this.displayResponseBody({ body, packageName }) + } +} + +module.exports = TrustList diff --git a/lib/commands/trust/revoke.js b/lib/commands/trust/revoke.js new file mode 100644 index 0000000000000..8726bbeeb08f3 --- /dev/null +++ b/lib/commands/trust/revoke.js @@ -0,0 +1,51 @@ +const globalDefinitions = require('@npmcli/config/lib/definitions/definitions.js') +const Definition = require('@npmcli/config/lib/definitions/definition.js') +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'], + registry: globalDefinitions.registry, + 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 pkgName = positionalArgs[0] || (await this.optionalPkgJson()).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..d30554d400f07 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 = [] @@ -72,6 +72,7 @@ class Npm { shorthands, argv: [...process.argv, ...argv], excludeNpmCwd, + warn: false, }) } @@ -227,8 +228,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 +305,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..5fab8df1d21aa --- /dev/null +++ b/lib/trust-cmd.js @@ -0,0 +1,284 @@ +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, META } = 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) { + // Disable redaction: trust config values (e.g. CircleCI UUIDs) are not secrets + output.standard(JSON.stringify(options.values, null, 2), { [META]: true, redact: false }) + 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`)})`) + } + lines.push(parts.join(' ')) + } + } + if (pad) { + output.standard() + } + output.standard(lines.join('\n'), { [META]: true, redact: false }) + // Print URLs on their own lines after config, following the same order as rest keys + if (urls) { + const urlLines = [] + for (const key of Object.keys(rest)) { + if (urls[key]) { + urlLines.push(chalk.blue(urls[key])) + } + } + if (urlLines.length > 0) { + output.standard() + output.standard(urlLines.join('\n'), { [META]: true, redact: false }) + } + } + 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() + this.dialogue`Trust configuration created successfully for ${options.values.package} with the following settings:` + this.displayResponseBody({ body, packageName: options.values.package }) + } + + 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 }), + }, + } + } + + displayResponseBody ({ body, packageName }) { + if (!body || body.length === 0) { + this.dialogue`No trust configurations found for package (${packageName})` + return + } + const items = Array.isArray(body) ? body : [body] + for (const config of items) { + const values = this.constructor.bodyToOptions(config) + output.standard() + this.logOptions({ values }, false) + } + output.standard() + } +} + +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 1e29ff05185d1..18b95223523ec 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 @@ -63,6 +64,58 @@ npm error [--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 --allow-git +npm error Limits the ability for npm to fetch dependencies from git references. +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..0dd229f38630a 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,64 @@ 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 + --registry + --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 + --registry + --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 + circleci + 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..0357507a25a77 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:310:7) +verbose stack at MockNpm.exec ({CWD}/lib/npm.js:209: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:310:7) +verbose stack at MockNpm.exec ({CWD}/lib/npm.js:209: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:310:7) +verbose stack at MockNpm.exec ({CWD}/lib/npm.js:209: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 b9b2e32355e94..efa04e49b026c 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", @@ -2775,6 +2776,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 @@ -2803,6 +2814,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 @@ -2835,6 +2856,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 @@ -2868,6 +2932,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 @@ -2900,6 +2980,10 @@ npm cache npx info ... Options: [--cache ] + --cache + The location of npm's cache directory. + + Run "npm help cache" for more info \`\`\`bash @@ -2933,6 +3017,58 @@ 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. + + --allow-git + Limits the ability for npm to fetch dependencies from git references. + + --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 @@ -2968,6 +3104,9 @@ Tab Completion for npm Usage: npm completion +Options: + + Run "npm help completion" for more info \`\`\`bash @@ -2994,6 +3133,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 @@ -3034,6 +3189,58 @@ 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. + + --allow-git + Limits the ability for npm to fetch dependencies from git references. + + --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 @@ -3072,6 +3279,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 @@ -3099,6 +3316,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 @@ -3132,6 +3389,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 @@ -3160,6 +3427,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 @@ -3186,6 +3469,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 @@ -3206,6 +3493,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 @@ -3231,6 +3522,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 @@ -3260,6 +3567,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 @@ -3283,6 +3597,10 @@ npm explore [ -- ] Options: [--shell ] + --shell + The shell to run for the \`npm explore\` command. + + Run "npm help explore" for more info \`\`\`bash @@ -3309,6 +3627,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 @@ -3343,6 +3707,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 @@ -3365,6 +3745,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 @@ -3385,6 +3769,10 @@ npm help [] Options: [--viewer ] + --viewer + The program to use to view help content. + + alias: hlep Run "npm help help" for more info @@ -3409,6 +3797,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 @@ -3434,6 +3826,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 @@ -3480,6 +3915,88 @@ 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. + + --allow-git + Limits the ability for npm to fetch dependencies from git references. + + --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 @@ -3535,6 +4052,58 @@ 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. + + --allow-git + Limits the ability for npm to fetch dependencies from git references. + + --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 @@ -3583,6 +4152,88 @@ 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. + + --allow-git + Limits the ability for npm to fetch dependencies from git references. + + --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 @@ -3640,6 +4291,67 @@ 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. + + --allow-git + Limits the ability for npm to fetch dependencies from git references. + + --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 @@ -3686,6 +4398,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 @@ -3722,6 +4480,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 @@ -3744,6 +4512,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 @@ -3770,6 +4545,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 @@ -3829,6 +4650,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 @@ -3860,6 +4694,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 @@ -3888,6 +4744,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 @@ -3917,6 +4786,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 @@ -3941,6 +4832,10 @@ npm ping Options: [--registry ] + --registry + The base URL of the npm registry. + + Run "npm help ping" for more info \`\`\`bash @@ -3968,6 +4863,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 @@ -3994,6 +4902,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 @@ -4017,6 +4929,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 @@ -4047,6 +4972,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 @@ -4076,6 +5032,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 @@ -4105,6 +5083,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 @@ -4131,6 +5125,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 @@ -4162,6 +5181,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 @@ -4184,6 +5219,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 @@ -4203,6 +5245,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 @@ -4225,6 +5271,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 @@ -4257,6 +5325,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 @@ -4283,6 +5370,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 @@ -4317,6 +5438,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 @@ -4355,6 +5483,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 @@ -4377,6 +5515,10 @@ npm stars [] Options: [--registry ] + --registry + The base URL of the npm registry. + + Run "npm help stars" for more info \`\`\`bash @@ -4397,6 +5539,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 @@ -4416,6 +5565,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 @@ -4439,6 +5595,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 @@ -4466,6 +5635,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 @@ -4497,6 +5673,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 @@ -4524,6 +5746,50 @@ 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 + + circleci + Create a trusted relationship between a package and CircleCI + + 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 +#### Synopsis +#### Flags +#### Synopsis +#### Flags +#### Synopsis +#### Flags +#### Synopsis +#### Flags +` + exports[`test/lib/docs.js TAP usage undeprecate > must match snapshot 1`] = ` Undeprecate a version of a package @@ -4533,6 +5799,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 @@ -4558,6 +5834,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 @@ -4587,6 +5882,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 @@ -4608,6 +5916,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 @@ -4639,6 +5957,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 @@ -4685,6 +6064,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 @@ -4719,6 +6135,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 @@ -4744,6 +6173,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/circleci.js b/test/lib/commands/trust/circleci.js new file mode 100644 index 0000000000000..613609a564fcf --- /dev/null +++ b/test/lib/commands/trust/circleci.js @@ -0,0 +1,449 @@ +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('circleci 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', [ + 'circleci', + packageName, + '--yes', + '--org-id', '550e8400-e29b-41d4-a716-446655440000', + '--project-id', '7c9e6679-7425-40de-944b-e07fc1f90ae7', + '--pipeline-definition-id', '6ba7b810-9dad-11d1-80b4-00c04fd430c8', + '--vcs-origin', 'github.com/owner/repo', + '--context-id', '123e4567-e89b-12d3-a456-426614174000', + ]) +}) + +t.test('circleci without optional context-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', + }, + 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', [ + 'circleci', + packageName, + '--yes', + '--org-id', '550e8400-e29b-41d4-a716-446655440000', + '--project-id', '7c9e6679-7425-40de-944b-e07fc1f90ae7', + '--pipeline-definition-id', '6ba7b810-9dad-11d1-80b4-00c04fd430c8', + '--vcs-origin', 'github.com/owner/repo', + ]) +}) + +t.test('circleci with multiple context-ids', 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', [ + 'circleci', + packageName, + '--yes', + '--org-id', '550e8400-e29b-41d4-a716-446655440000', + '--project-id', '7c9e6679-7425-40de-944b-e07fc1f90ae7', + '--pipeline-definition-id', '6ba7b810-9dad-11d1-80b4-00c04fd430c8', + '--vcs-origin', 'github.com/owner/repo', + '--context-id', '123e4567-e89b-12d3-a456-426614174000', + '--context-id', 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + ]) +}) + +t.test('circleci missing required org-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', + }, + }) + + await t.rejects( + npm.exec('trust', [ + 'circleci', + packageName, + '--yes', + '--project-id', '7c9e6679-7425-40de-944b-e07fc1f90ae7', + '--pipeline-definition-id', '6ba7b810-9dad-11d1-80b4-00c04fd430c8', + '--vcs-origin', 'github.com/owner/repo', + ]), + { message: /org-id is required/ } + ) +}) + +t.test('circleci missing required project-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', + }, + }) + + await t.rejects( + npm.exec('trust', [ + 'circleci', + packageName, + '--yes', + '--org-id', '550e8400-e29b-41d4-a716-446655440000', + '--pipeline-definition-id', '6ba7b810-9dad-11d1-80b4-00c04fd430c8', + '--vcs-origin', 'github.com/owner/repo', + ]), + { message: /project-id is required/ } + ) +}) + +t.test('circleci missing required pipeline-definition-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', + }, + }) + + await t.rejects( + npm.exec('trust', [ + 'circleci', + packageName, + '--yes', + '--org-id', '550e8400-e29b-41d4-a716-446655440000', + '--project-id', '7c9e6679-7425-40de-944b-e07fc1f90ae7', + '--vcs-origin', 'github.com/owner/repo', + ]), + { message: /pipeline-definition-id is required/ } + ) +}) + +t.test('circleci missing required vcs-origin', 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', [ + 'circleci', + packageName, + '--yes', + '--org-id', '550e8400-e29b-41d4-a716-446655440000', + '--project-id', '7c9e6679-7425-40de-944b-e07fc1f90ae7', + '--pipeline-definition-id', '6ba7b810-9dad-11d1-80b4-00c04fd430c8', + ]), + { message: /vcs-origin is required/ } + ) +}) + +t.test('circleci with invalid org-id uuid 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', + }, + }) + + await t.rejects( + npm.exec('trust', [ + 'circleci', + packageName, + '--yes', + '--org-id', 'not-a-uuid', + '--project-id', '7c9e6679-7425-40de-944b-e07fc1f90ae7', + '--pipeline-definition-id', '6ba7b810-9dad-11d1-80b4-00c04fd430c8', + '--vcs-origin', 'github.com/owner/repo', + ]), + { message: /org-id must be a valid UUID/ } + ) +}) + +t.test('circleci with invalid vcs-origin 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', + }, + }) + + await t.rejects( + npm.exec('trust', [ + 'circleci', + packageName, + '--yes', + '--org-id', '550e8400-e29b-41d4-a716-446655440000', + '--project-id', '7c9e6679-7425-40de-944b-e07fc1f90ae7', + '--pipeline-definition-id', '6ba7b810-9dad-11d1-80b4-00c04fd430c8', + '--vcs-origin', 'invalid-format', + ]), + { message: /vcs-origin must be in format 'provider\/owner\/repo'/ } + ) +}) + +t.test('circleci missing package name', 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', [ + 'circleci', + '--yes', + '--org-id', '550e8400-e29b-41d4-a716-446655440000', + '--project-id', '7c9e6679-7425-40de-944b-e07fc1f90ae7', + '--pipeline-definition-id', '6ba7b810-9dad-11d1-80b4-00c04fd430c8', + '--vcs-origin', 'github.com/owner/repo', + ]), + { message: /Package name must be specified either as an argument or in package.json file/ } + ) +}) + +t.test('bodyToOptions with all fields', t => { + const TrustCircleCI = require('../../../../lib/commands/trust/circleci.js') + + const body = { + id: 'test-id', + type: 'circleci', + claims: { + 'oidc.circleci.com/org-id': '550e8400-e29b-41d4-a716-446655440000', + 'oidc.circleci.com/project-id': '7c9e6679-7425-40de-944b-e07fc1f90ae7', + 'oidc.circleci.com/pipeline-definition-id': '6ba7b810-9dad-11d1-80b4-00c04fd430c8', + 'oidc.circleci.com/vcs-origin': 'github.com/owner/repo', + 'oidc.circleci.com/context-ids': ['123e4567-e89b-12d3-a456-426614174000'], + }, + } + + const options = TrustCircleCI.bodyToOptions(body) + + t.equal(options.id, 'test-id', 'id should be set') + t.equal(options.type, 'circleci', 'type should be set') + t.equal(options.orgId, '550e8400-e29b-41d4-a716-446655440000', 'orgId should be set') + t.equal(options.projectId, '7c9e6679-7425-40de-944b-e07fc1f90ae7', 'projectId should be set') + t.equal(options.pipelineDefinitionId, '6ba7b810-9dad-11d1-80b4-00c04fd430c8', 'pipelineDefinitionId should be set') + t.equal(options.vcsOrigin, 'github.com/owner/repo', 'vcsOrigin should be set') + t.same(options.contextIds, ['123e4567-e89b-12d3-a456-426614174000'], 'contextIds should be set') + t.end() +}) + +t.test('bodyToOptions without optional context_ids', t => { + const TrustCircleCI = require('../../../../lib/commands/trust/circleci.js') + + const body = { + id: 'test-id', + type: 'circleci', + claims: { + 'oidc.circleci.com/org-id': '550e8400-e29b-41d4-a716-446655440000', + 'oidc.circleci.com/project-id': '7c9e6679-7425-40de-944b-e07fc1f90ae7', + 'oidc.circleci.com/pipeline-definition-id': '6ba7b810-9dad-11d1-80b4-00c04fd430c8', + 'oidc.circleci.com/vcs-origin': 'github.com/owner/repo', + }, + } + + const options = TrustCircleCI.bodyToOptions(body) + + t.equal(options.contextIds, undefined, 'contextIds should be undefined') + t.end() +}) + +t.test('optionsToBody with all fields', t => { + const TrustCircleCI = require('../../../../lib/commands/trust/circleci.js') + + const options = { + orgId: '550e8400-e29b-41d4-a716-446655440000', + projectId: '7c9e6679-7425-40de-944b-e07fc1f90ae7', + pipelineDefinitionId: '6ba7b810-9dad-11d1-80b4-00c04fd430c8', + vcsOrigin: 'github.com/owner/repo', + contextIds: ['123e4567-e89b-12d3-a456-426614174000'], + } + + const body = TrustCircleCI.optionsToBody(options) + + t.equal(body.type, 'circleci', 'type should be circleci') + t.equal(body.claims['oidc.circleci.com/org-id'], '550e8400-e29b-41d4-a716-446655440000', 'org-id should be set') + t.equal(body.claims['oidc.circleci.com/project-id'], '7c9e6679-7425-40de-944b-e07fc1f90ae7', 'project-id should be set') + t.equal(body.claims['oidc.circleci.com/pipeline-definition-id'], '6ba7b810-9dad-11d1-80b4-00c04fd430c8', 'pipeline-definition-id should be set') + t.equal(body.claims['oidc.circleci.com/vcs-origin'], 'github.com/owner/repo', 'vcs-origin should be set') + t.same(body.claims['oidc.circleci.com/context-ids'], ['123e4567-e89b-12d3-a456-426614174000'], 'context-ids should be set') + t.end() +}) + +t.test('optionsToBody without optional contextIds', t => { + const TrustCircleCI = require('../../../../lib/commands/trust/circleci.js') + + const options = { + orgId: '550e8400-e29b-41d4-a716-446655440000', + projectId: '7c9e6679-7425-40de-944b-e07fc1f90ae7', + pipelineDefinitionId: '6ba7b810-9dad-11d1-80b4-00c04fd430c8', + vcsOrigin: 'github.com/owner/repo', + } + + const body = TrustCircleCI.optionsToBody(options) + + t.equal(body.claims['oidc.circleci.com/context-ids'], undefined, 'context-ids should be undefined') + t.end() +}) + +t.test('optionsToBody with multiple contextIds', t => { + const TrustCircleCI = require('../../../../lib/commands/trust/circleci.js') + + const options = { + orgId: '550e8400-e29b-41d4-a716-446655440000', + projectId: '7c9e6679-7425-40de-944b-e07fc1f90ae7', + pipelineDefinitionId: '6ba7b810-9dad-11d1-80b4-00c04fd430c8', + vcsOrigin: 'github.com/owner/repo', + contextIds: [ + '123e4567-e89b-12d3-a456-426614174000', + 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + ], + } + + const body = TrustCircleCI.optionsToBody(options) + + t.same(body.claims['oidc.circleci.com/context-ids'], [ + '123e4567-e89b-12d3-a456-426614174000', + 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + ], 'context-ids should contain both UUIDs') + t.end() +}) + +t.test('getVcsOriginUrl generates correct URL', t => { + const TrustCircleCI = require('../../../../lib/commands/trust/circleci.js') + + t.equal( + TrustCircleCI.prototype.getVcsOriginUrl('github.com/npm/cli'), + 'https://github.com/npm/cli', + 'should generate https URL from vcs-origin' + ) + t.equal( + TrustCircleCI.prototype.getVcsOriginUrl('bitbucket.org/owner/repo'), + 'https://bitbucket.org/owner/repo', + 'should work with bitbucket' + ) + t.equal( + TrustCircleCI.prototype.getVcsOriginUrl(null), + null, + 'should return null for null input' + ) + t.equal( + TrustCircleCI.prototype.getVcsOriginUrl(undefined), + null, + 'should return null for undefined input' + ) + t.end() +}) diff --git a/test/lib/commands/trust/github.js b/test/lib/commands/trust/github.js new file mode 100644 index 0000000000000..a2b16d272bde1 --- /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..0b60196830c5f --- /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..99d25b66bc90c --- /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: /Package name must be specified either as an argument or in the package\.json file/ } + ) +}) + +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..d44d576664c86 --- /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: /Package name must be specified either as an argument or in the package\.json file/ } + ) +}) + +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..f0c52aadbd2c4 --- /dev/null +++ b/test/lib/trust-cmd.js @@ -0,0 +1,848 @@ +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') +}) + +t.test('TrustCommand - logOptions with no 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 without urls object + cmd.logOptions({ + values: { + type: 'github', + id: 'test-id', + repository: 'npm/cli', + file: 'workflow.yml', + }, + }) + const output = joinedOutput() + t.ok(output.includes('repository'), 'shows repository field') + t.ok(output.includes('file'), 'shows file field') + t.notOk(output.includes('Links to verify manually'), 'does not show links header when no urls') +}) + +t.test('TrustCommand - logOptions with urls but all values are null', 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 urls object but all values are null/undefined + cmd.logOptions({ + values: { + type: 'github', + id: 'test-id', + repository: 'npm/cli', + file: 'workflow.yml', + }, + urls: { + repository: null, + file: undefined, + }, + }) + const output = joinedOutput() + t.ok(output.includes('repository'), 'shows repository field') + t.ok(output.includes('file'), 'shows file field') + t.notOk(output.includes('Links to verify manually'), 'does not show links header when all urls are null') +}) 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') +})