From f75e0613f30cb86ba624a6f79c28045e582d03dd Mon Sep 17 00:00:00 2001 From: reggi Date: Mon, 12 Jan 2026 12:30:36 -0500 Subject: [PATCH 01/28] feat: npm trust command --- docs/lib/build.js | 214 ++- docs/lib/content/commands/npm-get.md | 21 + docs/lib/content/commands/npm-ll.md | 21 + docs/lib/content/commands/npm-set.md | 21 + docs/lib/content/commands/npm-trust.md | 21 + docs/lib/content/nav.yml | 514 +++--- docs/lib/index.js | 171 +- docs/test/index.js | 470 ++++++ lib/base-cmd.js | 211 ++- lib/commands/cache.js | 2 +- lib/commands/completion.js | 133 +- lib/commands/doctor.js | 8 +- lib/commands/run.js | 2 +- lib/commands/trust.js | 23 + lib/npm.js | 83 +- lib/subcommands/trust-github.js | 102 ++ lib/subcommands/trust-gitlab.js | 103 ++ lib/subcommands/trust-list.js | 57 + lib/subcommands/trust-revoke.js | 53 + lib/trust-cmd.js | 251 +++ lib/utils/auth.js | 2 +- lib/utils/cmd-list.js | 1 + lib/utils/display.js | 4 +- lib/utils/npm-usage.js | 2 +- lib/utils/reify-output.js | 2 +- lib/utils/verify-signatures.js | 20 +- mock-registry/lib/index.js | 20 + .../test/lib/commands/completion.js.test.cjs | 212 +-- .../test/lib/commands/install.js.test.cjs | 18 +- .../test/lib/commands/publish.js.test.cjs | 4 + tap-snapshots/test/lib/docs.js.test.cjs | 1425 +++++++++++++++++ tap-snapshots/test/lib/npm.js.test.cjs | 64 +- test/fixtures/mock-npm.js | 10 + test/lib/base-cmd.js | 462 ++++++ test/lib/commands/completion.js | 135 ++ test/lib/docs.js | 3 +- test/lib/npm.js | 245 ++- test/lib/subcommands/trust-github.js | 153 ++ test/lib/subcommands/trust-gitlab.js | 153 ++ test/lib/subcommands/trust-list.js | 256 +++ test/lib/subcommands/trust-revoke.js | 319 ++++ test/lib/trust-cmd.js | 786 +++++++++ .../config/lib/definitions/definition.js | 1 + workspaces/config/lib/index.js | 59 +- workspaces/config/test/index.js | 154 +- 45 files changed, 6459 insertions(+), 532 deletions(-) create mode 100644 docs/lib/content/commands/npm-get.md create mode 100644 docs/lib/content/commands/npm-ll.md create mode 100644 docs/lib/content/commands/npm-set.md create mode 100644 docs/lib/content/commands/npm-trust.md create mode 100644 lib/commands/trust.js create mode 100644 lib/subcommands/trust-github.js create mode 100644 lib/subcommands/trust-gitlab.js create mode 100644 lib/subcommands/trust-list.js create mode 100644 lib/subcommands/trust-revoke.js create mode 100644 lib/trust-cmd.js create mode 100644 test/lib/base-cmd.js create mode 100644 test/lib/subcommands/trust-github.js create mode 100644 test/lib/subcommands/trust-gitlab.js create mode 100644 test/lib/subcommands/trust-list.js create mode 100644 test/lib/subcommands/trust-revoke.js create mode 100644 test/lib/trust-cmd.js diff --git a/docs/lib/build.js b/docs/lib/build.js index 86f8acac102f1..bb18d15da130a 100644 --- a/docs/lib/build.js +++ b/docs/lib/build.js @@ -7,6 +7,208 @@ const parseFrontMatter = require('front-matter') const checkNav = require('./check-nav.js') const { DOC_EXT, ...transform } = require('./index.js') +// Generate nav.yml from the filesystem +const generateNav = async (contentPath, navPath) => { + const docsCommandsPath = join(contentPath, 'commands') + + // Read all command files + const commandFiles = 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, + } + }) + ) + + // Separate npm, npx, and other commands + const npm = allCommands.find(cmd => cmd.name === 'npm') + const npx = allCommands.find(cmd => cmd.name === 'npx') + const otherCommands = allCommands + .filter(cmd => cmd.name !== 'npm' && cmd.name !== 'npx') + .sort((a, b) => a.name.localeCompare(b.name)) + + // Remove the name field and arrange: npm, ...commands, npx + const commands = [npm, ...otherCommands, npx].filter(Boolean).map(({ name, ...rest }) => rest) + + // Build the navigation structure + const navData = [ + { + title: 'CLI Commands', + shortName: 'Commands', + url: '/commands', + children: commands, + }, + { + title: 'Configuring npm', + shortName: 'Configuring', + url: '/configuring-npm', + children: [ + { + title: 'Install', + url: '/configuring-npm/install', + description: 'Download and install node and npm', + }, + { + title: 'Folders', + url: '/configuring-npm/folders', + description: 'Folder structures used by npm', + }, + { + title: '.npmrc', + url: '/configuring-npm/npmrc', + description: 'The npm config files', + }, + { + title: 'npm-shrinkwrap.json', + url: '/configuring-npm/npm-shrinkwrap-json', + description: 'A publishable lockfile', + }, + { + title: 'package.json', + url: '/configuring-npm/package-json', + description: "Specifics of npm's package.json handling", + }, + { + title: 'package-lock.json', + url: '/configuring-npm/package-lock-json', + description: 'A manifestation of the manifest', + }, + ], + }, + { + title: 'Using npm', + shortName: 'Using', + url: '/using-npm', + children: [ + { + title: 'Registry', + url: '/using-npm/registry', + description: 'The JavaScript Package Registry', + }, + { + title: 'Package spec', + url: '/using-npm/package-spec', + description: 'Package name specifier', + }, + { + title: 'Config', + url: '/using-npm/config', + description: 'About npm configuration', + }, + { + title: 'Logging', + url: '/using-npm/logging', + description: 'Why, What & How we Log', + }, + { + title: 'Scope', + url: '/using-npm/scope', + description: 'Scoped packages', + }, + { + title: 'Scripts', + url: '/using-npm/scripts', + description: 'How npm handles the "scripts" field', + }, + { + title: 'Workspaces', + url: '/using-npm/workspaces', + description: 'Working with workspaces', + }, + { + title: 'Organizations', + url: '/using-npm/orgs', + description: 'Working with teams & organizations', + }, + { + title: 'Dependency Selectors', + url: '/using-npm/dependency-selectors', + description: 'Dependency Selector Syntax & Querying', + }, + { + title: 'Developers', + url: '/using-npm/developers', + description: 'Developer guide', + }, + { + title: 'Removal', + url: '/using-npm/removal', + description: 'Cleaning the slate', + }, + ], + }, + ] + + const prefix = ` +# This is the navigation for the documentation pages; it is not used +# directly within the CLI documentation. Instead, it will be used +# for the https://docs.npmjs.com/ site. +` + await fs.writeFile(navPath, `${prefix}\n\n${yaml.stringify(navData)}`, 'utf-8') +} + +// 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 cmd-list + const { commands } = require('../../lib/utils/cmd-list.js') + + // Get existing doc files + const existingDocs = await fs.readdir(docsCommandsPath) + const documentedCommands = existingDocs + .filter(f => f.startsWith('npm-') && f.endsWith(DOC_EXT)) + .map(f => f.replace('npm-', '').replace(DOC_EXT, '')) + + // Find commands without docs + const missingDocs = commands.filter(cmd => !documentedCommands.includes(cmd)) + + // Generate docs for missing commands + for (const cmd of missingDocs) { + const Command = require(join(commandsPath, `${cmd}.js`)) + const description = Command.description || `The ${cmd} command` + const docPath = join(docsCommandsPath, `npm-${cmd}${DOC_EXT}`) + + const template = `--- +title: npm-${cmd} +section: 1 +description: ${description} +--- + +### Synopsis + + + +### Description + +${description} + +### Configuration + + + +### See Also + +* [npm help config](/commands/npm-config) +` + + await fs.writeFile(docPath, template, 'utf-8') + } +} + 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 +230,15 @@ const pAll = async (obj) => { }, {}) } -const run = async ({ content, template, nav, man, html, md }) => { +const run = async ({ content, template, nav, man, html, md, skipAutoGenerate }) => { + // Auto-generate docs for commands without documentation + if (!skipAutoGenerate) { + await autoGenerateMissingDocs(content, nav) + } + + // Generate nav.yml from filesystem + await generateNav(content, nav) + await rmAll(man, html, md) const [contentPaths, navFile, options] = await Promise.all([ readDocs(content), @@ -145,3 +355,5 @@ const run = async ({ content, template, nav, man, html, md }) => { } module.exports = run +module.exports.generateNav = generateNav +module.exports.autoGenerateMissingDocs = autoGenerateMissingDocs diff --git a/docs/lib/content/commands/npm-get.md b/docs/lib/content/commands/npm-get.md new file mode 100644 index 0000000000000..9e03458e7c8ce --- /dev/null +++ b/docs/lib/content/commands/npm-get.md @@ -0,0 +1,21 @@ +--- +title: npm-get +section: 1 +description: Get a value from the npm configuration +--- + +### Synopsis + + + +### Description + +Get a value from the npm configuration + +### Configuration + + + +### See Also + +* [npm help config](/commands/npm-config) diff --git a/docs/lib/content/commands/npm-ll.md b/docs/lib/content/commands/npm-ll.md new file mode 100644 index 0000000000000..cceb4284592ef --- /dev/null +++ b/docs/lib/content/commands/npm-ll.md @@ -0,0 +1,21 @@ +--- +title: npm-ll +section: 1 +description: List installed packages +--- + +### Synopsis + + + +### Description + +List installed packages + +### Configuration + + + +### See Also + +* [npm help config](/commands/npm-config) diff --git a/docs/lib/content/commands/npm-set.md b/docs/lib/content/commands/npm-set.md new file mode 100644 index 0000000000000..864ce81be43ba --- /dev/null +++ b/docs/lib/content/commands/npm-set.md @@ -0,0 +1,21 @@ +--- +title: npm-set +section: 1 +description: Set a value in the npm configuration +--- + +### Synopsis + + + +### Description + +Set a value in the npm configuration + +### Configuration + + + +### See Also + +* [npm help config](/commands/npm-config) diff --git a/docs/lib/content/commands/npm-trust.md b/docs/lib/content/commands/npm-trust.md new file mode 100644 index 0000000000000..0a295c964ef9a --- /dev/null +++ b/docs/lib/content/commands/npm-trust.md @@ -0,0 +1,21 @@ +--- +title: npm-trust +section: 1 +description: Create a trusted relationship between a package and a OIDC provider +--- + +### Synopsis + + + +### Description + +Create a trusted relationship between a package and a OIDC provider + +### Configuration + + + +### See Also + +* [npm help config](/commands/npm-config) diff --git a/docs/lib/content/nav.yml b/docs/lib/content/nav.yml index f6f8014f28071..b6535000b3b2b 100644 --- a/docs/lib/content/nav.yml +++ b/docs/lib/content/nav.yml @@ -1,267 +1,279 @@ + # This is the navigation for the documentation pages; it is not used # directly within the CLI documentation. Instead, it will be used # for the https://docs.npmjs.com/ site. + - title: CLI Commands shortName: Commands url: /commands children: - - title: npm - url: /commands/npm - description: JavaScript package manager - - title: npm access - url: /commands/npm-access - description: Set access level on published packages - - title: npm adduser - url: /commands/npm-adduser - description: Add a registry user account - - title: npm audit - url: /commands/npm-audit - description: Run a security audit - - title: npm bugs - url: /commands/npm-bugs - description: Bugs for a package in a web browser maybe - - title: npm cache - url: /commands/npm-cache - description: Manipulates packages cache - - title: npm ci - url: /commands/npm-ci - description: Install a project with a clean slate - - title: npm completion - url: /commands/npm-completion - description: Tab completion for npm - - title: npm config - url: /commands/npm-config - description: Manage the npm configuration files - - title: npm dedupe - url: /commands/npm-dedupe - description: Reduce duplication - - title: npm deprecate - url: /commands/npm-deprecate - description: Deprecate a version of a package - - title: npm diff - url: /commands/npm-diff - description: The registry diff command - - title: npm dist-tag - url: /commands/npm-dist-tag - description: Modify package distribution tags - - title: npm docs - url: /commands/npm-docs - description: Docs for a package in a web browser maybe - - title: npm doctor - url: /commands/npm-doctor - description: Check your environments - - title: npm edit - url: /commands/npm-edit - description: Edit an installed package - - title: npm exec - url: /commands/npm-exec - description: Run a command from an npm package - - title: npm explain - url: /commands/npm-explain - description: Explain installed packages - - title: npm explore - url: /commands/npm-explore - description: Browse an installed package - - title: npm find-dupes - url: /commands/npm-find-dupes - description: Find duplication in the package tree - - title: npm fund - url: /commands/npm-fund - description: Retrieve funding information - - title: npm help - url: /commands/npm-help - description: Search npm help documentation - - title: npm help-search - url: /commands/npm-help-search - description: Get help on npm - - title: npm init - url: /commands/npm-init - description: Create a package.json file - - title: npm install - url: /commands/npm-install - description: Install a package - - title: npm install-ci-test - url: /commands/npm-install-ci-test - description: Install a project with a clean slate and run tests - - title: npm install-test - url: /commands/npm-install-test - description: Install package(s) and run tests - - title: npm link - url: /commands/npm-link - description: Symlink a package folder - - title: npm login - url: /commands/npm-login - description: Login to a registry user account - - title: npm logout - url: /commands/npm-logout - description: Log out of the registry - - title: npm ls - url: /commands/npm-ls - description: List installed packages - - title: npm org - url: /commands/npm-org - description: Manage orgs - - title: npm outdated - url: /commands/npm-outdated - description: Check for outdated packages - - title: npm owner - url: /commands/npm-owner - description: Manage package owners - - title: npm pack - url: /commands/npm-pack - description: Create a tarball from a package - - title: npm ping - url: /commands/npm-ping - description: Ping npm registry - - title: npm pkg - url: /commands/npm-pkg - description: Manages your package.json - - title: npm prefix - url: /commands/npm-prefix - description: Display prefix - - title: npm profile - url: /commands/npm-profile - description: Change settings on your registry profile - - title: npm prune - url: /commands/npm-prune - description: Remove extraneous packages - - title: npm publish - url: /commands/npm-publish - description: Publish a package - - title: npm query - url: /commands/npm-query - description: Retrieve a filtered list of packages - - title: npm rebuild - url: /commands/npm-rebuild - description: Rebuild a package - - title: npm repo - url: /commands/npm-repo - description: Open package repository page in the browser - - title: npm restart - url: /commands/npm-restart - description: Restart a package - - title: npm root - url: /commands/npm-root - description: Display npm root - - title: npm run - url: /commands/npm-run - description: Run arbitrary package scripts - - title: npm sbom - url: /commands/npm-sbom - description: Generate a Software Bill of Materials (SBOM) - - title: npm search - url: /commands/npm-search - description: Search for packages - - title: npm shrinkwrap - url: /commands/npm-shrinkwrap - description: Lock down dependency versions for publication - - title: npm star - url: /commands/npm-star - description: Mark your favorite packages - - title: npm stars - url: /commands/npm-stars - description: View packages marked as favorites - - title: npm start - url: /commands/npm-start - description: Start a package - - title: npm stop - url: /commands/npm-stop - description: Stop a package - - title: npm team - url: /commands/npm-team - description: Manage organization teams and team memberships - - title: npm test - url: /commands/npm-test - description: Test a package - - title: npm token - url: /commands/npm-token - description: Manage your authentication tokens - - title: npm undeprecate - url: /commands/npm-undeprecate - description: Undeprecate a version of a package - - title: npm uninstall - url: /commands/npm-uninstall - description: Remove a package - - title: npm unpublish - url: /commands/npm-unpublish - description: Remove a package from the registry - - title: npm unstar - url: /commands/npm-unstar - description: Remove an item from your favorite packages - - title: npm update - url: /commands/npm-update - description: Update a package - - title: npm version - url: /commands/npm-version - description: Bump a package version - - title: npm view - url: /commands/npm-view - description: View registry info - - title: npm whoami - url: /commands/npm-whoami - description: Display npm username - - title: npx - url: /commands/npx - description: Run a command from an npm package - + - title: npm + url: /commands/npm + description: javascript package manager + - title: npm access + url: /commands/npm-access + description: Set access level on published packages + - title: npm adduser + url: /commands/npm-adduser + description: Add a registry user account + - title: npm audit + url: /commands/npm-audit + description: Run a security audit + - title: npm bugs + url: /commands/npm-bugs + description: 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: Clean install a project + - title: npm completion + url: /commands/npm-completion + description: Tab Completion for npm + - title: npm config + url: /commands/npm-config + description: Manage the npm configuration files + - title: npm dedupe + url: /commands/npm-dedupe + description: Reduce duplication in the package tree + - title: npm deprecate + url: /commands/npm-deprecate + description: Deprecate a version of a package + - title: npm diff + url: /commands/npm-diff + description: The registry diff command + - title: npm dist-tag + url: /commands/npm-dist-tag + description: Modify package distribution tags + - title: npm docs + url: /commands/npm-docs + description: Open documentation for a package in a web browser + - title: npm doctor + url: /commands/npm-doctor + 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 a local or remote npm package + - title: npm explain + url: /commands/npm-explain + description: Explain installed packages + - title: npm explore + url: /commands/npm-explore + description: Browse an installed package + - title: npm find-dupes + url: /commands/npm-find-dupes + description: Find duplication in the package tree + - title: npm fund + url: /commands/npm-fund + description: Retrieve funding information + - title: npm get + url: /commands/npm-get + description: Get a value from the npm configuration + - title: npm help + url: /commands/npm-help + description: Get help on npm + - title: npm help-search + url: /commands/npm-help-search + description: Search npm help documentation + - title: npm init + url: /commands/npm-init + description: Create a package.json file + - title: npm install + url: /commands/npm-install + description: Install a package + - title: npm install-ci-test + url: /commands/npm-install-ci-test + description: Install a project with a clean slate and run tests + - title: npm install-test + url: /commands/npm-install-test + description: Install package(s) and run tests + - title: npm link + url: /commands/npm-link + description: Symlink a package folder + - title: npm ll + url: /commands/npm-ll + description: List installed packages + - title: npm login + url: /commands/npm-login + description: Login to a registry user account + - title: npm logout + url: /commands/npm-logout + description: Log out of the registry + - title: npm ls + url: /commands/npm-ls + description: List installed packages + - title: npm org + url: /commands/npm-org + description: Manage orgs + - title: npm outdated + url: /commands/npm-outdated + description: Check for outdated packages + - title: npm owner + url: /commands/npm-owner + description: Manage package owners + - title: npm pack + url: /commands/npm-pack + description: Create a tarball from a package + - title: npm ping + url: /commands/npm-ping + description: Ping npm registry + - title: npm pkg + url: /commands/npm-pkg + description: Manages your package.json + - title: npm prefix + url: /commands/npm-prefix + description: Display prefix + - title: npm profile + url: /commands/npm-profile + description: Change settings on your registry profile + - title: npm prune + url: /commands/npm-prune + description: Remove extraneous packages + - title: npm publish + url: /commands/npm-publish + description: Publish a package + - title: npm query + url: /commands/npm-query + description: Dependency selector query + - title: npm rebuild + url: /commands/npm-rebuild + description: Rebuild a package + - title: npm repo + url: /commands/npm-repo + description: Open package repository page in the browser + - title: npm restart + url: /commands/npm-restart + description: Restart a package + - title: npm root + url: /commands/npm-root + description: Display npm root + - title: npm run + url: /commands/npm-run + description: Run arbitrary package scripts + - title: npm sbom + url: /commands/npm-sbom + description: Generate a Software Bill of Materials (SBOM) + - title: npm search + url: /commands/npm-search + description: Search for packages + - title: npm set + url: /commands/npm-set + description: Set a value in the npm configuration + - title: npm shrinkwrap + url: /commands/npm-shrinkwrap + description: Lock down dependency versions for publication + - title: npm star + url: /commands/npm-star + description: Mark your favorite packages + - title: npm stars + url: /commands/npm-stars + description: View packages marked as favorites + - title: npm start + url: /commands/npm-start + description: Start a package + - title: npm stop + url: /commands/npm-stop + description: Stop a package + - title: npm team + url: /commands/npm-team + description: Manage organization teams and team memberships + - title: npm test + url: /commands/npm-test + description: Test a package + - title: npm token + url: /commands/npm-token + description: Manage your authentication tokens + - title: npm trust + url: /commands/npm-trust + description: Create a trusted relationship between a package and a OIDC provider + - title: npm undeprecate + url: /commands/npm-undeprecate + description: Undeprecate a version of a package + - title: npm uninstall + url: /commands/npm-uninstall + description: Remove a package + - title: npm unpublish + url: /commands/npm-unpublish + description: Remove a package from the registry + - title: npm unstar + url: /commands/npm-unstar + description: Remove an item from your favorite packages + - title: npm update + url: /commands/npm-update + description: Update packages + - title: npm version + url: /commands/npm-version + description: Bump a package version + - title: npm view + url: /commands/npm-view + description: View registry info + - title: npm whoami + url: /commands/npm-whoami + description: Display npm username + - title: npx + url: /commands/npx + description: Run a command from a local or remote npm package - title: Configuring npm shortName: Configuring url: /configuring-npm children: - - title: Install - url: /configuring-npm/install - description: Download and install node and npm - - title: Folders - url: /configuring-npm/folders - description: Folder structures used by npm - - title: .npmrc - url: /configuring-npm/npmrc - description: The npm config files - - title: npm-shrinkwrap.json - url: /configuring-npm/npm-shrinkwrap-json - description: A publishable lockfile - - title: package.json - url: /configuring-npm/package-json - description: Specifics of npm's package.json handling - - title: package-lock.json - url: /configuring-npm/package-lock-json - description: A manifestation of the manifest - + - title: Install + url: /configuring-npm/install + description: Download and install node and npm + - title: Folders + url: /configuring-npm/folders + description: Folder structures used by npm + - title: .npmrc + url: /configuring-npm/npmrc + description: The npm config files + - title: npm-shrinkwrap.json + url: /configuring-npm/npm-shrinkwrap-json + description: A publishable lockfile + - title: package.json + url: /configuring-npm/package-json + description: Specifics of npm's package.json handling + - title: package-lock.json + url: /configuring-npm/package-lock-json + description: A manifestation of the manifest - title: Using npm shortName: Using url: /using-npm children: - - title: Registry - url: /using-npm/registry - description: The JavaScript Package Registry - - title: Package spec - url: /using-npm/package-spec - description: Package name specifier - - title: Config - url: /using-npm/config - description: About npm configuration - - title: Logging - url: /using-npm/logging - description: Why, What & How we Log - - title: Scope - url: /using-npm/scope - description: Scoped packages - - title: Scripts - url: /using-npm/scripts - description: How npm handles the "scripts" field - - title: Workspaces - url: /using-npm/workspaces - description: Working with workspaces - - title: Organizations - url: /using-npm/orgs - description: Working with teams & organizations - - title: Dependency Selectors - url: /using-npm/dependency-selectors - description: Dependency Selector Syntax & Querying - - title: Developers - url: /using-npm/developers - description: Developer guide - - title: Removal - url: /using-npm/removal - description: Cleaning the slate + - title: Registry + url: /using-npm/registry + description: The JavaScript Package Registry + - title: Package spec + url: /using-npm/package-spec + description: Package name specifier + - title: Config + url: /using-npm/config + description: About npm configuration + - title: Logging + url: /using-npm/logging + description: Why, What & How we Log + - title: Scope + url: /using-npm/scope + description: Scoped packages + - title: Scripts + url: /using-npm/scripts + description: How npm handles the "scripts" field + - title: Workspaces + url: /using-npm/workspaces + description: Working with workspaces + - title: Organizations + url: /using-npm/orgs + description: Working with teams & organizations + - title: Dependency Selectors + url: /using-npm/dependency-selectors + description: Dependency Selector Syntax & Querying + - title: Developers + url: /using-npm/developers + description: Developer guide + - title: Removal + url: /using-npm/removal + description: Cleaning the slate diff --git a/docs/lib/index.js b/docs/lib/index.js index 5e40f48882cad..d4e58c6d8ea3a 100644 --- a/docs/lib/index.js +++ b/docs/lib/index.js @@ -40,12 +40,17 @@ const getCommandByDoc = (docFile, docExt) => { // `npx` is not technically a command in and of itself, // so it just needs the usage of npm exec const srcName = name === 'npx' ? 'exec' : name - const { params, usage = [''], workspaces } = require(`../../lib/commands/${srcName}`) + const command = require(`../../lib/commands/${srcName}`) + const { params, usage = [''], workspaces } = command + const commandDefinitions = command.definitions || {} + const definitionPool = { ...definitions, ...commandDefinitions } const usagePrefix = name === 'npx' ? 'npx' : `npm ${name}` if (params) { for (const param of params) { - if (definitions[param].exclusive) { - for (const e of definitions[param].exclusive) { + // Check command-specific definitions first, fall back to global definitions + const paramDef = definitionPool[param] + if (paramDef && paramDef.exclusive) { + for (const e of paramDef.exclusive) { if (!params.includes(e)) { params.splice(params.indexOf(param) + 1, 0, e) } @@ -93,16 +98,168 @@ const replaceUsage = (src, { path }) => { } const replaceParams = (src, { path }) => { - const { params } = getCommandByDoc(path, DOC_EXT) - const replacer = params && assertPlaceholder(src, path, TAGS.CONFIG) + const { params, name } = getCommandByDoc(path, DOC_EXT) + // Load command to get command-specific definitions and subcommands if they exist + let commandDefinitions = {} + let subcommands = {} + try { + const command = require(`../../lib/commands/${name}`) + commandDefinitions = command.definitions || {} + subcommands = command.subcommands || {} + } catch { + // If command doesn't exist or has no definitions, continue with global definitions only + } + + // Check if there's a config placeholder in the source + const hasPlaceholder = src.includes(TAGS.CONFIG) + + // If no params and no subcommands, nothing to replace + if (!params && Object.keys(subcommands).length === 0) { + return src + } + + // Only assert placeholder if we have content to replace + const replacer = hasPlaceholder ? assertPlaceholder(src, path, TAGS.CONFIG) : null + + if (!replacer) { + return src + } + + // If command has subcommands, generate sections for each subcommand + if (Object.keys(subcommands).length > 0) { + const subcommandSections = Object.entries(subcommands).map(([subName, SubCommand]) => { + const subUsage = SubCommand.usage || [] + const subDefinitions = SubCommand.definitions || {} + // If params not defined, extract from definitions + const subParams = SubCommand.params || Object.keys(subDefinitions) + + const parts = [`### \`npm ${name} ${subName}\``, ''] + + if (SubCommand.description) { + parts.push(SubCommand.description, '') + } + + // Add usage/synopsis + if (subUsage.length > 0) { + parts.push('#### Synopsis', '', '```bash') + subUsage.forEach(u => { + parts.push(`npm ${name} ${subName} ${u}`.trim()) + }) + parts.push('```', '') + } + + // Separate command-specific and global config params for this subcommand + const commandSpecificParams = [] + const globalConfigParams = [] + + for (const paramName of subParams) { + const isCommandSpecific = subDefinitions[paramName] && !definitions[paramName] + if (isCommandSpecific) { + commandSpecificParams.push(paramName) + } else { + globalConfigParams.push(paramName) + } + } + + // Add command-specific flags section if any exist + if (commandSpecificParams.length > 0) { + parts.push('#### Flags', '') + parts.push('These flags are specific to this subcommand and are not part of npm\'s global configuration or `.npmrc` files.', '') + + commandSpecificParams.forEach((n) => { + const def = subDefinitions[n] + const shortcuts = def.short ? `\n* Shortcut: \`-${def.short}\`` : '' + const defAliases = def.alias || [] + const aliasText = defAliases.length > 0 + ? `\n* Aliases: ${defAliases.map(a => `\`--${a}\``).join(', ')}` + : '' + parts.push(`${def.describe()}${shortcuts}${aliasText}`, '') + }) + } + + // Add global config section if any exist + if (globalConfigParams.length > 0) { + if (commandSpecificParams.length === 0) { + parts.push('#### Configuration', '') + } + globalConfigParams.forEach((n) => { + const def = subDefinitions[n] || definitions[n] + const shortcuts = def.short ? `\n* Shortcut: \`-${def.short}\`` : '' + const defAliases = def.alias || [] + const aliasText = defAliases.length > 0 + ? `\n* Aliases: ${defAliases.map(a => `\`--${a}\``).join(', ')}` + : '' + parts.push(`${def.describe()}${shortcuts}${aliasText}`, '') + }) + } + + return parts.join('\n') + }) + + return src.replace(replacer, subcommandSections.join('\n')) + } + + // Original behavior for commands without subcommands but with params 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] + if (isCommandSpecific) { + commandSpecificParams.push(paramName) + } else { + globalConfigParams.push(paramName) + } + } + + const sections = [] + + // Add command-specific flags section if any exist + 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}\`` : '' + const defAliases = def.alias || [] + const aliasText = defAliases.length > 0 + ? `\n* Aliases: ${defAliases.map(a => `\`--${a}\``).join(', ')}` + : '' + return `${def.describe()}${shortcuts}${aliasText}` + }) + + if (commandSpecificParams.length > 0) { + sections.push('', '#### Configuration', '') + } + sections.push(globalConfig.join('\n\n')) + } - return src.replace(replacer, paramsConfig.join('\n\n')) + return src.replace(replacer, sections.join('\n')) } const replaceConfig = (src, { path }) => { diff --git a/docs/test/index.js b/docs/test/index.js index be537e68b2a18..0257a07a0e663 100644 --- a/docs/test/index.js +++ b/docs/test/index.js @@ -1,6 +1,8 @@ const t = require('tap') const { join } = require('path') const walk = require('ignore-walk') +const fs = require('fs/promises') +const yaml = require('yaml') const { paths: { content: CONTENT_DIR, nav: NAV, template: TEMPLATE } } = require('../lib/index.js') const testBuildDocs = async (t, { verify, ...opts } = {}) => { @@ -13,6 +15,11 @@ const testBuildDocs = async (t, { verify, ...opts } = {}) => { ...opts, } + // Ensure commands directory exists if content is provided + if (fixtures.content && !fixtures.content.commands) { + fixtures.content.commands = {} + } + const root = t.testdir(fixtures) const paths = { @@ -22,6 +29,8 @@ const testBuildDocs = async (t, { verify, ...opts } = {}) => { man: join(root, 'man'), html: join(root, 'html'), md: join(root, 'md'), + // Skip auto-generation of missing docs when using test fixtures + skipAutoGenerate: !!fixtures.content, } return { @@ -31,6 +40,83 @@ const testBuildDocs = async (t, { verify, ...opts } = {}) => { } } +// Helper to create a standard command doc with placeholders +const createCommandDoc = (title, description) => `--- +title: ${title} +section: 1 +description: ${description} +--- + +### Synopsis + + + +### Configuration + + +` + +// Helper to read and return HTML content from a built doc +const readHtmlDoc = async (htmlPath, commandName) => { + const htmlFile = join(htmlPath, `commands/${commandName}.html`) + return await fs.readFile(htmlFile, 'utf-8') +} + +// Helper to test a command doc with common assertions +const testCommandDoc = async (t, commandName, description, assertions = {}) => { + const doc = createCommandDoc(commandName, description) + const { html } = await testBuildDocs(t, { + content: { + commands: { [`${commandName}.md`]: doc }, + }, + nav: `- url: /commands/${commandName}`, + }) + + const htmlContent = await readHtmlDoc(html, commandName) + + // Default assertions + t.ok(htmlContent.length > 0, `generates HTML for ${commandName} command`) + + // Custom assertions + if (assertions.match) { + for (const pattern of assertions.match) { + t.match(htmlContent, pattern, `contains expected pattern: ${pattern}`) + } + } + + return { html, htmlContent } +} + +// Helper to create test directory structure for autoGenerateMissingDocs tests +const createAutoGenTestDir = (t, { existingDocs = {}, navEntries = [], commandFiles = {} }) => { + const navYml = ` +- title: CLI Commands + children: + - title: npm + url: /commands/npm +${navEntries.map(entry => ` - title: ${entry.title}\n url: ${entry.url}\n description: ${entry.description}`).join('\n')} +` + + return t.testdir({ + content: { + commands: existingDocs, + }, + 'nav.yml': navYml, + lib: { + commands: commandFiles, + }, + }) +} + +// Helper to verify nav structure after auto-generation +const verifyNavStructure = async (navPath) => { + const navContent = await fs.readFile(navPath, 'utf-8') + const navData = yaml.parse(navContent) + const commandsSection = navData.find(s => s.title === 'CLI Commands') + + return { navContent, navData, commandsSection } +} + t.test('builds and verifies the real docs', async (t) => { const { man, html, md, results } = await testBuildDocs(t, { verify: true }) @@ -109,3 +195,387 @@ t.test('html', async t => { }) }) }) + +t.test('command-specific definitions and exclusive parameters', async t => { + // Test through the actual doc building process with real commands + t.test('config command uses params correctly', async t => { + await testCommandDoc(t, 'npm-config', 'Manage the npm configuration files') + }) + + t.test('install command includes exclusive save parameters', async t => { + const { htmlContent } = await testCommandDoc(t, 'npm-install', 'Install a package', { + match: [/save/], + }) + + // The install command should have save-related params due to exclusive expansion + t.match(htmlContent, /save/, 'includes save-related configuration') + }) +}) + +t.test('autoGenerateMissingDocs', async t => { + const { autoGenerateMissingDocs } = require('../lib/build.js') + + t.test('generates docs for missing commands', async t => { + const testDir = createAutoGenTestDir(t, { + existingDocs: { + 'npm-access.md': createCommandDoc('npm-access', 'Set access level on published packages'), + }, + navEntries: [ + { title: 'npm access', url: '/commands/npm-access', description: 'Set access level on published packages' }, + ], + commandFiles: { + 'access.js': ` +class AccessCommand { + static description = 'Set access level on published packages' +} +module.exports = AccessCommand +`, + 'testcmd.js': ` +class TestCommand { + static description = 'A test command' +} +module.exports = TestCommand +`, + }, + }) + + const contentPath = join(testDir, 'content') + const navPath = join(testDir, 'nav.yml') + const commandsPath = join(testDir, 'lib', 'commands') + + await autoGenerateMissingDocs(contentPath, navPath, commandsPath) + + // Verify the doc was created + const testcmdDocPath = join(contentPath, 'commands', 'npm-testcmd.md') + const docExists = await fs.access(testcmdDocPath).then(() => true).catch(() => false) + t.ok(docExists, 'creates documentation file for missing command') + + // Verify the doc has correct content + const docContent = await fs.readFile(testcmdDocPath, 'utf-8') + t.match(docContent, /title: npm-testcmd/, 'doc has correct title') + t.match(docContent, /description: A test command/, 'doc has correct description') + t.match(docContent, //, 'doc has usage placeholder') + t.match(docContent, //, 'doc has config placeholder') + t.match(docContent, /A test command/, 'doc has description in body') + }) + + t.test('updates nav.yml for new commands', async t => { + const testDir = createAutoGenTestDir(t, { + existingDocs: { + 'npm-existing.md': `--- +title: npm-existing +section: 1 +description: Existing command +---`, + }, + navEntries: [ + { title: 'npm existing', url: '/commands/npm-existing', description: 'Existing command' }, + ], + commandFiles: { + 'existing.js': ` +class ExistingCommand { + static description = 'Existing command' +} +module.exports = ExistingCommand +`, + 'newcmd.js': ` +class NewCommand { + static description = 'New command' +} +module.exports = NewCommand +`, + }, + }) + + const contentPath = join(testDir, 'content') + const navPath = join(testDir, 'nav.yml') + const commandsPath = join(testDir, 'lib', 'commands') + + await autoGenerateMissingDocs(contentPath, navPath, commandsPath) + + // Read and verify nav.yml was updated + const { commandsSection } = await verifyNavStructure(navPath) + + t.ok(commandsSection, 'nav has CLI Commands section') + const newCmdEntry = commandsSection.children.find(c => c.url === '/commands/npm-newcmd') + t.ok(newCmdEntry, 'nav has entry for new command') + t.equal(newCmdEntry.title, 'npm newcmd', 'nav entry has correct title') + t.equal(newCmdEntry.description, 'New command', 'nav entry has correct description') + }) + + t.test('sorts nav children alphabetically', async t => { + const testDir = createAutoGenTestDir(t, { + existingDocs: {}, + navEntries: [ + { title: 'npm zebra', url: '/commands/npm-zebra', description: 'Zebra command' }, + { title: 'npm alpha', url: '/commands/npm-alpha', description: 'Alpha command' }, + ], + commandFiles: { + 'zebra.js': `module.exports = { description: 'Zebra command' }`, + 'alpha.js': `module.exports = { description: 'Alpha command' }`, + 'beta.js': `module.exports = { description: 'Beta command' }`, + }, + }) + + const contentPath = join(testDir, 'content') + const navPath = join(testDir, 'nav.yml') + const commandsPath = join(testDir, 'lib', 'commands') + + await autoGenerateMissingDocs(contentPath, navPath, commandsPath) + + // Verify sorting + const { commandsSection } = await verifyNavStructure(navPath) + + const titles = commandsSection.children.map(c => c.title) + + // npm should be first + t.equal(titles[0], 'npm', 'npm command is first') + + // Rest should be alphabetically sorted + const rest = titles.slice(1) + const sorted = [...rest].sort() + t.same(rest, sorted, 'remaining commands are alphabetically sorted') + t.ok(titles.includes('npm alpha'), 'includes alpha') + t.ok(titles.includes('npm beta'), 'includes beta') + t.ok(titles.includes('npm zebra'), 'includes zebra') + }) + + t.test('handles commands without description', async t => { + const testDir = createAutoGenTestDir(t, { + existingDocs: {}, + navEntries: [], + commandFiles: { + 'nodesc.js': `module.exports = {}`, + }, + }) + + const contentPath = join(testDir, 'content') + const navPath = join(testDir, 'nav.yml') + const commandsPath = join(testDir, 'lib', 'commands') + + await autoGenerateMissingDocs(contentPath, navPath, commandsPath) + + // Verify fallback description + const docPath = join(contentPath, 'commands', 'npm-nodesc.md') + const docContent = await fs.readFile(docPath, 'utf-8') + t.match(docContent, /description: The nodesc command/, 'uses fallback description in frontmatter') + t.match(docContent, /The nodesc command/, 'uses fallback description in body') + }) + + t.test('does not add duplicate entries to nav', async t => { + const testDir = createAutoGenTestDir(t, { + existingDocs: {}, + navEntries: [ + { title: 'npm duplicate', url: '/commands/npm-duplicate', description: 'Already exists' }, + ], + commandFiles: { + 'duplicate.js': `module.exports = { description: 'Already exists' }`, + }, + }) + + const contentPath = join(testDir, 'content') + const navPath = join(testDir, 'nav.yml') + const commandsPath = join(testDir, 'lib', 'commands') + + await autoGenerateMissingDocs(contentPath, navPath, commandsPath) + + // Verify no duplicate + const { commandsSection } = await verifyNavStructure(navPath) + + const duplicateEntries = commandsSection.children.filter(c => c.url === '/commands/npm-duplicate') + t.equal(duplicateEntries.length, 1, 'does not create duplicate nav entries') + }) + + t.test('skips update when no missing docs', async t => { + const testDir = createAutoGenTestDir(t, { + existingDocs: { + 'npm-complete.md': `--- +title: npm-complete +section: 1 +description: Complete command +---`, + }, + navEntries: [ + { title: 'npm complete', url: '/commands/npm-complete', description: 'Complete command' }, + ], + commandFiles: { + 'complete.js': `module.exports = { description: 'Complete command' }`, + }, + }) + + const contentPath = join(testDir, 'content') + const navPath = join(testDir, 'nav.yml') + const commandsPath = join(testDir, 'lib', 'commands') + + const navBefore = await fs.readFile(navPath, 'utf-8') + await autoGenerateMissingDocs(contentPath, navPath, commandsPath) + const navAfter = await fs.readFile(navPath, 'utf-8') + + t.equal(navBefore, navAfter, 'does not modify nav when no missing docs') + }) + + t.test('handles nav without CLI Commands section', async t => { + const testDir = t.testdir({ + content: { + commands: {}, + }, + 'nav.yml': ` +- title: Other Section + children: [] +`, + lib: { + commands: { + 'test.js': `module.exports = { description: 'Test command' }`, + }, + }, + }) + + const contentPath = join(testDir, 'content') + const navPath = join(testDir, 'nav.yml') + const commandsPath = join(testDir, 'lib', 'commands') + + // Should not throw, just skip nav update + await autoGenerateMissingDocs(contentPath, navPath, commandsPath) + + // Doc should still be created + const docPath = join(contentPath, 'commands', 'npm-test.md') + const docExists = await fs.access(docPath).then(() => true).catch(() => false) + t.ok(docExists, 'creates doc even when nav section missing') + }) + + t.test('handles nav with CLI Commands but no children', async t => { + const testDir = t.testdir({ + content: { + commands: {}, + }, + 'nav.yml': ` +- title: CLI Commands +`, + lib: { + commands: { + 'test.js': `module.exports = { description: 'Test command' }`, + }, + }, + }) + + const contentPath = join(testDir, 'content') + const navPath = join(testDir, 'nav.yml') + const commandsPath = join(testDir, 'lib', 'commands') + + // Should not throw, just skip nav children update + await autoGenerateMissingDocs(contentPath, navPath, commandsPath) + + // Doc should still be created + const docPath = join(contentPath, 'commands', 'npm-test.md') + const docExists = await fs.access(docPath).then(() => true).catch(() => false) + t.ok(docExists, 'creates doc even when children missing') + }) + + t.test('handles npm command not in first position', async t => { + const testDir = createAutoGenTestDir(t, { + existingDocs: {}, + navEntries: [ + { title: 'npm alpha', url: '/commands/npm-alpha', description: 'Alpha command' }, + ], + commandFiles: { + 'alpha.js': `module.exports = { description: 'Alpha command' }`, + 'beta.js': `module.exports = { description: 'Beta command' }`, + }, + }) + + // Manually adjust nav to put npm not first + const navPath = join(testDir, 'nav.yml') + await fs.writeFile(navPath, ` +- title: CLI Commands + children: + - title: npm alpha + url: /commands/npm-alpha + - title: npm + url: /commands/npm +`) + + const contentPath = join(testDir, 'content') + const commandsPath = join(testDir, 'lib', 'commands') + + await autoGenerateMissingDocs(contentPath, navPath, commandsPath) + + // Verify npm moved to first position + const { commandsSection } = await verifyNavStructure(navPath) + + const titles = commandsSection.children.map(c => c.title) + t.equal(titles[0], 'npm', 'npm command moved to first position') + }) + + t.test('calls autoGenerateMissingDocs via run with default skipAutoGenerate', async t => { + // This test ensures the default parameter path is covered + const build = require('../lib/build.js') + const testDir = t.testdir({ + content: { + commands: { + 'npm-test.md': createCommandDoc('npm-test', 'Test'), + }, + }, + 'nav.yml': ` +- title: CLI Commands + url: /commands/npm-test +`, + lib: { + commands: { + 'test.js': `module.exports = { description: 'Test' }`, + }, + }, + }) + + const template = '{{ content }}' + await fs.writeFile(join(testDir, 'template.html'), template) + + const contentPath = join(testDir, 'content') + const navPath = join(testDir, 'nav.yml') + const templatePath = join(testDir, 'template.html') + const manPath = join(testDir, 'man') + const htmlPath = join(testDir, 'html') + const mdPath = join(testDir, 'md') + + // Call run with skipAutoGenerate set to true to avoid hitting real commands + const results = await build({ + content: contentPath, + template: templatePath, + nav: navPath, + man: manPath, + html: htmlPath, + md: mdPath, + skipAutoGenerate: true, + }) + + t.ok(results.length > 0, 'build runs successfully') + }) +}) + +t.test('command-specific definitions with missing command file', async t => { + // This test targets the catch block in index.js lines 110-113 + // Use a command that exists and has params - the catch block is for safety + // when command-specific definitions can't be loaded + await testCommandDoc(t, 'npm-install', 'Install a package', { + match: [/install/], + }) +}) + +t.test('replaceParams with name edge cases', async t => { + // Test the conditions around the catch block more explicitly + t.test('npm command (no params)', async t => { + await testCommandDoc(t, 'npm', 'javascript package manager') + }) + + t.test('npx command (special case)', async t => { + await testCommandDoc(t, 'npx', 'Run a command from a local or remote npm package', { + match: [/package/], + }) + }) + + t.test('regular command with params (access)', async t => { + // Tests line 110: name && name !== 'npm' && name !== 'npx' + await testCommandDoc(t, 'npm-access', 'Set access level on published packages', { + match: [/registry/], + }) + }) +}) diff --git a/lib/base-cmd.js b/lib/base-cmd.js index 3e6c4758cbd58..9234ccc632a82 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,62 @@ class BaseCommand { static name = null static description = null static params = null + static definitions = null + static subcommands = null - // this is a static so that we can read from it without instantiating a command + // this is a static so that we can read =rom it without instantiating a command // which would require loading the config static get describeUsage () { - const { definitions } = require('@npmcli/config/lib/definitions') + return this.getUsage() + } + + static getUsage (parentName = null, includeDescriptions = true) { const { aliases: cmdAliases } = require('./utils/cmd-list') const seenExclusive = new Set() const wrapWidth = 80 - const { description, usage = [''], name, params } = this + let { description, usage = [''], name, params } = this + + let definitionsPool = {} + if (this.definitions) { + definitionsPool = { ...definitions, ...this.definitions } + // Auto-populate params from definitions if not explicitly set + if (!params && this.definitions) { + params = Object.values(this.definitions).map(def => def.key) + } + } else { + // Don't mutate this.definitions - just use definitions directly + definitionsPool = definitions + } + + // If this is a subcommand, prepend parent name + const fullCommandName = parentName ? `${parentName} ${name}` : name const fullUsage = [ `${description}`, '', 'Usage:', - ...usage.map(u => `npm ${name} ${u}`.trim()), + ...usage.map(u => `npm ${fullCommandName} ${u}`.trim()), ] + if (this.subcommands) { + fullUsage.push('') + fullUsage.push('Subcommands:') + const subcommandEntries = Object.entries(this.subcommands) + for (let i = 0; i < subcommandEntries.length; i++) { + const [subName, SubCommand] = subcommandEntries[i] + fullUsage.push(` ${subName}`) + if (SubCommand.description) { + fullUsage.push(` ${SubCommand.description}`) + } + // Add space between subcommands except after the last one + if (i < subcommandEntries.length - 1) { + fullUsage.push('') + } + } + fullUsage.push('') + fullUsage.push(`Run "npm ${name} --help" for more info on a subcommand.`) + } + if (params) { let results = '' let line = '' @@ -35,14 +76,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 +97,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 +138,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 +151,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 +277,133 @@ 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) + } + + // 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) { + // Build a set of all valid flag names (global + command-specific + shorthands) + const validFlags = new Set([ + ...Object.keys(definitions), + ...Object.keys(commandDefinitions), + ...Object.keys(shorthands), // Add global shorthands like 'verbose', 'dd', etc. + ]) + + // Add aliases to valid flags + for (const def of Object.values(commandDefinitions)) { + if (def.alias && Array.isArray(def.alias)) { + for (const alias of def.alias) { + validFlags.add(alias) + } + } + } + + // Check parsed flags against valid flags + const unknownFlags = [] + for (const key of Object.keys(parsed)) { + if (!validFlags.has(key)) { + unknownFlags.push(key) + } + } + + // Throw error if unknown flags were found + if (unknownFlags.length > 0) { + const flagList = unknownFlags.map(f => `--${f}`).join(', ') + throw new Error(`Unknown flag${unknownFlags.length > 1 ? 's' : ''}: ${flagList}`) + } + + 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..b82cb7a9d061c 100644 --- a/lib/commands/completion.js +++ b/lib/commands/completion.js @@ -42,11 +42,14 @@ const fileExists = (file) => fs.stat(file).then(s => s.isFile()).catch(() => fal 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 +93,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 +126,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 +238,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 +305,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.js b/lib/commands/trust.js new file mode 100644 index 0000000000000..8db2dc34f31d2 --- /dev/null +++ b/lib/commands/trust.js @@ -0,0 +1,23 @@ +const BaseCommand = require('../base-cmd.js') + +class Trust extends BaseCommand { + static description = 'Create a trusted relationship between a package and a OIDC provider' + static name = 'trust' + + static subcommands = { + github: require('../subcommands/trust-github.js'), + gitlab: require('../subcommands/trust-gitlab.js'), + list: require('../subcommands/trust-list.js'), + revoke: require('../subcommands/trust-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/npm.js b/lib/npm.js index c635f3e05a7b3..d0a78844fa3cf 100644 --- a/lib/npm.js +++ b/lib/npm.js @@ -37,6 +37,8 @@ class Npm { #runId = new Date().toISOString().replace(/[.:]/g, '_') #title = 'npm' #argvClean = [] + #argv = undefined + #excludeNpmCwd = undefined #npmRoot = null #display = null @@ -72,6 +74,7 @@ class Npm { shorthands, argv: [...process.argv, ...argv], excludeNpmCwd, + warn: false, }) } @@ -227,8 +230,64 @@ class Npm { process.env.npm_command = this.command } + // Only log warnings for legacy commands without definitions or subcommands + // Commands with definitions will handle warnings in base-cmd flags() + // Commands with subcommands will delegate to the subcommand to handle warnings + if (!Command.definitions && !Command.subcommands) { + this.config.logWarnings() + } + + // this needs to be rest after because some commands run + // this.npm.config.checkUnknown('publishConfig', key) + this.config.warn = true + + return this.execCommandClass(command, args, [cmd]) + } + + // Unified command execution for both top-level commands and subcommands + // Supports n-depth subcommands, workspaces, and definitions + async execCommandClass (commandInstance, args, commandPath = []) { + const Command = commandInstance.constructor + const commandName = commandPath.join(':') + + // Handle subcommands if present + if (Command.subcommands) { + const subcommandName = args[0] + + // If help is requested without a subcommand, show main command help + if (this.config.get('usage') && !subcommandName) { + return output.standard(commandInstance.usage) + } + + // If no subcommand provided, show usage error + if (!subcommandName) { + throw commandInstance.usageError() + } + + // Check if the subcommand exists + const SubCommand = Command.subcommands[subcommandName] + if (!SubCommand) { + throw commandInstance.usageError(`Unknown subcommand: ${subcommandName}`) + } + + // Check if help is requested for the subcommand + if (this.config.get('usage')) { + const parentName = commandPath[0] + return output.standard(SubCommand.getUsage(parentName)) + } + + // Create subcommand instance and recurse + const subcommandInstance = new SubCommand(this) + const subcommandArgs = args.slice(1) // Remove subcommand name from args + const subcommandPath = [...commandPath, subcommandName] + + return time.start(`command:${subcommandPath.join(':')}`, () => + this.execCommandClass(subcommandInstance, subcommandArgs, subcommandPath)) + } + + // No subcommands - execute this command if (this.config.get('usage')) { - return output.standard(command.usage) + return output.standard(commandInstance.usage) } let execWorkspaces = false @@ -248,12 +307,26 @@ class Npm { execWorkspaces = true } - if (command.checkDevEngines && !this.global) { - await command.checkDevEngines() + // Check dev engines if needed + if (commandInstance.checkDevEngines && !this.global) { + await commandInstance.checkDevEngines() } - return time.start(`command:${cmd}`, () => - execWorkspaces ? command.execWorkspaces(args) : command.exec(args)) + // Execute command with or without definitions + if (Command.definitions) { + // config.argv contains the full argv with flags (set by Config in production, by MockNpm in tests) + // Pass depth so flags() knows how many command names to skip + const [flags, positionalArgs] = commandInstance.flags(commandPath.length) + return time.start(`command:${commandName}`, () => + execWorkspaces + ? commandInstance.execWorkspaces(positionalArgs, flags) + : commandInstance.exec(positionalArgs, flags)) + } else { + // Legacy commands without definitions + this.config.logWarnings() + return time.start(`command:${commandName}`, () => + execWorkspaces ? commandInstance.execWorkspaces(args) : commandInstance.exec(args)) + } } // This gets called at the end of the exit handler and diff --git a/lib/subcommands/trust-github.js b/lib/subcommands/trust-github.js new file mode 100644 index 0000000000000..d2a8e8f8f9b4a --- /dev/null +++ b/lib/subcommands/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 providerName = 'GitHub Actions' + static providerEntity = 'GitHub repository' + static providerFile = 'GitHub Actions Workflow' + static providerHostname = 'https://github.com' + + // entity means project / repository + static entityKey = 'repository' + + static usage = [ + '[package] --file [--repo|--repository] [--env|--environment] [-y|--yes]', + ] + + static definitions = { + yes: globalDefinitions.yes, + json: globalDefinitions.json, + 'dry-run': globalDefinitions['dry-run'], + file: new Definition('file', { + default: null, + type: String, + description: 'Name of workflow file within a repositories .GitHub folder (must end in yaml, yml)', + }), + repository: new Definition('repository', { + default: null, + type: String, + description: 'Name of the repository in the format owner/repo', + alias: ['repo'], + }), + environment: new Definition('environment', { + default: null, + type: String, + description: 'CI environment name', + alias: ['env'], + }), + } + + getEntityUrl ({ providerHostname, file, entity }) { + if (file) { + return new URL(`${entity}/blob/HEAD/.github/workflows/${file}`, providerHostname).toString() + } + return new URL(entity, providerHostname).toString() + } + + validateEntity (entity) { + if (entity.split('/').length !== 2) { + throw new Error(`${this.providerEntity} must be specified in the format owner/repository`) + } + } + + validateFile (file) { + if (file !== path.basename(file)) { + throw new Error('GitHub Actions workflow must be just a file not a path') + } + } + + static optionsToBody (options) { + const { file, repository, environment } = options + const trustConfig = { + type: 'github', + claims: { + repository, + workflow_ref: { + file, + }, + }, + } + if (environment) { + trustConfig.environment = environment + } + return trustConfig + } + + // Convert API response body to options + static bodyToOptions (body) { + const file = body.claims?.workflow_ref?.file + const repository = body.claims?.repository + const environment = body.environment + return { + ...(body.id) && { id: body.id }, + ...(body.type) && { type: body.type }, + ...(file) && { file }, + ...(repository) && { repository }, + ...(environment) && { environment }, + } + } + + async exec (positionalArgs, flags) { + await this.createConfigCommand({ + positionalArgs, + flags, + }) + } +} + +module.exports = TrustGitHub diff --git a/lib/subcommands/trust-gitlab.js b/lib/subcommands/trust-gitlab.js new file mode 100644 index 0000000000000..c1ad2fe0f9a51 --- /dev/null +++ b/lib/subcommands/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 providerName = 'GitLab CI/CD' + static providerEntity = 'GitLab project' + static providerFile = 'GitLab CI/CD Pipeline' + static providerHostname = 'https://gitlab.com' + + // entity means project / repository + static entityKey = 'project' + + static usage = [ + '[package] --file [--project|--repo|--repository] [--env|--environment] [-y|--yes]', + ] + + static definitions = { + yes: globalDefinitions.yes, + json: globalDefinitions.json, + 'dry-run': globalDefinitions['dry-run'], + file: new Definition('file', { + default: null, + type: String, + description: 'Name of pipeline file (e.g., .gitlab-ci.yml)', + }), + project: new Definition('project', { + default: null, + type: String, + description: 'Name of the project in the format group/project or group/subgroup/project', + }), + environment: new Definition('environment', { + default: null, + type: String, + description: 'CI environment name', + alias: ['env'], + }), + } + + getEntityUrl ({ providerHostname, file, entity }) { + if (file) { + return new URL(`${entity}/-/blob/HEAD/${file}`, providerHostname).toString() + } + return new URL(entity, providerHostname).toString() + } + + validateEntity (entity) { + if (entity.split('/').length < 2) { + throw new Error(`${this.providerEntity} must be specified in the format group/project or group/subgroup/project`) + } + } + + validateFile (file) { + if (file !== path.basename(file)) { + throw new Error('GitLab CI/CD pipeline file must be just a file not a path') + } + } + + static optionsToBody (options) { + const { file, project, environment } = options + const trustConfig = { + type: 'gitlab', + claims: { + project_path: project, + // this looks off, but this is correct + /** The ref path to the top-level pipeline definition, for example, gitlab.example.com/my-group/my-project//.gitlab-ci.yml@refs/heads/main. Introduced in GitLab 16.2. This claim is null unless the pipeline definition is located in the same project. */ + ci_config_ref_uri: { + file, + }, + }, + } + if (environment) { + trustConfig.environment = environment + } + return trustConfig + } + + // Convert API response body to options + static bodyToOptions (body) { + const file = body.claims?.ci_config_ref_uri?.file + const project = body.claims?.project_path + const environment = body.environment + return { + ...(body.id) && { id: body.id }, + ...(body.type) && { type: body.type }, + ...(file) && { file }, + ...(project) && { project }, + ...(environment) && { environment }, + } + } + + async exec (positionalArgs, flags) { + await this.createConfigCommand({ + positionalArgs, + flags, + }) + } +} + +module.exports = TrustGitLab diff --git a/lib/subcommands/trust-list.js b/lib/subcommands/trust-list.js new file mode 100644 index 0000000000000..904c7fab14feb --- /dev/null +++ b/lib/subcommands/trust-list.js @@ -0,0 +1,57 @@ +const pkgJson = require('@npmcli/package-json') +const { otplease } = require('../utils/auth.js') +const npmFetch = require('npm-registry-fetch') +const npa = require('npm-package-arg') +const TrustGithub = require('./trust-github.js') +const TrustGitlab = require('./trust-gitlab.js') +const TrustCommand = require('../trust-cmd.js') +const globalDefinitions = require('@npmcli/config/lib/definitions/definitions.js') + +// Convert trust config body to options object +function bodyToOptions (body) { + if (body.type === 'github') { + return TrustGithub.bodyToOptions(body) + } else if (body.type === 'gitlab') { + return TrustGitlab.bodyToOptions(body) + } + return TrustCommand.bodyToOptions(body) +} + +class TrustList extends TrustCommand { + static description = 'List trusted relationships for a package' + static name = 'list' + + static usage = [ + '[package]', + ] + + static definitions = { + json: globalDefinitions.json, + } + + async exec (args) { + const { content } = await pkgJson.normalize(this.npm.prefix) + const packageName = args[0] || content.name + if (!packageName) { + throw new Error('Package name must be specified either as an argument or in the package.json file') + } + const spec = npa(packageName) + const uri = `/-/package/${spec.escapedName}/trust` + const res = await otplease(this.npm, this.npm.flatOptions, opts => npmFetch.json(uri, { + ...opts, + method: 'GET', + })) + + if (!res || res.length === 0) { + this.warn`No trust configurations found for package (${packageName})` + return + } + this.notice`Trust configurations for package (${packageName}):` + for (const config of res) { + const values = bodyToOptions(config) + this.logOptions({ values }) + } + } +} + +module.exports = TrustList diff --git a/lib/subcommands/trust-revoke.js b/lib/subcommands/trust-revoke.js new file mode 100644 index 0000000000000..35ac2c258c979 --- /dev/null +++ b/lib/subcommands/trust-revoke.js @@ -0,0 +1,53 @@ +const globalDefinitions = require('@npmcli/config/lib/definitions/definitions.js') +const Definition = require('@npmcli/config/lib/definitions/definition.js') +const pkgJson = require('@npmcli/package-json') +const { otplease } = require('../utils/auth.js') +const npmFetch = require('npm-registry-fetch') +const BaseCommand = require('../base-cmd.js') +const npa = require('npm-package-arg') +const { log } = require('proc-log') + +class TrustRevoke extends BaseCommand { + static description = 'Revoke a trusted relationship for a package' + static name = 'revoke' + + static usage = [ + '[package] --id=', + ] + + static definitions = { + 'dry-run': globalDefinitions['dry-run'], + id: new Definition('id', { + default: null, + type: String, + description: 'ID of the trusted relationship to revoke', + }), + } + + async exec (positionalArgs, flags) { + const dryRun = this.config.get('dry-run') + const { content } = await pkgJson.normalize(this.npm.prefix) + const pkgName = positionalArgs[0] || content.name + if (!pkgName) { + throw new Error('Package name must be specified either as an argument or in the package.json file') + } + const { id } = flags + if (!id) { + throw new Error('ID of the trusted relationship to revoke must be specified with the --id option') + } + const chalk = this.npm.chalk + log.notice(`Attempting to revoke trusted configuration for package ${chalk.blue(pkgName)} with id ${chalk.blue(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', + })) + log.notice('', `Revoked trusted configuration for package ${chalk.blue(pkgName)} with id ${chalk.blue(id)}`) + } +} + +module.exports = TrustRevoke diff --git a/lib/trust-cmd.js b/lib/trust-cmd.js new file mode 100644 index 0000000000000..1f7b96193d5e0 --- /dev/null +++ b/lib/trust-cmd.js @@ -0,0 +1,251 @@ +const BaseCommand = require('./base-cmd.js') +const { otplease } = require('./utils/auth.js') +const npmFetch = require('npm-registry-fetch') +const npa = require('npm-package-arg') +const { read: _read } = require('read') +const { input, output, log } = require('proc-log') +const gitinfo = require('hosted-git-info') +const pkgJson = require('@npmcli/package-json') + +const NPM_FRONTEND = 'https://www.npmjs.com' + +class TrustCommand extends BaseCommand { + // Helper to format template strings with color + // Blue text with reset color for interpolated values + warnString (strings, ...values) { + const chalk = this.npm.chalk + const message = strings.reduce((result, str, i) => { + return result + chalk.blue(str) + (values[i] ? chalk.reset(values[i]) : '') + }, '') + return message + } + + // Log a warning message with blue formatting + warn (strings, ...values) { + log.warn('trust', this.warnString(strings, ...values)) + } + + // Log a notice message with blue formatting + notice (strings, ...values) { + log.notice('trust', 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) { + const { values, warnings, fromPackageJson, urls } = { warnings: [], ...options } + if (warnings && warnings.length > 0) { + for (const warningMsg of warnings) { + log.warn('trust', warningMsg) + } + } + + const json = this.config.get('json') + if (json) { + output.standard(JSON.stringify(options.values, null, 2)) + return + } + + const chalk = this.npm.chalk + const { type, id, ...rest } = values || {} + + if (values) { + const lines = [] + if (type) { + lines.push(`type: ${chalk.green(type)}`) + } + if (id) { + lines.push(`id: ${chalk.green(id)}`) + } + for (const [key, value] of Object.entries(rest)) { + if (value !== null && value !== undefined) { + const parts = [ + `${chalk.reset(key)}: ${chalk.green(value)}`, + ] + if (fromPackageJson && fromPackageJson[key]) { + parts.push(`(${chalk.yellow(`from package.json`)})`) + } + if (urls && urls[key]) { + parts.push(`(${chalk.blue(urls[key])})`) + } + lines.push(parts.join(' ')) + } + } + output.standard() + output.standard(lines.join('\n')) + 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.notice`Establishing trust between ${options.values.package} package and ${providerName}` + this.notice`Anyone with ${providerEntity} write access can publish to ${options.values.package}` + this.notice`Two-factor authentication is required for this operation` + if (!this.registryIsDefault) { + this.warn`Registry ${this.npm.config.get('registry')} may not support trusted publishing` + } + this.logOptions(options) + if (dryRun) { + return + } + await this.confirmOperation(yes) + const trustConfig = this.constructor.optionsToBody(options.values) + const response = await this.createConfig(options.values.package, [trustConfig]) + const body = await response.json(yes) + this.notice`Trust configuration created successfully with the following settings:` + const values = this.constructor.bodyToOptions(body) + this.logOptions({ values }) + } + + async flagsToOptions ({ positionalArgs, flags, providerHostname }) { + const { entityKey, name, providerEntity, providerFile } = this.constructor + const content = await this.optionalPkgJson() + const pkgPositional = positionalArgs[0] + const pkgJsonName = content.name + const git = this.getRepositoryFromPackageJson(content) + // the provided positional matches package.json name or no positional provided + const matchPkg = (!pkgPositional || pkgPositional === pkgJsonName) + const pkgName = pkgPositional || pkgJsonName + const usedPkgNameFromPkgJson = !pkgPositional && Boolean(pkgJsonName) + const invalidPkgJsonProviderType = matchPkg && git && git?.type !== name + + let entity + let entitySource + + if (flags[entityKey]) { + entity = flags[entityKey] + entitySource = 'flag' + } else if (!invalidPkgJsonProviderType && git?.repository) { + entity = git.repository + entitySource = 'package.json' + } + const mismatchPkgJsonRepository = matchPkg && git && entity !== git.repository + const usedRepositoryInPkgJson = entitySource === 'package.json' + + const warnings = [] + if (!pkgName) { + throw new Error('Package name must be specified either as an argument or in package.json file') + } + + if (!flags.file) { + throw new Error(`${providerFile} must be specified with the file option`) + } + if (!flags.file.endsWith('.yml') && !flags.file.endsWith('.yaml')) { + throw new Error(`${providerFile} must end in .yml or .yaml`) + } + + this.validateFile?.(flags.file) + + if (invalidPkgJsonProviderType) { + const message = this.warnString`Repository in package.json is not a ${providerEntity}` + if (!flags[entityKey]) { + throw new Error(message) + } else { + warnings.push(message) + } + } else { + if (mismatchPkgJsonRepository) { + warnings.push(this.warnString`Repository in package.json (${git.repository}) differs from provided ${providerEntity} (${entity})`) + } + } + + if (!entity && matchPkg) { + throw new Error(`${providerEntity} must be specified with ${entityKey} option or inferred from the package.json repository field`) + } + if (!entity) { + throw new Error(`${providerEntity} must be specified with ${entityKey} option`) + } + + this.validateEntity(entity) + + return { + values: { + package: pkgName, + file: flags.file, + [entityKey]: entity, + ...(flags.environment && { environment: flags.environment }), + }, + fromPackageJson: { + [entityKey]: usedRepositoryInPkgJson, + package: usedPkgNameFromPkgJson, + }, + warnings: warnings, + urls: { + package: this.getFrontendUrl({ pkgName }), + [entityKey]: this.getEntityUrl({ providerHostname, entity }), + file: this.getEntityUrl({ providerHostname, entity, file: flags.file }), + }, + } + } +} + +module.exports = TrustCommand +module.exports.NPM_FRONTEND = NPM_FRONTEND diff --git a/lib/utils/auth.js b/lib/utils/auth.js index a617ab9430b2a..0111ba94f2aa5 100644 --- a/lib/utils/auth.js +++ b/lib/utils/auth.js @@ -12,7 +12,7 @@ const otplease = async (npm, opts, fn) => { } // web otp - if (err.code === 'EOTP' && err.body?.authUrl && err.body?.doneUrl) { + if ((err.code === 'EOTP' || err.code === 'E401') && err.body?.authUrl && err.body?.doneUrl) { const { token: otp } = await webAuthOpener( createOpener(npm, 'Authenticate your account at'), err.body.authUrl, diff --git a/lib/utils/cmd-list.js b/lib/utils/cmd-list.js index 61f64f77678e2..ec1d50dcc0c56 100644 --- a/lib/utils/cmd-list.js +++ b/lib/utils/cmd-list.js @@ -62,6 +62,7 @@ const commands = [ 'team', 'test', 'token', + 'trust', 'undeprecate', 'uninstall', 'unpublish', diff --git a/lib/utils/display.js b/lib/utils/display.js index dff16ceebef6f..122a7f6e8c577 100644 --- a/lib/utils/display.js +++ b/lib/utils/display.js @@ -337,7 +337,7 @@ class Display { log.resume() // For silent prompts (like password), add newline to preserve output if (meta?.silent) { - output.standard('') + output.standard() } output.flush() this.#progress.resume() @@ -360,7 +360,7 @@ class Display { }) .catch((error) => { // If user hits ctrl+c, add newline to preserve output. - output.standard('') + output.standard() input.end() rej(error) }) diff --git a/lib/utils/npm-usage.js b/lib/utils/npm-usage.js index 1bd790ca601bc..e01a1956e1054 100644 --- a/lib/utils/npm-usage.js +++ b/lib/utils/npm-usage.js @@ -62,7 +62,7 @@ const cmdUsages = (Npm) => { let maxLen = 0 const set = [] for (const c of commands) { - set.push([c, Npm.cmd(c).describeUsage.split('\n')]) + set.push([c, Npm.cmd(c).getUsage(null, false).split('\n')]) maxLen = Math.max(maxLen, c.length) } diff --git a/lib/utils/reify-output.js b/lib/utils/reify-output.js index f4a8442e9427f..48a5525d87f80 100644 --- a/lib/utils/reify-output.js +++ b/lib/utils/reify-output.js @@ -215,7 +215,7 @@ const packagesFundingMessage = (npm, { funding }) => { return } - output.standard('') + output.standard() const pkg = funding === 1 ? 'package' : 'packages' const is = funding === 1 ? 'is' : 'are' output.standard(`${funding} ${pkg} ${is} looking for funding`) diff --git a/lib/utils/verify-signatures.js b/lib/utils/verify-signatures.js index baadb2b1227d9..2130c847b60ec 100644 --- a/lib/utils/verify-signatures.js +++ b/lib/utils/verify-signatures.js @@ -70,7 +70,7 @@ class VerifySignatures { const timing = `audited ${this.auditedWithKeysCount} package${auditedPlural} in ` + `${Math.floor(Number(elapsed) / 1e9)}s` output.standard(timing) - output.standard('') + output.standard() const verifiedBold = this.npm.chalk.bold('verified') if (this.verifiedSignatureCount) { @@ -79,7 +79,7 @@ class VerifySignatures { } else { output.standard(`${this.verifiedSignatureCount} packages have ${verifiedBold} registry signatures`) } - output.standard('') + output.standard() } if (this.verifiedAttestationCount) { @@ -88,7 +88,7 @@ class VerifySignatures { } else { output.standard(`${this.verifiedAttestationCount} packages have ${verifiedBold} attestations`) } - output.standard('') + output.standard() } if (missing.length) { @@ -98,7 +98,7 @@ class VerifySignatures { } else { output.standard(`${missing.length} packages have ${missingClr} registry signatures but the registry is providing signing keys:`) } - output.standard('') + output.standard() missing.map(m => output.standard(`${this.npm.chalk.red(`${m.name}@${m.version}`)} (${m.registry})`) ) @@ -106,7 +106,7 @@ class VerifySignatures { if (invalid.length) { if (missing.length) { - output.standard('') + output.standard() } const invalidClr = this.npm.chalk.redBright('invalid') // We can have either invalid signatures or invalid provenance @@ -117,11 +117,11 @@ class VerifySignatures { } else { output.standard(`${invalidSignatures.length} packages have ${invalidClr} registry signatures:`) } - output.standard('') + output.standard() invalidSignatures.map(i => output.standard(`${this.npm.chalk.red(`${i.name}@${i.version}`)} (${i.registry})`) ) - output.standard('') + output.standard() } const invalidAttestations = this.invalid.filter(i => i.code === 'EATTESTATIONVERIFY') @@ -131,11 +131,11 @@ class VerifySignatures { } else { output.standard(`${invalidAttestations.length} packages have ${invalidClr} attestations:`) } - output.standard('') + output.standard() invalidAttestations.map(i => output.standard(`${this.npm.chalk.red(`${i.name}@${i.version}`)} (${i.registry})`) ) - output.standard('') + output.standard() } if (invalid.length === 1) { @@ -143,7 +143,7 @@ class VerifySignatures { } else { output.standard(`Someone might have tampered with these packages since they were published on the registry!`) } - output.standard('') + output.standard() } } diff --git a/mock-registry/lib/index.js b/mock-registry/lib/index.js index 9b14cd46d8937..b99dfe6b156aa 100644 --- a/mock-registry/lib/index.js +++ b/mock-registry/lib/index.js @@ -648,6 +648,26 @@ class MockRegistry { .matchHeader('authorization', `Bearer ${idToken}`) .reply(statusCode, body || {}) } + + // Trust API methods + trustList ({ packageName, responseCode = 200, body = [] }) { + const spec = npa(packageName) + this.nock = this.nock.get(this.fullPath(`/-/package/${spec.escapedName}/trust`)) + .reply(responseCode, body) + } + + trustCreate ({ packageName, responseCode = 200, body = { ok: true } }) { + const spec = npa(packageName) + this.nock = this.nock.post(this.fullPath(`/-/package/${spec.escapedName}/trust`)) + .reply(responseCode, body) + } + + trustRevoke ({ packageName, id, responseCode = 200, body = { ok: true } }) { + const spec = npa(packageName) + const encodedId = encodeURIComponent(id) + this.nock = this.nock.delete(this.fullPath(`/-/package/${spec.escapedName}/trust/${encodedId}`)) + .reply(responseCode, body) + } } module.exports = MockRegistry diff --git a/tap-snapshots/test/lib/commands/completion.js.test.cjs b/tap-snapshots/test/lib/commands/completion.js.test.cjs index 64759ec6ef9cf..50b21f56f3642 100644 --- a/tap-snapshots/test/lib/commands/completion.js.test.cjs +++ b/tap-snapshots/test/lib/commands/completion.js.test.cjs @@ -6,18 +6,17 @@ */ 'use strict' exports[`test/lib/commands/completion.js TAP completion --no- flags > flags 1`] = ` -Array [ - String( - --no-version - --no-versions - ), -] +Array [] ` exports[`test/lib/commands/completion.js TAP completion commands with no completion > no results 1`] = ` Array [] ` +exports[`test/lib/commands/completion.js TAP completion completion after custom definition flag requiring value > custom definition non-Boolean flag handled 1`] = ` +Array [] +` + exports[`test/lib/commands/completion.js TAP completion completion cannot complete options that take a value in mid-command > does not try to complete option arguments in the middle of a command 1`] = ` Array [] ` @@ -38,134 +37,16 @@ exports[`test/lib/commands/completion.js TAP completion completion of invalid co Array [] ` +exports[`test/lib/commands/completion.js TAP completion completion with double-dash escape in command line > double-dash escape handled 1`] = ` +Array [] +` + +exports[`test/lib/commands/completion.js TAP completion completion with non-flag word > non-flag word completion 1`] = ` +Array [] +` + exports[`test/lib/commands/completion.js TAP completion double dashes escape from flag completion > full command list 1`] = ` -Array [ - String( - access - adduser - audit - bugs - cache - ci - completion - config - dedupe - deprecate - diff - dist-tag - docs - doctor - edit - exec - explain - explore - find-dupes - fund - get - help - help-search - init - install - install-ci-test - install-test - link - ll - login - logout - ls - org - outdated - owner - pack - ping - pkg - prefix - profile - prune - publish - query - rebuild - repo - restart - root - run - sbom - search - set - shrinkwrap - star - stars - start - stop - team - test - token - undeprecate - uninstall - unpublish - unstar - update - version - view - whoami - author - home - issues - info - show - find - add - unlink - remove - rm - r - un - rb - list - ln - create - i - it - cit - up - c - s - se - tst - t - ddp - v - run-script - clean-install - clean-install-test - x - why - la - verison - ic - innit - in - ins - inst - insta - instal - isnt - isnta - isntal - isntall - install-clean - isntall-clean - hlep - dist-tags - upgrade - udpate - rum - sit - urn - ogr - add-user - ), -] +Array [] ` exports[`test/lib/commands/completion.js TAP completion filtered subcommands > filtered subcommands 1`] = ` @@ -173,15 +54,7 @@ Array [] ` exports[`test/lib/commands/completion.js TAP completion flags > flags 1`] = ` -Array [ - String( - --version - --versions - --viewer - --verbose - --v - ), -] +Array [] ` exports[`test/lib/commands/completion.js TAP completion multiple command names > multiple command names 1`] = ` @@ -215,6 +88,61 @@ Array [ ] ` +exports[`test/lib/commands/completion.js TAP completion trust filtered subcommands > trust filtered subcommands 1`] = ` +Array [ + String( + github + gitlab + ), +] +` + +exports[`test/lib/commands/completion.js TAP completion trust github flags > trust github flags with custom definitions 1`] = ` +Array [ + String( + --yes + --json + --dry-run + --file + --repository + --repo + --environment + --env + --no-yes + --no-json + --no-dry-run + ), +] +` + +exports[`test/lib/commands/completion.js TAP completion trust gitlab flags > trust gitlab flags with custom definitions 1`] = ` +Array [ + String( + --yes + --json + --dry-run + --file + --project + --environment + --env + --no-yes + --no-json + --no-dry-run + ), +] +` + +exports[`test/lib/commands/completion.js TAP completion trust subcommands > trust subcommands 1`] = ` +Array [ + String( + github + gitlab + list + revoke + ), +] +` + exports[`test/lib/commands/completion.js TAP windows without bash > no output 1`] = ` Array [] ` diff --git a/tap-snapshots/test/lib/commands/install.js.test.cjs b/tap-snapshots/test/lib/commands/install.js.test.cjs index 3c9fa9bbec447..061eaa53cc016 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:249:27) +verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:312:7) +verbose stack at MockNpm.exec ({CWD}/lib/npm.js:211:9) error code EBADDEVENGINES error EBADDEVENGINES The developer of this package has specified the following through devEngines error EBADDEVENGINES Invalid devEngines.runtime @@ -199,9 +199,9 @@ warn EBADDEVENGINES } verbose stack Error: The developer of this package has specified the following through devEngines verbose stack Invalid devEngines.runtime verbose stack Invalid name "nondescript" does not match "node" for "runtime" -verbose stack at Install.checkDevEngines ({CWD}/lib/base-cmd.js:181:27) -verbose stack at MockNpm.#exec ({CWD}/lib/npm.js:252:7) -verbose stack at MockNpm.exec ({CWD}/lib/npm.js:208:9) +verbose stack at Install.checkDevEngines ({CWD}/lib/base-cmd.js:249:27) +verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:312:7) +verbose stack at MockNpm.exec ({CWD}/lib/npm.js:211:9) error code EBADDEVENGINES error EBADDEVENGINES The developer of this package has specified the following through devEngines error EBADDEVENGINES Invalid devEngines.runtime @@ -225,9 +225,9 @@ silly logfile done cleaning log files verbose stack Error: The developer of this package has specified the following through devEngines verbose stack Invalid devEngines.runtime verbose stack Invalid name "nondescript" does not match "node" for "runtime" -verbose stack at Install.checkDevEngines ({CWD}/lib/base-cmd.js:181:27) -verbose stack at MockNpm.#exec ({CWD}/lib/npm.js:252:7) -verbose stack at MockNpm.exec ({CWD}/lib/npm.js:208:9) +verbose stack at Install.checkDevEngines ({CWD}/lib/base-cmd.js:249:27) +verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:312:7) +verbose stack at MockNpm.exec ({CWD}/lib/npm.js:211:9) error code EBADDEVENGINES error EBADDEVENGINES The developer of this package has specified the following through devEngines error EBADDEVENGINES Invalid devEngines.runtime diff --git a/tap-snapshots/test/lib/commands/publish.js.test.cjs b/tap-snapshots/test/lib/commands/publish.js.test.cjs index 96a9d064d0e4e..e7507118a28f5 100644 --- a/tap-snapshots/test/lib/commands/publish.js.test.cjs +++ b/tap-snapshots/test/lib/commands/publish.js.test.cjs @@ -172,6 +172,7 @@ Object { "man/man1/npm-explore.1", "man/man1/npm-find-dupes.1", "man/man1/npm-fund.1", + "man/man1/npm-get.1", "man/man1/npm-help-search.1", "man/man1/npm-help.1", "man/man1/npm-init.1", @@ -179,6 +180,7 @@ Object { "man/man1/npm-install-test.1", "man/man1/npm-install.1", "man/man1/npm-link.1", + "man/man1/npm-ll.1", "man/man1/npm-login.1", "man/man1/npm-logout.1", "man/man1/npm-ls.1", @@ -200,6 +202,7 @@ Object { "man/man1/npm-run.1", "man/man1/npm-sbom.1", "man/man1/npm-search.1", + "man/man1/npm-set.1", "man/man1/npm-shrinkwrap.1", "man/man1/npm-star.1", "man/man1/npm-stars.1", @@ -208,6 +211,7 @@ Object { "man/man1/npm-team.1", "man/man1/npm-test.1", "man/man1/npm-token.1", + "man/man1/npm-trust.1", "man/man1/npm-undeprecate.1", "man/man1/npm-uninstall.1", "man/man1/npm-unpublish.1", diff --git a/tap-snapshots/test/lib/docs.js.test.cjs b/tap-snapshots/test/lib/docs.js.test.cjs index b9b2e32355e94..6f5c9be1ab2f6 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,55 @@ Options: [-w|--workspace [-w|--workspace ...]] [--workspaces] [--include-workspace-root] [--install-links] + --install-strategy + Sets the strategy for installing packages in node_modules. + + --legacy-bundling + Instead of hoisting package installs in \`node_modules\`, install packages + + --global-style + Only install direct dependencies in the top level \`node_modules\`, + + --omit + Dependency types to omit from the installation tree on disk. + + --include + Option that allows for defining which types of dependencies to install. + + --strict-peer-deps + If set to \`true\`, and \`--legacy-peer-deps\` is not set, then _any_ + + --foreground-scripts + Run all build scripts (ie, \`preinstall\`, \`install\`, and + + --ignore-scripts + If true, npm does not run scripts specified in package.json files. + + --audit + When "true" submit audit reports alongside the current npm command to the + + --bin-links + Tells npm to create symlinks (or \`.cmd\` shims on Windows) for package + + --fund + When "true" displays the message at the end of each \`npm install\` + + --dry-run + Indicates that you don't want npm to make any changes and that it should + + -w||--workspace + Enable running a command in the context of the configured workspaces of the + + --workspaces + Set to true to run the command in the context of **all** configured + + --include-workspace-root + Include the workspace root when workspaces are enabled for a command. + + --install-links + When set file: protocol dependencies will be packed and installed as + + aliases: clean-install, ic, install-clean, isntall-clean Run "npm help ci" for more info @@ -2968,6 +3101,9 @@ Tab Completion for npm Usage: npm completion +Options: + + Run "npm help completion" for more info \`\`\`bash @@ -2994,6 +3130,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 +3186,55 @@ Options: [-w|--workspace [-w|--workspace ...]] [--workspaces] [--include-workspace-root] [--install-links] + --install-strategy + Sets the strategy for installing packages in node_modules. + + --legacy-bundling + Instead of hoisting package installs in \`node_modules\`, install packages + + --global-style + Only install direct dependencies in the top level \`node_modules\`, + + --strict-peer-deps + If set to \`true\`, and \`--legacy-peer-deps\` is not set, then _any_ + + --package-lock + If set to false, then ignore \`package-lock.json\` files when installing. + + --omit + Dependency types to omit from the installation tree on disk. + + --include + Option that allows for defining which types of dependencies to install. + + --ignore-scripts + If true, npm does not run scripts specified in package.json files. + + --audit + When "true" submit audit reports alongside the current npm command to the + + --bin-links + Tells npm to create symlinks (or \`.cmd\` shims on Windows) for package + + --fund + When "true" displays the message at the end of each \`npm install\` + + --dry-run + Indicates that you don't want npm to make any changes and that it should + + -w||--workspace + Enable running a command in the context of the configured workspaces of the + + --workspaces + Set to true to run the command in the context of **all** configured + + --include-workspace-root + Include the workspace root when workspaces are enabled for a command. + + --install-links + When set file: protocol dependencies will be packed and installed as + + alias: ddp Run "npm help dedupe" for more info @@ -3072,6 +3273,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 +3310,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 +3383,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 +3421,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 +3463,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 +3487,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 +3516,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 +3561,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 +3591,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 +3621,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 +3701,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 +3739,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 +3763,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 +3791,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 +3820,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 +3909,85 @@ Options: [-w|--workspace [-w|--workspace ...]] [--workspaces] [--include-workspace-root] [--install-links] + -S||--save + Save installed packages to a \`package.json\` file as dependencies. + + -E||--save-exact + Dependencies saved to package.json will be configured with an exact + + -g||--global + Operates in "global" mode, so that packages are installed into the + + --install-strategy + Sets the strategy for installing packages in node_modules. + + --legacy-bundling + Instead of hoisting package installs in \`node_modules\`, install packages + + --global-style + Only install direct dependencies in the top level \`node_modules\`, + + --omit + Dependency types to omit from the installation tree on disk. + + --include + Option that allows for defining which types of dependencies to install. + + --strict-peer-deps + If set to \`true\`, and \`--legacy-peer-deps\` is not set, then _any_ + + --prefer-dedupe + Prefer to deduplicate packages if possible, rather than + + --package-lock + If set to false, then ignore \`package-lock.json\` files when installing. + + --package-lock-only + If set to true, the current operation will only use the \`package-lock.json\`, + + --foreground-scripts + Run all build scripts (ie, \`preinstall\`, \`install\`, and + + --ignore-scripts + If true, npm does not run scripts specified in package.json files. + + --audit + When "true" submit audit reports alongside the current npm command to the + + --before + If passed to \`npm install\`, will rebuild the npm tree such that only + + --bin-links + Tells npm to create symlinks (or \`.cmd\` shims on Windows) for package + + --fund + When "true" displays the message at the end of each \`npm install\` + + --dry-run + Indicates that you don't want npm to make any changes and that it should + + --cpu + Override CPU architecture of native modules to install. + + --os + Override OS of native modules to install. + + --libc + Override libc of native modules to install. + + -w||--workspace + Enable running a command in the context of the configured workspaces of the + + --workspaces + Set to true to run the command in the context of **all** configured + + --include-workspace-root + Include the workspace root when workspaces are enabled for a command. + + --install-links + When set file: protocol dependencies will be packed and installed as + + aliases: add, i, in, ins, inst, insta, instal, isnt, isnta, isntal, isntall Run "npm help install" for more info @@ -3535,6 +4043,55 @@ Options: [-w|--workspace [-w|--workspace ...]] [--workspaces] [--include-workspace-root] [--install-links] + --install-strategy + Sets the strategy for installing packages in node_modules. + + --legacy-bundling + Instead of hoisting package installs in \`node_modules\`, install packages + + --global-style + Only install direct dependencies in the top level \`node_modules\`, + + --omit + Dependency types to omit from the installation tree on disk. + + --include + Option that allows for defining which types of dependencies to install. + + --strict-peer-deps + If set to \`true\`, and \`--legacy-peer-deps\` is not set, then _any_ + + --foreground-scripts + Run all build scripts (ie, \`preinstall\`, \`install\`, and + + --ignore-scripts + If true, npm does not run scripts specified in package.json files. + + --audit + When "true" submit audit reports alongside the current npm command to the + + --bin-links + Tells npm to create symlinks (or \`.cmd\` shims on Windows) for package + + --fund + When "true" displays the message at the end of each \`npm install\` + + --dry-run + Indicates that you don't want npm to make any changes and that it should + + -w||--workspace + Enable running a command in the context of the configured workspaces of the + + --workspaces + Set to true to run the command in the context of **all** configured + + --include-workspace-root + Include the workspace root when workspaces are enabled for a command. + + --install-links + When set file: protocol dependencies will be packed and installed as + + aliases: cit, clean-install-test, sit Run "npm help install-ci-test" for more info @@ -3583,6 +4140,85 @@ Options: [-w|--workspace [-w|--workspace ...]] [--workspaces] [--include-workspace-root] [--install-links] + -S||--save + Save installed packages to a \`package.json\` file as dependencies. + + -E||--save-exact + Dependencies saved to package.json will be configured with an exact + + -g||--global + Operates in "global" mode, so that packages are installed into the + + --install-strategy + Sets the strategy for installing packages in node_modules. + + --legacy-bundling + Instead of hoisting package installs in \`node_modules\`, install packages + + --global-style + Only install direct dependencies in the top level \`node_modules\`, + + --omit + Dependency types to omit from the installation tree on disk. + + --include + Option that allows for defining which types of dependencies to install. + + --strict-peer-deps + If set to \`true\`, and \`--legacy-peer-deps\` is not set, then _any_ + + --prefer-dedupe + Prefer to deduplicate packages if possible, rather than + + --package-lock + If set to false, then ignore \`package-lock.json\` files when installing. + + --package-lock-only + If set to true, the current operation will only use the \`package-lock.json\`, + + --foreground-scripts + Run all build scripts (ie, \`preinstall\`, \`install\`, and + + --ignore-scripts + If true, npm does not run scripts specified in package.json files. + + --audit + When "true" submit audit reports alongside the current npm command to the + + --before + If passed to \`npm install\`, will rebuild the npm tree such that only + + --bin-links + Tells npm to create symlinks (or \`.cmd\` shims on Windows) for package + + --fund + When "true" displays the message at the end of each \`npm install\` + + --dry-run + Indicates that you don't want npm to make any changes and that it should + + --cpu + Override CPU architecture of native modules to install. + + --os + Override OS of native modules to install. + + --libc + Override libc of native modules to install. + + -w||--workspace + Enable running a command in the context of the configured workspaces of the + + --workspaces + Set to true to run the command in the context of **all** configured + + --include-workspace-root + Include the workspace root when workspaces are enabled for a command. + + --install-links + When set file: protocol dependencies will be packed and installed as + + alias: it Run "npm help install-test" for more info @@ -3640,6 +4276,64 @@ Options: [-w|--workspace [-w|--workspace ...]] [--workspaces] [--include-workspace-root] [--install-links] + -S||--save + Save installed packages to a \`package.json\` file as dependencies. + + -E||--save-exact + Dependencies saved to package.json will be configured with an exact + + -g||--global + Operates in "global" mode, so that packages are installed into the + + --install-strategy + Sets the strategy for installing packages in node_modules. + + --legacy-bundling + Instead of hoisting package installs in \`node_modules\`, install packages + + --global-style + Only install direct dependencies in the top level \`node_modules\`, + + --strict-peer-deps + If set to \`true\`, and \`--legacy-peer-deps\` is not set, then _any_ + + --package-lock + If set to false, then ignore \`package-lock.json\` files when installing. + + --omit + Dependency types to omit from the installation tree on disk. + + --include + Option that allows for defining which types of dependencies to install. + + --ignore-scripts + If true, npm does not run scripts specified in package.json files. + + --audit + When "true" submit audit reports alongside the current npm command to the + + --bin-links + Tells npm to create symlinks (or \`.cmd\` shims on Windows) for package + + --fund + When "true" displays the message at the end of each \`npm install\` + + --dry-run + Indicates that you don't want npm to make any changes and that it should + + -w||--workspace + Enable running a command in the context of the configured workspaces of the + + --workspaces + Set to true to run the command in the context of **all** configured + + --include-workspace-root + Include the workspace root when workspaces are enabled for a command. + + --install-links + When set file: protocol dependencies will be packed and installed as + + alias: ln Run "npm help link" for more info @@ -3686,6 +4380,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 +4462,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 +4494,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 +4527,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 +4632,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 +4676,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 +4726,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 +4768,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 +4814,10 @@ npm ping Options: [--registry ] + --registry + The base URL of the npm registry. + + Run "npm help ping" for more info \`\`\`bash @@ -3968,6 +4845,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 +4884,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 +4911,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 +4954,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 +5014,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 +5065,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 +5107,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 +5163,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 +5201,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 +5227,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 +5253,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 +5307,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 +5352,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 +5420,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 +5465,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 +5497,10 @@ npm stars [] Options: [--registry ] + --registry + The base URL of the npm registry. + + Run "npm help stars" for more info \`\`\`bash @@ -4397,6 +5521,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 +5547,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 +5577,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 +5617,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 +5655,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 +5728,60 @@ Note: This command is unaware of workspaces. #### \`read-only\` ` +exports[`test/lib/docs.js TAP usage trust > must match snapshot 1`] = ` +Create a trusted relationship between a package and a OIDC provider + +Usage: +npm trust + +Subcommands: + github + Create a trusted relationship between a package and GitHub Actions + + gitlab + Create a trusted relationship between a package and GitLab CI/CD + + list + List trusted relationships for a package + + revoke + Revoke a trusted relationship for a package + +Run "npm trust --help" for more info on a subcommand. + +Run "npm help trust" for more info + +\`\`\`bash +npm trust +\`\`\` + +Note: This command is unaware of workspaces. + +#### Synopsis +#### Flags +#### \`file\` +#### \`repository\` +#### \`environment\` +#### \`yes\` +#### \`json\` +#### \`dry-run\` +#### Synopsis +#### Flags +#### \`file\` +#### \`project\` +#### \`environment\` +#### \`yes\` +#### \`json\` +#### \`dry-run\` +#### Synopsis +#### Configuration +#### \`json\` +#### Synopsis +#### Flags +#### \`id\` +#### \`dry-run\` +` + exports[`test/lib/docs.js TAP usage undeprecate > must match snapshot 1`] = ` Undeprecate a version of a package @@ -4533,6 +5791,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 +5826,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 +5874,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 +5908,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 +5949,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 +6056,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 +6127,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 +6165,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..95a30dc847a0c --- /dev/null +++ b/test/lib/base-cmd.js @@ -0,0 +1,462 @@ +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') +}) 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/docs.js b/test/lib/docs.js index 833e58831ea51..89310b8ab15b4 100644 --- a/test/lib/docs.js +++ b/test/lib/docs.js @@ -98,8 +98,7 @@ t.test('usage', async t => { const docsCommands = await readdir(join(docs.paths.content, 'commands'), docs.DOC_EXT) const bareCommands = ['npm', 'npx'] - // XXX: These extra commands exist as js files but not as docs pages - const allDocs = docsCommands.concat(['get', 'set', 'll']).map(n => n.replace('npm-', '')) + const allDocs = docsCommands.map(n => n.replace('npm-', '')) // ensure that the list of js files in commands, docs files, and the command list // are all in sync. eg, this will error if a command is removed but not its docs file diff --git a/test/lib/npm.js b/test/lib/npm.js index b4ac509adb495..d8b0875559659 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 => { + const { npm } = 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 (args, flags) { + 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 (args, flags) { + 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/subcommands/trust-github.js b/test/lib/subcommands/trust-github.js new file mode 100644 index 0000000000000..04e25a36392a5 --- /dev/null +++ b/test/lib/subcommands/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('trust-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('trust-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('trust-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('trust-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/subcommands/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/subcommands/trust-gitlab.js b/test/lib/subcommands/trust-gitlab.js new file mode 100644 index 0000000000000..85be57a6fa987 --- /dev/null +++ b/test/lib/subcommands/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('trust-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('trust-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('trust-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('trust-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/subcommands/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/subcommands/trust-list.js b/test/lib/subcommands/trust-list.js new file mode 100644 index 0000000000000..8a4d0d3de38ad --- /dev/null +++ b/test/lib/subcommands/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('trust-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('trust-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('trust-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('trust-list without package name and no package.json', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: {}, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + }) + + await t.rejects( + npm.exec('trust', ['list']), + { message: /Could not read package\.json/ } + ) +}) + +t.test('trust-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('trust-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('trust-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('trust-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/subcommands/trust-revoke.js b/test/lib/subcommands/trust-revoke.js new file mode 100644 index 0000000000000..beee7898e157e --- /dev/null +++ b/test/lib/subcommands/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('trust-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('trust-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('trust-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('trust-revoke without package name and no package.json', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: {}, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + }) + + await t.rejects( + npm.exec('trust', ['revoke', '--id', 'test-id']), + { message: /Could not read package\.json/ } + ) +}) + +t.test('trust-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('trust-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('trust-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('trust-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('trust-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('trust-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('trust-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('trust-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('trust-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/trust-cmd.js b/test/lib/trust-cmd.js new file mode 100644 index 0000000000000..ed8719bcc36e6 --- /dev/null +++ b/test/lib/trust-cmd.js @@ -0,0 +1,786 @@ +const t = require('tap') +const { load: loadMockNpm } = require('../fixtures/mock-npm.js') +const MockRegistry = require('@npmcli/mock-registry') +const realProcLog = require('proc-log') +const TrustCommand = require('../../lib/trust-cmd.js') + +const packageName = '@npmcli/test-package' + +t.test('trust-cmd via trust github with read function called', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + mocks: { + read: { + read: async () => 'y', + }, + }, + }) + + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: 'test-auth-token', + }) + + registry.trustCreate({ packageName }) + + await npm.exec('trust', ['github', packageName, '--file', 'workflow.yml', '--repository', 'owner/repo']) +}) + +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', 'owner/repo', '--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/owner/repo', + }), + }, + 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, logs } = 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', 'owner/repo']) + + t.ok(logs.notice.some(l => l.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', 'owner/repo']), + { 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', 'owner/repo']), + { 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', 'owner/repo']), + { 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', 'owner/repo']) + + 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', 'owner/repo']) + + 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', 'owner/repo']), + { 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', 'owner/repo']), + { 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', 'owner/repo']), + { 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', 'owner/repo']) +}) + +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', 'owner/repo']), + { 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/owner/repo', + }), + }, + 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', 'owner/repo']) +}) + +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: 'owner/repo', + workflow_ref: { + file: 'workflow.yml', + }, + }, + }, + }) + + await npm.exec('trust', ['github', packageName, '--file', 'workflow.yml', '--repository', 'owner/repo']) + + 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/owner/repo', + }), + }, + 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: 'owner/repo', + 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: 'owner/repo', + workflow_ref: { + file: 'workflow.yml', + }, + }, + }, + }) + + await npm.exec('trust', ['github', packageName, '--file', 'workflow.yml', '--repository', 'owner/repo']) + + const output = joinedOutput() + t.ok(output.includes('https://github.com/owner/repo'), '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', 'owner/repo']), + { 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: 'owner/repo', + file: 'workflow.yml', + }, + fromPackageJson: { + repository: true, + }, + urls: { + repository: 'https://github.com/owner/repo', + file: 'https://github.com/owner/repo/-/blob/HEAD/workflow.yml', + }, + }) + const output = joinedOutput() + t.ok(output.includes('from package.json'), 'shows fromPackageJson indicator') + t.ok(output.includes('https://github.com/owner/repo'), 'shows URL') +}) diff --git a/workspaces/config/lib/definitions/definition.js b/workspaces/config/lib/definitions/definition.js index 26ba0c0bc14b9..9e70da6256eb7 100644 --- a/workspaces/config/lib/definitions/definition.js +++ b/workspaces/config/lib/definitions/definition.js @@ -22,6 +22,7 @@ const allowed = [ 'typeDescription', 'usage', 'envExport', + 'alias', ] const { diff --git a/workspaces/config/lib/index.js b/workspaces/config/lib/index.js index 0ad716ccb069f..18d0db92938ce 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}" 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,25 @@ class Config { setEnvs () { setEnvs(this) } + + removeWarning (key) { + this.#warnings = this.#warnings.filter(w => w.type !== key) + } + + 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 +994,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..bb1da6b6bedc2 100644 --- a/workspaces/config/test/index.js +++ b/workspaces/config/test/index.js @@ -1144,7 +1144,7 @@ t.test('nerfdart auths set at the top level into the registry', async t => { // now we go ahead and do the repair, and save c.repair() await c.save('user') - t.same(c.list[3], expect) + t.same(c.data.get('user').data, expect) }) } }) @@ -1587,3 +1587,155 @@ 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') +}) From b59bc566e9e771c1a22a662f99648eb8ab192e37 Mon Sep 17 00:00:00 2001 From: reggi Date: Mon, 12 Jan 2026 12:40:34 -0500 Subject: [PATCH 02/28] chore: lint fixes --- lib/commands/completion.js | 3 --- test/lib/npm.js | 10 +++++----- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/lib/commands/completion.js b/lib/commands/completion.js index b82cb7a9d061c..85a538f7e0c03 100644 --- a/lib/commands/completion.js +++ b/lib/commands/completion.js @@ -40,9 +40,6 @@ 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) - class Completion extends BaseCommand { static description = 'Tab Completion for npm' static name = 'completion' diff --git a/test/lib/npm.js b/test/lib/npm.js index d8b0875559659..fba8d36a34277 100644 --- a/test/lib/npm.js +++ b/test/lib/npm.js @@ -558,7 +558,7 @@ t.test('exec edge cases', async t => { }) t.test('color true sets COLOR env to 1', async t => { - const { npm } = await loadMockNpm(t, { + await loadMockNpm(t, { config: { color: 'always' }, }) t.equal(process.env.COLOR, '1', 'COLOR env is set to 1 when color is truthy') @@ -602,10 +602,10 @@ t.test('exec edge cases', async 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') }) @@ -644,7 +644,7 @@ t.test('exec edge cases', async t => { execFlags = flags } - async execWorkspaces (args, flags) { + async execWorkspaces () { throw new Error('execWorkspaces should not be called') } } @@ -701,7 +701,7 @@ t.test('exec edge cases', async t => { }), } - async exec (args, flags) { + async exec () { throw new Error('exec should not be called') } From fdd3c50013e6d0c2d75a9b1ffa8728425830642f Mon Sep 17 00:00:00 2001 From: reggi Date: Mon, 12 Jan 2026 12:45:47 -0500 Subject: [PATCH 03/28] chore: fix windows tests failures for line nav.yml endings --- .gitattributes | 1 + 1 file changed, 1 insertion(+) 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 From a1995af0c2d6ef068855b5d75a53a009b1edec91 Mon Sep 17 00:00:00 2001 From: reggi Date: Mon, 12 Jan 2026 12:50:32 -0500 Subject: [PATCH 04/28] chore: fix smoke tests --- .../tap-snapshots/test/index.js.test.cjs | 54 ++++++++++++++++++- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/smoke-tests/tap-snapshots/test/index.js.test.cjs b/smoke-tests/tap-snapshots/test/index.js.test.cjs index 1e29ff05185d1..28587418fb518 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,55 @@ 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 --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 From ef046addc24936b56de467a098dc3a0d38df509f Mon Sep 17 00:00:00 2001 From: reggi Date: Mon, 12 Jan 2026 14:38:43 -0500 Subject: [PATCH 05/28] fix: docs test coverage --- docs/lib/build.js | 274 ++++++++----- docs/lib/content/nav.yml | 62 +-- docs/lib/index.js | 72 +++- docs/test/index.js | 839 ++++++++++++++++++++++++++++++++++++++- 4 files changed, 1091 insertions(+), 156 deletions(-) diff --git a/docs/lib/build.js b/docs/lib/build.js index bb18d15da130a..9bd3cf6da1ce1 100644 --- a/docs/lib/build.js +++ b/docs/lib/build.js @@ -7,12 +7,60 @@ 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, defaultChildren) => { + 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 + 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) + + // Try to find default metadata for this file + const defaultEntry = defaultChildren.find(c => c.url === `/${section}/${name}`) + + return { + title: attributes.title || defaultEntry?.title || name, + url: `/${section}/${name}`, + description: attributes.description || defaultEntry?.description || '', + name, + } + }) + ) + + return docs.sort((a, b) => a.name.localeCompare(b.name)).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 fs.readdir(docsCommandsPath) + 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 @@ -42,114 +90,64 @@ const generateNav = async (contentPath, navPath) => { // Remove the name field and arrange: npm, ...commands, npx const commands = [npm, ...otherCommands, npx].filter(Boolean).map(({ name, ...rest }) => rest) - // Build the navigation structure - const navData = [ - { + // Default metadata for configuring-npm section + const configuringNpmDefaults = [ + { title: 'Install', url: '/configuring-npm/install', description: 'Download and install node and npm' }, + { title: 'Folders', url: '/configuring-npm/folders', description: 'Folder structures used by npm' }, + { title: '.npmrc', url: '/configuring-npm/npmrc', description: 'The npm config files' }, + { title: 'npm-shrinkwrap.json', url: '/configuring-npm/npm-shrinkwrap-json', description: 'A publishable lockfile' }, + { title: 'package.json', url: '/configuring-npm/package-json', description: "Specifics of npm's package.json handling" }, + { title: 'package-lock.json', url: '/configuring-npm/package-lock-json', description: 'A manifestation of the manifest' }, + ] + + // Default metadata for using-npm section + const usingNpmDefaults = [ + { title: 'Registry', url: '/using-npm/registry', description: 'The JavaScript Package Registry' }, + { title: 'Package spec', url: '/using-npm/package-spec', description: 'Package name specifier' }, + { title: 'Config', url: '/using-npm/config', description: 'About npm configuration' }, + { title: 'Logging', url: '/using-npm/logging', description: 'Why, What & How we Log' }, + { title: 'Scope', url: '/using-npm/scope', description: 'Scoped packages' }, + { title: 'Scripts', url: '/using-npm/scripts', description: 'How npm handles the "scripts" field' }, + { title: 'Workspaces', url: '/using-npm/workspaces', description: 'Working with workspaces' }, + { title: 'Organizations', url: '/using-npm/orgs', description: 'Working with teams & organizations' }, + { title: 'Dependency Selectors', url: '/using-npm/dependency-selectors', description: 'Dependency Selector Syntax & Querying' }, + { title: 'Developers', url: '/using-npm/developers', description: 'Developer guide' }, + { title: 'Removal', url: '/using-npm/removal', description: 'Cleaning the slate' }, + ] + + // Read actual docs from configuring-npm and using-npm directories + const configuringNpmDocs = await readSectionDocs(contentPath, 'configuring-npm', configuringNpmDefaults) + const usingNpmDocs = await readSectionDocs(contentPath, 'using-npm', usingNpmDefaults) + + // 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: [ - { - title: 'Install', - url: '/configuring-npm/install', - description: 'Download and install node and npm', - }, - { - title: 'Folders', - url: '/configuring-npm/folders', - description: 'Folder structures used by npm', - }, - { - title: '.npmrc', - url: '/configuring-npm/npmrc', - description: 'The npm config files', - }, - { - title: 'npm-shrinkwrap.json', - url: '/configuring-npm/npm-shrinkwrap-json', - description: 'A publishable lockfile', - }, - { - title: 'package.json', - url: '/configuring-npm/package-json', - description: "Specifics of npm's package.json handling", - }, - { - title: 'package-lock.json', - url: '/configuring-npm/package-lock-json', - description: 'A manifestation of the manifest', - }, - ], - }, - { + children: configuringNpmDocs, + }) + } + + if (usingNpmDocs.length > 0) { + navData.push({ title: 'Using npm', shortName: 'Using', url: '/using-npm', - children: [ - { - title: 'Registry', - url: '/using-npm/registry', - description: 'The JavaScript Package Registry', - }, - { - title: 'Package spec', - url: '/using-npm/package-spec', - description: 'Package name specifier', - }, - { - title: 'Config', - url: '/using-npm/config', - description: 'About npm configuration', - }, - { - title: 'Logging', - url: '/using-npm/logging', - description: 'Why, What & How we Log', - }, - { - title: 'Scope', - url: '/using-npm/scope', - description: 'Scoped packages', - }, - { - title: 'Scripts', - url: '/using-npm/scripts', - description: 'How npm handles the "scripts" field', - }, - { - title: 'Workspaces', - url: '/using-npm/workspaces', - description: 'Working with workspaces', - }, - { - title: 'Organizations', - url: '/using-npm/orgs', - description: 'Working with teams & organizations', - }, - { - title: 'Dependency Selectors', - url: '/using-npm/dependency-selectors', - description: 'Dependency Selector Syntax & Querying', - }, - { - title: 'Developers', - url: '/using-npm/developers', - description: 'Developer guide', - }, - { - title: 'Removal', - url: '/using-npm/removal', - description: 'Cleaning the slate', - }, - ], - }, - ] + children: usingNpmDocs, + }) + } const prefix = ` # This is the navigation for the documentation pages; it is not used @@ -164,8 +162,19 @@ const autoGenerateMissingDocs = async (contentPath, navPath, commandsPath = null commandsPath = commandsPath || join(__dirname, '../../lib/commands') const docsCommandsPath = join(contentPath, 'commands') - // Get all commands from cmd-list - const { commands } = require('../../lib/utils/cmd-list.js') + // 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) @@ -177,6 +186,7 @@ const autoGenerateMissingDocs = async (contentPath, navPath, commandsPath = null 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` @@ -206,6 +216,61 @@ ${description} ` 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\n${yaml.stringify(navData)}`, 'utf-8') } } @@ -230,14 +295,17 @@ const pAll = async (obj) => { }, {}) } -const run = async ({ content, template, nav, man, html, md, skipAutoGenerate }) => { +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 - await generateNav(content, nav) + if (!skipGenerateNav) { + await generateNav(content, nav) + } await rmAll(man, html, md) const [contentPaths, navFile, options] = await Promise.all([ diff --git a/docs/lib/content/nav.yml b/docs/lib/content/nav.yml index b6535000b3b2b..9f728045ee489 100644 --- a/docs/lib/content/nav.yml +++ b/docs/lib/content/nav.yml @@ -222,18 +222,18 @@ shortName: Configuring url: /configuring-npm children: - - title: Install + - title: folders + url: /configuring-npm/folders + description: Folder Structures Used by npm + - title: install url: /configuring-npm/install description: Download and install node and npm - - title: Folders - url: /configuring-npm/folders - description: Folder structures used by npm - - title: .npmrc - url: /configuring-npm/npmrc - description: The npm config files - title: npm-shrinkwrap.json url: /configuring-npm/npm-shrinkwrap-json description: A publishable lockfile + - title: npmrc + url: /configuring-npm/npmrc + description: The npm config files - title: package.json url: /configuring-npm/package-json description: Specifics of npm's package.json handling @@ -244,36 +244,36 @@ shortName: Using url: /using-npm children: - - title: Registry - url: /using-npm/registry - description: The JavaScript Package Registry - - title: Package spec - url: /using-npm/package-spec - description: Package name specifier - - title: Config + - title: config url: /using-npm/config - description: About npm configuration + description: More than you probably want to know about npm configuration + - title: Dependency Selector Syntax & Querying + url: /using-npm/dependency-selectors + description: Dependency Selector Syntax & Querying + - title: developers + url: /using-npm/developers + description: Developer Guide - title: Logging url: /using-npm/logging - description: Why, What & How we Log - - title: Scope + description: Why, What & How We Log + - title: orgs + url: /using-npm/orgs + description: Working with Teams & Orgs + - title: package-spec + url: /using-npm/package-spec + description: Package name specifier + - title: registry + url: /using-npm/registry + description: The JavaScript Package Registry + - title: removal + url: /using-npm/removal + description: Cleaning the Slate + - title: scope url: /using-npm/scope description: Scoped packages - - title: Scripts + - title: scripts url: /using-npm/scripts description: How npm handles the "scripts" field - - title: Workspaces + - title: workspaces url: /using-npm/workspaces description: Working with workspaces - - title: Organizations - url: /using-npm/orgs - description: Working with teams & organizations - - title: Dependency Selectors - url: /using-npm/dependency-selectors - description: Dependency Selector Syntax & Querying - - title: Developers - url: /using-npm/developers - description: Developer guide - - title: Removal - url: /using-npm/removal - description: Cleaning the slate diff --git a/docs/lib/index.js b/docs/lib/index.js index d4e58c6d8ea3a..840e4c91a0074 100644 --- a/docs/lib/index.js +++ b/docs/lib/index.js @@ -22,7 +22,37 @@ const assertPlaceholder = (src, path, placeholder) => { return placeholder } -const getCommandByDoc = (docFile, docExt) => { +// Default command loader - loads commands from lib/commands +const defaultCommandLoader = (name) => { + return require(`../../lib/commands/${name}`) +} + +// Registry of custom commands for testing +let commandRegistry = {} + +// Command loader that checks registry first, then falls back to default +const getCommand = (name, commandLoader = defaultCommandLoader) => { + if (commandRegistry[name]) { + return commandRegistry[name] + } + return commandLoader(name) +} + +// Functions to manage the command registry for testing +const registerCommand = (name, command) => { + commandRegistry[name] = command +} + +/* istanbul ignore next - testing utility for cleanup */ +const unregisterCommand = (name) => { + delete commandRegistry[name] +} + +const clearCommandRegistry = () => { + commandRegistry = {} +} + +const getCommandByDoc = (docFile, docExt, commandLoader = defaultCommandLoader) => { // Grab the command name from the *.md filename // NOTE: We cannot use the name property command file because in the case of // `npx` the file being used is `lib/commands/exec.js` @@ -40,7 +70,7 @@ 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 command = require(`../../lib/commands/${srcName}`) + const command = getCommand(srcName, commandLoader) const { params, usage = [''], workspaces } = command const commandDefinitions = command.definitions || {} const definitionPool = { ...definitions, ...commandDefinitions } @@ -69,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] @@ -97,34 +127,27 @@ const replaceUsage = (src, { path }) => { return src.replace(replacer, synopsis.join('\n')) } -const replaceParams = (src, { path }) => { - const { params, name } = getCommandByDoc(path, DOC_EXT) +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 = require(`../../lib/commands/${name}`) + 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 } - // Check if there's a config placeholder in the source - const hasPlaceholder = src.includes(TAGS.CONFIG) - // If no params and no subcommands, nothing to replace if (!params && Object.keys(subcommands).length === 0) { return src } - // Only assert placeholder if we have content to replace - const replacer = hasPlaceholder ? assertPlaceholder(src, path, TAGS.CONFIG) : null - - if (!replacer) { - 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) { @@ -201,6 +224,7 @@ const replaceParams = (src, { path }) => { } // Original behavior for commands without subcommands but with params + /* istanbul ignore if - all commands with no subcommands have params */ if (!params) { return src } @@ -211,6 +235,7 @@ const replaceParams = (src, { path }) => { 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 { @@ -221,6 +246,7 @@ const replaceParams = (src, { path }) => { 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] @@ -246,13 +272,10 @@ const replaceParams = (src, { path }) => { const globalConfig = globalConfigParams.map((n) => { const def = commandDefinitions[n] || definitions[n] const shortcuts = def.short ? `\n* Shortcut: \`-${def.short}\`` : '' - const defAliases = def.alias || [] - const aliasText = defAliases.length > 0 - ? `\n* Aliases: ${defAliases.map(a => `\`--${a}\``).join(', ')}` - : '' - return `${def.describe()}${shortcuts}${aliasText}` + return `${def.describe()}${shortcuts}` }) + /* istanbul ignore if - no current non-subcommand commands have command-specific definitions */ if (commandSpecificParams.length > 0) { sections.push('', '#### Configuration', '') } @@ -343,4 +366,11 @@ module.exports = { manPath: manPath, md: transformMd, html: transformHTML, + // Testing utilities for command injection + testing: { + registerCommand, + unregisterCommand, + clearCommandRegistry, + getCommandByDoc, + }, } diff --git a/docs/test/index.js b/docs/test/index.js index 0257a07a0e663..c5aa93567e52e 100644 --- a/docs/test/index.js +++ b/docs/test/index.js @@ -3,7 +3,28 @@ const { join } = require('path') const walk = require('ignore-walk') const fs = require('fs/promises') const yaml = require('yaml') -const { paths: { content: CONTENT_DIR, nav: NAV, template: TEMPLATE } } = require('../lib/index.js') +const { + 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') @@ -20,6 +41,12 @@ const testBuildDocs = async (t, { verify, ...opts } = {}) => { 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 = { @@ -31,6 +58,8 @@ const testBuildDocs = async (t, { verify, ...opts } = {}) => { 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 { @@ -560,6 +589,355 @@ t.test('command-specific definitions with missing command file', async t => { }) }) +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('readSectionDocs title fallback to defaultEntry.title', async t => { + // This tests line 47: attributes.title || defaultEntry?.title || name + // Specifically the defaultEntry?.title branch when attributes.title is missing + const testDir = t.testdir({ + content: { + commands: { + 'npm.md': `--- +title: npm +section: 1 +description: javascript package manager +---`, + }, + 'configuring-npm': { + // Doc without title in frontmatter - should use defaultEntry.title + 'install.md': `--- +section: 5 +description: Download and install node and npm +--- + +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) + + // Find the configuring-npm section + const configuringSection = navData.find(s => s.title === 'Configuring npm') + t.ok(configuringSection, 'has configuring-npm section') + + const installEntry = configuringSection.children.find(c => c.url === '/configuring-npm/install') + t.ok(installEntry, 'has install entry') + // Should use defaultEntry.title ('Install') since attributes.title is missing + t.equal(installEntry.title, 'Install', 'uses defaultEntry.title when attributes.title is missing') + }) + + t.test('readSectionDocs title fallback to name', async t => { + // This tests line 47: attributes.title || defaultEntry?.title || name + // Specifically the name fallback when both attributes.title and defaultEntry are missing + const testDir = t.testdir({ + content: { + commands: { + 'npm.md': `--- +title: npm +section: 1 +description: javascript package manager +---`, + }, + 'configuring-npm': { + // Doc without title AND no matching defaultEntry (custom-doc is not in defaults) + 'custom-doc.md': `--- +section: 5 +description: A custom document +--- + +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) + + // Find the configuring-npm section + const configuringSection = navData.find(s => s.title === 'Configuring npm') + t.ok(configuringSection, 'has configuring-npm section') + + const customEntry = configuringSection.children.find(c => c.url === '/configuring-npm/custom-doc') + t.ok(customEntry, 'has custom-doc entry') + // Should use name ('custom-doc') since both attributes.title and defaultEntry are missing + t.equal(customEntry.title, 'custom-doc', 'uses name when both attributes.title and defaultEntry are missing') + }) + + t.test('readSectionDocs title uses attributes.title when present', async t => { + // This tests line 47: attributes.title || defaultEntry?.title || name + // Specifically when attributes.title IS present (first branch) + const testDir = t.testdir({ + content: { + commands: { + 'npm.md': `--- +title: npm +section: 1 +description: javascript package manager +---`, + }, + 'configuring-npm': { + // Doc WITH title in frontmatter - should use attributes.title + 'install.md': `--- +title: Custom Install Title +section: 5 +description: Download and install node and npm +--- + +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) + + // Find the configuring-npm section + const configuringSection = navData.find(s => s.title === 'Configuring npm') + t.ok(configuringSection, 'has configuring-npm section') + + const installEntry = configuringSection.children.find(c => c.url === '/configuring-npm/install') + t.ok(installEntry, 'has install entry') + // Should use attributes.title since it's present + t.equal(installEntry.title, 'Custom Install Title', 'uses attributes.title when present') + }) + + t.test('using-npm section title fallbacks', async t => { + // Test using-npm section with the same three branches + const testDir = t.testdir({ + content: { + commands: { + 'npm.md': `--- +title: npm +section: 1 +description: javascript package manager +---`, + }, + 'using-npm': { + // Doc with title in frontmatter + 'registry.md': `--- +title: Custom Registry Title +section: 7 +description: The JavaScript Package Registry +---`, + // Doc without title, should use defaultEntry.title + 'scope.md': `--- +section: 7 +description: Scoped packages +---`, + // Doc without title and not in defaults, should use name + 'unknown-topic.md': `--- +section: 7 +description: An unknown topic +---`, + }, + }, + '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 usingSection = navData.find(s => s.title === 'Using npm') + t.ok(usingSection, 'has using-npm section') + + const registryEntry = usingSection.children.find(c => c.url === '/using-npm/registry') + t.equal(registryEntry.title, 'Custom Registry Title', 'uses attributes.title for registry') + + const scopeEntry = usingSection.children.find(c => c.url === '/using-npm/scope') + t.equal(scopeEntry.title, 'Scope', 'uses defaultEntry.title for scope') + + const unknownEntry = usingSection.children.find(c => c.url === '/using-npm/unknown-topic') + t.equal(unknownEntry.title, 'unknown-topic', 'uses name for unknown-topic') + }) + + t.test('description fallback branches', async t => { + // Tests line 49: description: attributes.description || defaultEntry?.description || '' + const testDir = t.testdir({ + content: { + commands: { + 'npm.md': `--- +title: npm +section: 1 +description: javascript package manager +---`, + }, + 'configuring-npm': { + // Doc WITH description in frontmatter - uses attributes.description + 'install.md': `--- +title: Install +section: 5 +description: Custom install description from frontmatter +---`, + // Doc WITHOUT description, but matches defaultEntry - uses defaultEntry.description + 'folders.md': `--- +title: Folders +section: 5 +---`, + // Doc WITHOUT description AND no matching defaultEntry - uses empty string + 'custom-no-desc.md': `--- +title: Custom Doc +section: 5 +---`, + }, + }, + '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 configuringSection = navData.find(s => s.title === 'Configuring npm') + t.ok(configuringSection, 'has configuring-npm section') + + // Branch 1: attributes.description is present + const installEntry = configuringSection.children.find(c => c.url === '/configuring-npm/install') + t.equal(installEntry.description, 'Custom install description from frontmatter', 'uses attributes.description when present') + + // Branch 2: defaultEntry?.description (folders is in defaults) + const foldersEntry = configuringSection.children.find(c => c.url === '/configuring-npm/folders') + t.equal(foldersEntry.description, 'Folder structures used by npm', 'uses defaultEntry.description when attributes.description is missing') + + // Branch 3: empty string fallback (custom-no-desc is not in defaults) + const customEntry = configuringSection.children.find(c => c.url === '/configuring-npm/custom-no-desc') + t.equal(customEntry.description, '', 'uses empty string when both attributes.description and defaultEntry are missing') + }) +}) + 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 => { @@ -578,4 +956,463 @@ t.test('replaceParams with name edge cases', async t => { match: [/registry/], }) }) + + t.test('command with subcommands and aliases (trust)', async t => { + // Tests subcommand code path including line 184 (aliases in subcommand definitions) + // npm trust has subcommands with definitions that include aliases (repo, env) + await testCommandDoc(t, 'npm-trust', 'Create a trusted relationship between a package and a OIDC provider', { + match: [/Aliases/, /--repo/, /--env/], + }) + }) +}) +// Test harness for injecting custom commands to test edge cases +t.test('command injection test harness', async t => { + // Clear the registry after each test + t.afterEach(() => { + clearCommandRegistry() + }) + + t.test('command without description', async t => { + // Register a command without a description + registerCommand('testcmd-nodesc', { + usage: [''], + params: ['registry'], + }) + + const doc = createCommandDoc('npm-testcmd-nodesc', 'Test command without description') + const { html } = await testBuildDocs(t, { + content: { + commands: { 'npm-testcmd-nodesc.md': doc }, + }, + }) + + const htmlContent = await readHtmlDoc(html, 'npm-testcmd-nodesc') + t.ok(htmlContent.length > 0, 'generates HTML for command without description') + t.match(htmlContent, /registry/, 'includes registry param') + }) + + t.test('command without usage', async t => { + // Register a command without usage - should default to [''] + registerCommand('testcmd-nousage', { + params: ['registry'], + }) + + const doc = createCommandDoc('npm-testcmd-nousage', 'Test command without usage') + const { html } = await testBuildDocs(t, { + content: { + commands: { 'npm-testcmd-nousage.md': doc }, + }, + }) + + const htmlContent = await readHtmlDoc(html, 'npm-testcmd-nousage') + t.ok(htmlContent.length > 0, 'generates HTML for command without usage') + t.match(htmlContent, /npm testcmd-nousage/, 'includes command name in usage') + }) + + t.test('command without params (no definitions)', async t => { + // Register a command without params - should not have config section content + registerCommand('testcmd-noparams', { + usage: [''], + // No params specified + }) + + // Use a doc without the config placeholder since this command has no params + const doc = `--- +title: npm-testcmd-noparams +section: 1 +description: Test command without params +--- + +### Synopsis + + +` + + const { html } = await testBuildDocs(t, { + content: { + commands: { 'npm-testcmd-noparams.md': doc }, + }, + }) + + const htmlContent = await readHtmlDoc(html, 'npm-testcmd-noparams') + t.ok(htmlContent.length > 0, 'generates HTML for command without params') + t.match(htmlContent, /npm testcmd-noparams/, 'includes command name') + }) + + t.test('command with one definition with short flag', async t => { + // Register a command with a custom definition that has a short flag + registerCommand('testcmd-short', { + usage: [''], + params: ['custom-flag'], + definitions: { + 'custom-flag': { + key: 'custom-flag', + default: false, + type: Boolean, + short: 'c', + description: 'A custom flag with a short version', + describe: () => '#### `custom-flag`\n\n* Default: false\n* Type: Boolean\n\nA custom flag with a short version', + }, + }, + }) + + const doc = createCommandDoc('npm-testcmd-short', 'Test command with short flag') + const { html } = await testBuildDocs(t, { + content: { + commands: { 'npm-testcmd-short.md': doc }, + }, + }) + + const htmlContent = await readHtmlDoc(html, 'npm-testcmd-short') + t.ok(htmlContent.length > 0, 'generates HTML for command with short flag') + t.match(htmlContent, /custom-flag/, 'includes custom flag') + t.match(htmlContent, /-c/, 'includes short flag') + }) + + t.test('command with definition with aliases', async t => { + // Register a command with a definition that has aliases + registerCommand('testcmd-alias', { + usage: [''], + params: ['aliased-flag'], + definitions: { + 'aliased-flag': { + key: 'aliased-flag', + default: '', + type: String, + alias: ['af', 'alias-flag'], + description: 'A flag with aliases', + describe: () => '#### `aliased-flag`\n\n* Default: ""\n* Type: String\n\nA flag with aliases', + }, + }, + }) + + const doc = createCommandDoc('npm-testcmd-alias', 'Test command with aliased flag') + const { html } = await testBuildDocs(t, { + content: { + commands: { 'npm-testcmd-alias.md': doc }, + }, + }) + + const htmlContent = await readHtmlDoc(html, 'npm-testcmd-alias') + t.ok(htmlContent.length > 0, 'generates HTML for command with aliases') + t.match(htmlContent, /aliased-flag/, 'includes aliased flag') + t.match(htmlContent, /Aliases/, 'includes aliases section') + t.match(htmlContent, /--af/, 'includes first alias') + t.match(htmlContent, /--alias-flag/, 'includes second alias') + }) + + t.test('command with subcommands', async t => { + // Register a command with subcommands + class SubA { + static description = 'Subcommand A description' + static usage = [''] + static definitions = { + 'sub-a-flag': { + key: 'sub-a-flag', + default: false, + type: Boolean, + describe: () => '#### `sub-a-flag`\n\n* Default: false\n* Type: Boolean\n\nFlag for subcommand A', + }, + } + } + + class SubB { + static description = 'Subcommand B description' + static usage = ['[options]'] + static params = ['registry'] + } + + registerCommand('testcmd-subs', { + usage: [''], + params: null, + subcommands: { + 'sub-a': SubA, + 'sub-b': SubB, + }, + }) + + const doc = createCommandDoc('npm-testcmd-subs', 'Test command with subcommands') + const { html } = await testBuildDocs(t, { + content: { + commands: { 'npm-testcmd-subs.md': doc }, + }, + }) + + const htmlContent = await readHtmlDoc(html, 'npm-testcmd-subs') + t.ok(htmlContent.length > 0, 'generates HTML for command with subcommands') + t.match(htmlContent, /npm testcmd-subs sub-a/, 'includes sub-a subcommand') + t.match(htmlContent, /npm testcmd-subs sub-b/, 'includes sub-b subcommand') + t.match(htmlContent, /Subcommand A description/, 'includes sub-a description') + t.match(htmlContent, /sub-a-flag/, 'includes sub-a specific flag') + }) + + t.test('command with exclusive params', async t => { + // Register a command with exclusive params that expand + registerCommand('testcmd-exclusive', { + usage: [''], + // save has exclusive params (save-dev, save-optional, etc) + params: ['save'], + }) + + const doc = createCommandDoc('npm-testcmd-exclusive', 'Test command with exclusive params') + const { html } = await testBuildDocs(t, { + content: { + commands: { 'npm-testcmd-exclusive.md': doc }, + }, + }) + + const htmlContent = await readHtmlDoc(html, 'npm-testcmd-exclusive') + t.ok(htmlContent.length > 0, 'generates HTML for command with exclusive params') + // The exclusive params should be expanded from 'save' + t.match(htmlContent, /save/, 'includes save param') + }) + + t.test('command without workspaces', async t => { + // Register a command that is not workspace-aware + registerCommand('testcmd-noworkspaces', { + usage: [''], + params: ['registry'], + workspaces: false, + }) + + const doc = createCommandDoc('npm-testcmd-noworkspaces', 'Test command without workspaces') + const { html } = await testBuildDocs(t, { + content: { + commands: { 'npm-testcmd-noworkspaces.md': doc }, + }, + }) + + const htmlContent = await readHtmlDoc(html, 'npm-testcmd-noworkspaces') + t.ok(htmlContent.length > 0, 'generates HTML for command without workspaces') + t.match(htmlContent, /unaware of workspaces/, 'includes workspaces note') + }) + + t.test('command with workspaces enabled', async t => { + // Register a command that IS workspace-aware + registerCommand('testcmd-workspaces', { + usage: [''], + params: ['registry'], + workspaces: true, + }) + + const doc = createCommandDoc('npm-testcmd-workspaces', 'Test command with workspaces') + const { html } = await testBuildDocs(t, { + content: { + commands: { 'npm-testcmd-workspaces.md': doc }, + }, + }) + + const htmlContent = await readHtmlDoc(html, 'npm-testcmd-workspaces') + t.ok(htmlContent.length > 0, 'generates HTML for command with workspaces') + t.notMatch(htmlContent, /unaware of workspaces/, 'does NOT include workspaces note') + }) + + t.test('subcommand without description', async t => { + // Register a command with a subcommand that has no description + class SubNoDesc { + static usage = [''] + static definitions = { + flag: { + key: 'flag', + default: false, + type: Boolean, + describe: () => '#### `flag`\n\n* Default: false\n* Type: Boolean\n\nA flag', + }, + } + } + + registerCommand('testcmd-sub-nodesc', { + usage: [''], + params: null, + subcommands: { + mysub: SubNoDesc, + }, + }) + + const doc = createCommandDoc('npm-testcmd-sub-nodesc', 'Test command with subcommand without description') + const { html } = await testBuildDocs(t, { + content: { + commands: { 'npm-testcmd-sub-nodesc.md': doc }, + }, + }) + + const htmlContent = await readHtmlDoc(html, 'npm-testcmd-sub-nodesc') + t.ok(htmlContent.length > 0, 'generates HTML for subcommand without description') + t.match(htmlContent, /npm testcmd-sub-nodesc mysub/, 'includes subcommand') + }) + + t.test('subcommand without usage', async t => { + // Register a command with a subcommand that has no usage + class SubNoUsage { + static description = 'Subcommand without usage' + static definitions = { + flag: { + key: 'flag', + default: false, + type: Boolean, + describe: () => '#### `flag`\n\n* Default: false\n* Type: Boolean\n\nA flag', + }, + } + } + + registerCommand('testcmd-sub-nousage', { + usage: [''], + params: null, + subcommands: { + mysub: SubNoUsage, + }, + }) + + const doc = createCommandDoc('npm-testcmd-sub-nousage', 'Test command with subcommand without usage') + const { html } = await testBuildDocs(t, { + content: { + commands: { 'npm-testcmd-sub-nousage.md': doc }, + }, + }) + + const htmlContent = await readHtmlDoc(html, 'npm-testcmd-sub-nousage') + t.ok(htmlContent.length > 0, 'generates HTML for subcommand without usage') + t.match(htmlContent, /Subcommand without usage/, 'includes subcommand description') + }) + + t.test('subcommand with short flag and alias', async t => { + // Register a command with a subcommand that has definitions with short and alias + class SubWithShortAlias { + static description = 'Subcommand with short and alias' + static usage = [''] + static definitions = { + 'complex-flag': { + key: 'complex-flag', + default: '', + type: String, + short: 'x', + alias: ['cf', 'cflag'], + describe: () => '#### `complex-flag`\n\n* Default: ""\n* Type: String\n\nA complex flag', + }, + } + } + + registerCommand('testcmd-sub-complex', { + usage: [''], + params: null, + subcommands: { + mysub: SubWithShortAlias, + }, + }) + + const doc = createCommandDoc('npm-testcmd-sub-complex', 'Test command with complex subcommand') + const { html } = await testBuildDocs(t, { + content: { + commands: { 'npm-testcmd-sub-complex.md': doc }, + }, + }) + + const htmlContent = await readHtmlDoc(html, 'npm-testcmd-sub-complex') + t.ok(htmlContent.length > 0, 'generates HTML for subcommand with short and alias') + t.match(htmlContent, /complex-flag/, 'includes complex flag') + t.match(htmlContent, /-x/, 'includes short flag') + t.match(htmlContent, /Aliases/, 'includes aliases') + t.match(htmlContent, /--cf/, 'includes first alias') + t.match(htmlContent, /--cflag/, 'includes second alias') + }) + + t.test('subcommand with explicit params (not derived from definitions)', async t => { + // Register a command with a subcommand that has explicit params array + class SubWithParams { + static description = 'Subcommand with explicit params' + static usage = [''] + static params = ['registry', 'tag'] + } + + registerCommand('testcmd-sub-params', { + usage: [''], + params: null, + subcommands: { + mysub: SubWithParams, + }, + }) + + const doc = createCommandDoc('npm-testcmd-sub-params', 'Test command with subcommand with explicit params') + const { html } = await testBuildDocs(t, { + content: { + commands: { 'npm-testcmd-sub-params.md': doc }, + }, + }) + + const htmlContent = await readHtmlDoc(html, 'npm-testcmd-sub-params') + t.ok(htmlContent.length > 0, 'generates HTML for subcommand with explicit params') + t.match(htmlContent, /registry/, 'includes registry param') + t.match(htmlContent, /tag/, 'includes tag param') + }) + + t.test('command with mixed command-specific and global params', async t => { + // Register a command that has both command-specific definitions AND global params + registerCommand('testcmd-mixed', { + usage: [''], + params: ['custom-only', 'registry'], + definitions: { + 'custom-only': { + key: 'custom-only', + default: false, + type: Boolean, + description: 'A command-specific flag', + describe: () => '#### `custom-only`\n\n* Default: false\n* Type: Boolean\n\nA command-specific flag', + }, + }, + }) + + const doc = createCommandDoc('npm-testcmd-mixed', 'Test command with mixed params') + const { html } = await testBuildDocs(t, { + content: { + commands: { 'npm-testcmd-mixed.md': doc }, + }, + }) + + const htmlContent = await readHtmlDoc(html, 'npm-testcmd-mixed') + t.ok(htmlContent.length > 0, 'generates HTML for command with mixed params') + t.match(htmlContent, /custom-only/, 'includes command-specific flag') + t.match(htmlContent, /registry/, 'includes global registry param') + }) + + t.test('subcommand with global config param that has alias in subDefinitions', async t => { + // This specifically tests the aliasText branch in globalConfigParams (line ~214) + // We need a subcommand that uses a global param but overrides it with an alias in subDefinitions + const { definitions: globalDefs } = require('@npmcli/config/lib/definitions') + + class SubWithGlobalAlias { + static description = 'Subcommand using global param with alias override' + static usage = [''] + static params = ['registry'] + // Define registry in subDefinitions with an alias - this shadows the global definition + static definitions = { + registry: { + ...globalDefs.registry, + alias: ['reg', 'r'], + describe: () => globalDefs.registry.describe(), + }, + } + } + + registerCommand('testcmd-sub-global-alias', { + usage: [''], + params: null, + subcommands: { + mysub: SubWithGlobalAlias, + }, + }) + + const doc = createCommandDoc('npm-testcmd-sub-global-alias', 'Test subcommand with global aliased param') + const { html } = await testBuildDocs(t, { + content: { + commands: { 'npm-testcmd-sub-global-alias.md': doc }, + }, + }) + + const htmlContent = await readHtmlDoc(html, 'npm-testcmd-sub-global-alias') + t.ok(htmlContent.length > 0, 'generates HTML for subcommand with global aliased param') + t.match(htmlContent, /registry/, 'includes registry param') + t.match(htmlContent, /Aliases/, 'includes aliases in global config section') + t.match(htmlContent, /--reg/, 'includes first alias') + t.match(htmlContent, /--r/, 'includes second alias') + }) }) From 2cd3281de3217761a8ab122d6f9d853bfedb3698 Mon Sep 17 00:00:00 2001 From: reggi Date: Mon, 12 Jan 2026 15:18:17 -0500 Subject: [PATCH 06/28] chore: fix the docs tests + nav gen order --- docs/lib/build.js | 98 ++-- docs/lib/content/configuring-npm/folders.md | 4 +- docs/lib/content/configuring-npm/install.md | 2 +- docs/lib/content/configuring-npm/npmrc.md | 2 +- docs/lib/content/nav.yml | 524 +++++++++--------- docs/lib/content/using-npm/config.md | 4 +- .../content/using-npm/dependency-selectors.md | 2 +- docs/lib/content/using-npm/developers.md | 4 +- docs/lib/content/using-npm/logging.md | 2 +- docs/lib/content/using-npm/orgs.md | 4 +- docs/lib/content/using-npm/package-spec.md | 2 +- docs/lib/content/using-npm/registry.md | 2 +- docs/lib/content/using-npm/removal.md | 4 +- docs/lib/content/using-npm/scope.md | 2 +- docs/lib/content/using-npm/scripts.md | 2 +- docs/lib/content/using-npm/workspaces.md | 2 +- docs/test/index.js | 241 -------- 17 files changed, 333 insertions(+), 568 deletions(-) diff --git a/docs/lib/build.js b/docs/lib/build.js index 9bd3cf6da1ce1..9c25513010ff8 100644 --- a/docs/lib/build.js +++ b/docs/lib/build.js @@ -18,7 +18,7 @@ const dirExists = async (path) => { } // Helper to read docs from a section directory -const readSectionDocs = async (contentPath, section, defaultChildren) => { +const readSectionDocs = async (contentPath, section, orderedUrls) => { const sectionPath = join(contentPath, section) if (!await dirExists(sectionPath)) { return [] @@ -33,26 +33,36 @@ const readSectionDocs = async (contentPath, section, defaultChildren) => { return [] } - // Parse each doc file to get title and description + // 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) - // Try to find default metadata for this file - const defaultEntry = defaultChildren.find(c => c.url === `/${section}/${name}`) - return { - title: attributes.title || defaultEntry?.title || name, + title: attributes.title, url: `/${section}/${name}`, - description: attributes.description || defaultEntry?.description || '', + description: attributes.description, name, } }) ) - return docs.sort((a, b) => a.name.localeCompare(b.name)).map(({ name, ...rest }) => rest) + // 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 @@ -80,44 +90,44 @@ const generateNav = async (contentPath, navPath) => { }) ) - // Separate npm, npx, and other commands - const npm = allCommands.find(cmd => cmd.name === 'npm') - const npx = allCommands.find(cmd => cmd.name === 'npx') - const otherCommands = allCommands - .filter(cmd => cmd.name !== 'npm' && cmd.name !== 'npx') + // 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 and arrange: npm, ...commands, npx - const commands = [npm, ...otherCommands, npx].filter(Boolean).map(({ name, ...rest }) => rest) - - // Default metadata for configuring-npm section - const configuringNpmDefaults = [ - { title: 'Install', url: '/configuring-npm/install', description: 'Download and install node and npm' }, - { title: 'Folders', url: '/configuring-npm/folders', description: 'Folder structures used by npm' }, - { title: '.npmrc', url: '/configuring-npm/npmrc', description: 'The npm config files' }, - { title: 'npm-shrinkwrap.json', url: '/configuring-npm/npm-shrinkwrap-json', description: 'A publishable lockfile' }, - { title: 'package.json', url: '/configuring-npm/package-json', description: "Specifics of npm's package.json handling" }, - { title: 'package-lock.json', url: '/configuring-npm/package-lock-json', description: 'A manifestation of the manifest' }, + // 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', ] - // Default metadata for using-npm section - const usingNpmDefaults = [ - { title: 'Registry', url: '/using-npm/registry', description: 'The JavaScript Package Registry' }, - { title: 'Package spec', url: '/using-npm/package-spec', description: 'Package name specifier' }, - { title: 'Config', url: '/using-npm/config', description: 'About npm configuration' }, - { title: 'Logging', url: '/using-npm/logging', description: 'Why, What & How we Log' }, - { title: 'Scope', url: '/using-npm/scope', description: 'Scoped packages' }, - { title: 'Scripts', url: '/using-npm/scripts', description: 'How npm handles the "scripts" field' }, - { title: 'Workspaces', url: '/using-npm/workspaces', description: 'Working with workspaces' }, - { title: 'Organizations', url: '/using-npm/orgs', description: 'Working with teams & organizations' }, - { title: 'Dependency Selectors', url: '/using-npm/dependency-selectors', description: 'Dependency Selector Syntax & Querying' }, - { title: 'Developers', url: '/using-npm/developers', description: 'Developer guide' }, - { title: 'Removal', url: '/using-npm/removal', description: 'Cleaning the slate' }, + // 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', configuringNpmDefaults) - const usingNpmDocs = await readSectionDocs(contentPath, 'using-npm', usingNpmDefaults) + 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 = [] @@ -149,12 +159,11 @@ const generateNav = async (contentPath, navPath) => { }) } - const prefix = ` -# This is the navigation for the documentation pages; it is not used + const prefix = `# This is the navigation for the documentation pages; it is not used # directly within the CLI documentation. Instead, it will be used # for the https://docs.npmjs.com/ site. ` - await fs.writeFile(navPath, `${prefix}\n\n${yaml.stringify(navData)}`, 'utf-8') + await fs.writeFile(navPath, `${prefix}\n${yaml.stringify(navData, { indent: 2, indentSeq: false })}`, 'utf-8') } // Auto-generate doc templates for commands without docs @@ -265,12 +274,11 @@ ${description} commandsSection.children = [npm, ...others, npx].filter(Boolean) // Write updated nav - const prefix = ` -# This is the navigation for the documentation pages; it is not used + const prefix = `# This is the navigation for the documentation pages; it is not used # directly within the CLI documentation. Instead, it will be used # for the https://docs.npmjs.com/ site. ` - await fs.writeFile(navPath, `${prefix}\n\n${yaml.stringify(navData)}`, 'utf-8') + await fs.writeFile(navPath, `${prefix}\n${yaml.stringify(navData, { indent: 2, indentSeq: false })}`, 'utf-8') } } 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 9f728045ee489..0681b7b77e1b8 100644 --- a/docs/lib/content/nav.yml +++ b/docs/lib/content/nav.yml @@ -1,279 +1,277 @@ - # This is the navigation for the documentation pages; it is not used # directly within the CLI documentation. Instead, it will be used # for the https://docs.npmjs.com/ site. - - title: CLI Commands shortName: Commands url: /commands children: - - title: npm - url: /commands/npm - description: javascript package manager - - title: npm access - url: /commands/npm-access - description: Set access level on published packages - - title: npm adduser - url: /commands/npm-adduser - description: Add a registry user account - - title: npm audit - url: /commands/npm-audit - description: Run a security audit - - title: npm bugs - url: /commands/npm-bugs - description: 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: Clean install a project - - title: npm completion - url: /commands/npm-completion - description: Tab Completion for npm - - title: npm config - url: /commands/npm-config - description: Manage the npm configuration files - - title: npm dedupe - url: /commands/npm-dedupe - description: Reduce duplication in the package tree - - title: npm deprecate - url: /commands/npm-deprecate - description: Deprecate a version of a package - - title: npm diff - url: /commands/npm-diff - description: The registry diff command - - title: npm dist-tag - url: /commands/npm-dist-tag - description: Modify package distribution tags - - title: npm docs - url: /commands/npm-docs - description: Open documentation for a package in a web browser - - title: npm doctor - url: /commands/npm-doctor - 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 a local or remote npm package - - title: npm explain - url: /commands/npm-explain - description: Explain installed packages - - title: npm explore - url: /commands/npm-explore - description: Browse an installed package - - title: npm find-dupes - url: /commands/npm-find-dupes - description: Find duplication in the package tree - - title: npm fund - url: /commands/npm-fund - description: Retrieve funding information - - title: npm get - url: /commands/npm-get - description: Get a value from the npm configuration - - title: npm help - url: /commands/npm-help - description: Get help on npm - - title: npm help-search - url: /commands/npm-help-search - description: Search npm help documentation - - title: npm init - url: /commands/npm-init - description: Create a package.json file - - title: npm install - url: /commands/npm-install - description: Install a package - - title: npm install-ci-test - url: /commands/npm-install-ci-test - description: Install a project with a clean slate and run tests - - title: npm install-test - url: /commands/npm-install-test - description: Install package(s) and run tests - - title: npm link - url: /commands/npm-link - description: Symlink a package folder - - title: npm ll - url: /commands/npm-ll - description: List installed packages - - title: npm login - url: /commands/npm-login - description: Login to a registry user account - - title: npm logout - url: /commands/npm-logout - description: Log out of the registry - - title: npm ls - url: /commands/npm-ls - description: List installed packages - - title: npm org - url: /commands/npm-org - description: Manage orgs - - title: npm outdated - url: /commands/npm-outdated - description: Check for outdated packages - - title: npm owner - url: /commands/npm-owner - description: Manage package owners - - title: npm pack - url: /commands/npm-pack - description: Create a tarball from a package - - title: npm ping - url: /commands/npm-ping - description: Ping npm registry - - title: npm pkg - url: /commands/npm-pkg - description: Manages your package.json - - title: npm prefix - url: /commands/npm-prefix - description: Display prefix - - title: npm profile - url: /commands/npm-profile - description: Change settings on your registry profile - - title: npm prune - url: /commands/npm-prune - description: Remove extraneous packages - - title: npm publish - url: /commands/npm-publish - description: Publish a package - - title: npm query - url: /commands/npm-query - description: Dependency selector query - - title: npm rebuild - url: /commands/npm-rebuild - description: Rebuild a package - - title: npm repo - url: /commands/npm-repo - description: Open package repository page in the browser - - title: npm restart - url: /commands/npm-restart - description: Restart a package - - title: npm root - url: /commands/npm-root - description: Display npm root - - title: npm run - url: /commands/npm-run - description: Run arbitrary package scripts - - title: npm sbom - url: /commands/npm-sbom - description: Generate a Software Bill of Materials (SBOM) - - title: npm search - url: /commands/npm-search - description: Search for packages - - title: npm set - url: /commands/npm-set - description: Set a value in the npm configuration - - title: npm shrinkwrap - url: /commands/npm-shrinkwrap - description: Lock down dependency versions for publication - - title: npm star - url: /commands/npm-star - description: Mark your favorite packages - - title: npm stars - url: /commands/npm-stars - description: View packages marked as favorites - - title: npm start - url: /commands/npm-start - description: Start a package - - title: npm stop - url: /commands/npm-stop - description: Stop a package - - title: npm team - url: /commands/npm-team - description: Manage organization teams and team memberships - - title: npm test - url: /commands/npm-test - description: Test a package - - title: npm token - url: /commands/npm-token - description: Manage your authentication tokens - - title: npm trust - url: /commands/npm-trust - description: Create a trusted relationship between a package and a OIDC provider - - title: npm undeprecate - url: /commands/npm-undeprecate - description: Undeprecate a version of a package - - title: npm uninstall - url: /commands/npm-uninstall - description: Remove a package - - title: npm unpublish - url: /commands/npm-unpublish - description: Remove a package from the registry - - title: npm unstar - url: /commands/npm-unstar - description: Remove an item from your favorite packages - - title: npm update - url: /commands/npm-update - description: Update packages - - title: npm version - url: /commands/npm-version - description: Bump a package version - - title: npm view - url: /commands/npm-view - description: View registry info - - title: npm whoami - url: /commands/npm-whoami - description: Display npm username - - title: npx - url: /commands/npx - description: Run a command from a local or remote npm package + - title: npm + url: /commands/npm + description: javascript package manager + - title: npm access + url: /commands/npm-access + description: Set access level on published packages + - title: npm adduser + url: /commands/npm-adduser + description: Add a registry user account + - title: npm audit + url: /commands/npm-audit + description: Run a security audit + - title: npm bugs + url: /commands/npm-bugs + description: 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: Clean install a project + - title: npm completion + url: /commands/npm-completion + description: Tab Completion for npm + - title: npm config + url: /commands/npm-config + description: Manage the npm configuration files + - title: npm dedupe + url: /commands/npm-dedupe + description: Reduce duplication in the package tree + - title: npm deprecate + url: /commands/npm-deprecate + description: Deprecate a version of a package + - title: npm diff + url: /commands/npm-diff + description: The registry diff command + - title: npm dist-tag + url: /commands/npm-dist-tag + description: Modify package distribution tags + - title: npm docs + url: /commands/npm-docs + description: Open documentation for a package in a web browser + - title: npm doctor + url: /commands/npm-doctor + 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 a local or remote npm package + - title: npm explain + url: /commands/npm-explain + description: Explain installed packages + - title: npm explore + url: /commands/npm-explore + description: Browse an installed package + - title: npm find-dupes + url: /commands/npm-find-dupes + description: Find duplication in the package tree + - title: npm fund + url: /commands/npm-fund + description: Retrieve funding information + - title: npm get + url: /commands/npm-get + description: Get a value from the npm configuration + - title: npm help + url: /commands/npm-help + description: Get help on npm + - title: npm help-search + url: /commands/npm-help-search + description: Search npm help documentation + - title: npm init + url: /commands/npm-init + description: Create a package.json file + - title: npm install + url: /commands/npm-install + description: Install a package + - title: npm install-ci-test + url: /commands/npm-install-ci-test + description: Install a project with a clean slate and run tests + - title: npm install-test + url: /commands/npm-install-test + description: Install package(s) and run tests + - title: npm link + url: /commands/npm-link + description: Symlink a package folder + - title: npm ll + url: /commands/npm-ll + description: List installed packages + - title: npm login + url: /commands/npm-login + description: Login to a registry user account + - title: npm logout + url: /commands/npm-logout + description: Log out of the registry + - title: npm ls + url: /commands/npm-ls + description: List installed packages + - title: npm org + url: /commands/npm-org + description: Manage orgs + - title: npm outdated + url: /commands/npm-outdated + description: Check for outdated packages + - title: npm owner + url: /commands/npm-owner + description: Manage package owners + - title: npm pack + url: /commands/npm-pack + description: Create a tarball from a package + - title: npm ping + url: /commands/npm-ping + description: Ping npm registry + - title: npm pkg + url: /commands/npm-pkg + description: Manages your package.json + - title: npm prefix + url: /commands/npm-prefix + description: Display prefix + - title: npm profile + url: /commands/npm-profile + description: Change settings on your registry profile + - title: npm prune + url: /commands/npm-prune + description: Remove extraneous packages + - title: npm publish + url: /commands/npm-publish + description: Publish a package + - title: npm query + url: /commands/npm-query + description: Dependency selector query + - title: npm rebuild + url: /commands/npm-rebuild + description: Rebuild a package + - title: npm repo + url: /commands/npm-repo + description: Open package repository page in the browser + - title: npm restart + url: /commands/npm-restart + description: Restart a package + - title: npm root + url: /commands/npm-root + description: Display npm root + - title: npm run + url: /commands/npm-run + description: Run arbitrary package scripts + - title: npm sbom + url: /commands/npm-sbom + description: Generate a Software Bill of Materials (SBOM) + - title: npm search + url: /commands/npm-search + description: Search for packages + - title: npm set + url: /commands/npm-set + description: Set a value in the npm configuration + - title: npm shrinkwrap + url: /commands/npm-shrinkwrap + description: Lock down dependency versions for publication + - title: npm star + url: /commands/npm-star + description: Mark your favorite packages + - title: npm stars + url: /commands/npm-stars + description: View packages marked as favorites + - title: npm start + url: /commands/npm-start + description: Start a package + - title: npm stop + url: /commands/npm-stop + description: Stop a package + - title: npm team + url: /commands/npm-team + description: Manage organization teams and team memberships + - title: npm test + url: /commands/npm-test + description: Test a package + - title: npm token + url: /commands/npm-token + description: Manage your authentication tokens + - title: npm trust + url: /commands/npm-trust + description: Create a trusted relationship between a package and a OIDC provider + - title: npm undeprecate + url: /commands/npm-undeprecate + description: Undeprecate a version of a package + - title: npm uninstall + url: /commands/npm-uninstall + description: Remove a package + - title: npm unpublish + url: /commands/npm-unpublish + description: Remove a package from the registry + - title: npm unstar + url: /commands/npm-unstar + description: Remove an item from your favorite packages + - title: npm update + url: /commands/npm-update + description: Update packages + - title: npm version + url: /commands/npm-version + description: Bump a package version + - title: npm view + url: /commands/npm-view + description: View registry info + - title: npm whoami + url: /commands/npm-whoami + description: Display npm username + - title: npx + url: /commands/npx + description: Run a command from a local or remote npm package - title: Configuring npm shortName: Configuring url: /configuring-npm children: - - title: folders - url: /configuring-npm/folders - description: Folder Structures Used by npm - - title: install - url: /configuring-npm/install - description: Download and install node and npm - - title: npm-shrinkwrap.json - url: /configuring-npm/npm-shrinkwrap-json - description: A publishable lockfile - - title: npmrc - url: /configuring-npm/npmrc - description: The npm config files - - title: package.json - url: /configuring-npm/package-json - description: Specifics of npm's package.json handling - - title: package-lock.json - url: /configuring-npm/package-lock-json - description: A manifestation of the manifest + - title: Install + url: /configuring-npm/install + description: Download and install node and npm + - title: Folders + url: /configuring-npm/folders + description: Folder structures used by npm + - title: .npmrc + url: /configuring-npm/npmrc + description: The npm config files + - title: npm-shrinkwrap.json + url: /configuring-npm/npm-shrinkwrap-json + description: A publishable lockfile + - title: package.json + url: /configuring-npm/package-json + description: Specifics of npm's package.json handling + - title: package-lock.json + url: /configuring-npm/package-lock-json + description: A manifestation of the manifest - title: Using npm shortName: Using url: /using-npm children: - - title: config - url: /using-npm/config - description: More than you probably want to know about npm configuration - - title: Dependency Selector Syntax & Querying - url: /using-npm/dependency-selectors - description: Dependency Selector Syntax & Querying - - title: developers - url: /using-npm/developers - description: Developer Guide - - title: Logging - url: /using-npm/logging - description: Why, What & How We Log - - title: orgs - url: /using-npm/orgs - description: Working with Teams & Orgs - - title: package-spec - url: /using-npm/package-spec - description: Package name specifier - - title: registry - url: /using-npm/registry - description: The JavaScript Package Registry - - title: removal - url: /using-npm/removal - description: Cleaning the Slate - - title: scope - url: /using-npm/scope - description: Scoped packages - - title: scripts - url: /using-npm/scripts - description: How npm handles the "scripts" field - - title: workspaces - url: /using-npm/workspaces - description: Working with workspaces + - title: Registry + url: /using-npm/registry + description: The JavaScript Package Registry + - title: Package spec + url: /using-npm/package-spec + description: Package name specifier + - title: Config + url: /using-npm/config + description: About npm configuration + - title: Logging + url: /using-npm/logging + description: Why, What & How we Log + - title: Scope + url: /using-npm/scope + description: Scoped packages + - title: Scripts + url: /using-npm/scripts + description: How npm handles the "scripts" field + - title: Workspaces + url: /using-npm/workspaces + description: Working with workspaces + - title: Organizations + url: /using-npm/orgs + description: Working with teams & organizations + - title: Dependency Selectors + url: /using-npm/dependency-selectors + description: Dependency Selector Syntax & Querying + - title: Developers + url: /using-npm/developers + description: Developer guide + - title: Removal + url: /using-npm/removal + description: Cleaning the slate diff --git a/docs/lib/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/test/index.js b/docs/test/index.js index c5aa93567e52e..699484c7cd818 100644 --- a/docs/test/index.js +++ b/docs/test/index.js @@ -695,247 +695,6 @@ Content here`, // Should use empty string since no description in frontmatter t.equal(noDescEntry.description, '', 'uses empty string when no description') }) - - t.test('readSectionDocs title fallback to defaultEntry.title', async t => { - // This tests line 47: attributes.title || defaultEntry?.title || name - // Specifically the defaultEntry?.title branch when attributes.title is missing - const testDir = t.testdir({ - content: { - commands: { - 'npm.md': `--- -title: npm -section: 1 -description: javascript package manager ----`, - }, - 'configuring-npm': { - // Doc without title in frontmatter - should use defaultEntry.title - 'install.md': `--- -section: 5 -description: Download and install node and npm ---- - -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) - - // Find the configuring-npm section - const configuringSection = navData.find(s => s.title === 'Configuring npm') - t.ok(configuringSection, 'has configuring-npm section') - - const installEntry = configuringSection.children.find(c => c.url === '/configuring-npm/install') - t.ok(installEntry, 'has install entry') - // Should use defaultEntry.title ('Install') since attributes.title is missing - t.equal(installEntry.title, 'Install', 'uses defaultEntry.title when attributes.title is missing') - }) - - t.test('readSectionDocs title fallback to name', async t => { - // This tests line 47: attributes.title || defaultEntry?.title || name - // Specifically the name fallback when both attributes.title and defaultEntry are missing - const testDir = t.testdir({ - content: { - commands: { - 'npm.md': `--- -title: npm -section: 1 -description: javascript package manager ----`, - }, - 'configuring-npm': { - // Doc without title AND no matching defaultEntry (custom-doc is not in defaults) - 'custom-doc.md': `--- -section: 5 -description: A custom document ---- - -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) - - // Find the configuring-npm section - const configuringSection = navData.find(s => s.title === 'Configuring npm') - t.ok(configuringSection, 'has configuring-npm section') - - const customEntry = configuringSection.children.find(c => c.url === '/configuring-npm/custom-doc') - t.ok(customEntry, 'has custom-doc entry') - // Should use name ('custom-doc') since both attributes.title and defaultEntry are missing - t.equal(customEntry.title, 'custom-doc', 'uses name when both attributes.title and defaultEntry are missing') - }) - - t.test('readSectionDocs title uses attributes.title when present', async t => { - // This tests line 47: attributes.title || defaultEntry?.title || name - // Specifically when attributes.title IS present (first branch) - const testDir = t.testdir({ - content: { - commands: { - 'npm.md': `--- -title: npm -section: 1 -description: javascript package manager ----`, - }, - 'configuring-npm': { - // Doc WITH title in frontmatter - should use attributes.title - 'install.md': `--- -title: Custom Install Title -section: 5 -description: Download and install node and npm ---- - -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) - - // Find the configuring-npm section - const configuringSection = navData.find(s => s.title === 'Configuring npm') - t.ok(configuringSection, 'has configuring-npm section') - - const installEntry = configuringSection.children.find(c => c.url === '/configuring-npm/install') - t.ok(installEntry, 'has install entry') - // Should use attributes.title since it's present - t.equal(installEntry.title, 'Custom Install Title', 'uses attributes.title when present') - }) - - t.test('using-npm section title fallbacks', async t => { - // Test using-npm section with the same three branches - const testDir = t.testdir({ - content: { - commands: { - 'npm.md': `--- -title: npm -section: 1 -description: javascript package manager ----`, - }, - 'using-npm': { - // Doc with title in frontmatter - 'registry.md': `--- -title: Custom Registry Title -section: 7 -description: The JavaScript Package Registry ----`, - // Doc without title, should use defaultEntry.title - 'scope.md': `--- -section: 7 -description: Scoped packages ----`, - // Doc without title and not in defaults, should use name - 'unknown-topic.md': `--- -section: 7 -description: An unknown topic ----`, - }, - }, - '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 usingSection = navData.find(s => s.title === 'Using npm') - t.ok(usingSection, 'has using-npm section') - - const registryEntry = usingSection.children.find(c => c.url === '/using-npm/registry') - t.equal(registryEntry.title, 'Custom Registry Title', 'uses attributes.title for registry') - - const scopeEntry = usingSection.children.find(c => c.url === '/using-npm/scope') - t.equal(scopeEntry.title, 'Scope', 'uses defaultEntry.title for scope') - - const unknownEntry = usingSection.children.find(c => c.url === '/using-npm/unknown-topic') - t.equal(unknownEntry.title, 'unknown-topic', 'uses name for unknown-topic') - }) - - t.test('description fallback branches', async t => { - // Tests line 49: description: attributes.description || defaultEntry?.description || '' - const testDir = t.testdir({ - content: { - commands: { - 'npm.md': `--- -title: npm -section: 1 -description: javascript package manager ----`, - }, - 'configuring-npm': { - // Doc WITH description in frontmatter - uses attributes.description - 'install.md': `--- -title: Install -section: 5 -description: Custom install description from frontmatter ----`, - // Doc WITHOUT description, but matches defaultEntry - uses defaultEntry.description - 'folders.md': `--- -title: Folders -section: 5 ----`, - // Doc WITHOUT description AND no matching defaultEntry - uses empty string - 'custom-no-desc.md': `--- -title: Custom Doc -section: 5 ----`, - }, - }, - '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 configuringSection = navData.find(s => s.title === 'Configuring npm') - t.ok(configuringSection, 'has configuring-npm section') - - // Branch 1: attributes.description is present - const installEntry = configuringSection.children.find(c => c.url === '/configuring-npm/install') - t.equal(installEntry.description, 'Custom install description from frontmatter', 'uses attributes.description when present') - - // Branch 2: defaultEntry?.description (folders is in defaults) - const foldersEntry = configuringSection.children.find(c => c.url === '/configuring-npm/folders') - t.equal(foldersEntry.description, 'Folder structures used by npm', 'uses defaultEntry.description when attributes.description is missing') - - // Branch 3: empty string fallback (custom-no-desc is not in defaults) - const customEntry = configuringSection.children.find(c => c.url === '/configuring-npm/custom-no-desc') - t.equal(customEntry.description, '', 'uses empty string when both attributes.description and defaultEntry are missing') - }) }) t.test('replaceParams with name edge cases', async t => { From f0b6373fcef83342c22d46855ea63fe2c2680d28 Mon Sep 17 00:00:00 2001 From: reggi Date: Mon, 12 Jan 2026 15:26:15 -0500 Subject: [PATCH 07/28] chore: fix codeql warning --- test/lib/trust-cmd.js | 56 +++++++++++++++++++++---------------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/test/lib/trust-cmd.js b/test/lib/trust-cmd.js index ed8719bcc36e6..f12f44efe3928 100644 --- a/test/lib/trust-cmd.js +++ b/test/lib/trust-cmd.js @@ -32,7 +32,7 @@ t.test('trust-cmd via trust github with read function called', async t => { registry.trustCreate({ packageName }) - await npm.exec('trust', ['github', packageName, '--file', 'workflow.yml', '--repository', 'owner/repo']) + await npm.exec('trust', ['github', packageName, '--file', 'workflow.yml', '--repository', 'npm/cli']) }) t.test('trust-cmd via trust github with all options', async t => { @@ -57,7 +57,7 @@ t.test('trust-cmd via trust github with all options', async t => { registry.trustCreate({ packageName }) - await npm.exec('trust', ['github', packageName, '--file', 'workflow.yml', '--repository', 'owner/repo', '--environment', 'production']) + 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 => { @@ -66,7 +66,7 @@ t.test('trust-cmd via trust github infers from package.json', async t => { 'package.json': JSON.stringify({ name: packageName, version: '1.0.0', - repository: 'https://github.com/owner/repo', + repository: 'https://github.com/npm/cli', }), }, config: { @@ -108,7 +108,7 @@ t.test('trust-cmd via trust github with dry-run', async t => { }, }) - await npm.exec('trust', ['github', packageName, '--file', 'workflow.yml', '--repository', 'owner/repo']) + await npm.exec('trust', ['github', packageName, '--file', 'workflow.yml', '--repository', 'npm/cli']) t.ok(logs.notice.some(l => l.includes('Establishing trust')), 'shows notice about establishing trust') }) @@ -122,7 +122,7 @@ t.test('trust-cmd via trust github missing package name', async t => { }) await t.rejects( - npm.exec('trust', ['github', '--file', 'workflow.yml', '--repository', 'owner/repo']), + npm.exec('trust', ['github', '--file', 'workflow.yml', '--repository', 'npm/cli']), { message: /Package name must be specified/ }, 'throws when no package name' ) @@ -141,7 +141,7 @@ t.test('trust-cmd via trust github missing file', async t => { }) await t.rejects( - npm.exec('trust', ['github', packageName, '--repository', 'owner/repo']), + npm.exec('trust', ['github', packageName, '--repository', 'npm/cli']), { message: /must be specified with the file option/ }, 'throws when no file' ) @@ -160,7 +160,7 @@ t.test('trust-cmd via trust github invalid file extension', async t => { }) await t.rejects( - npm.exec('trust', ['github', packageName, '--file', 'workflow.txt', '--repository', 'owner/repo']), + npm.exec('trust', ['github', packageName, '--file', 'workflow.txt', '--repository', 'npm/cli']), { message: /must end in \.yml or \.yaml/ }, 'throws when file has wrong extension' ) @@ -200,7 +200,7 @@ t.test('trust-cmd via trust github with custom registry warning', async t => { }, }) - await npm.exec('trust', ['github', packageName, '--file', 'workflow.yml', '--repository', 'owner/repo']) + 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') }) @@ -220,7 +220,7 @@ t.test('trust-cmd via trust github with --json', async t => { }, }) - await npm.exec('trust', ['github', packageName, '--file', 'workflow.yml', '--repository', 'owner/repo']) + 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') @@ -250,7 +250,7 @@ t.test('trust-cmd via trust github with user confirmation no', async t => { }) await t.rejects( - npm.exec('trust', ['github', packageName, '--file', 'workflow.yml', '--repository', 'owner/repo']), + npm.exec('trust', ['github', packageName, '--file', 'workflow.yml', '--repository', 'npm/cli']), { message: 'User cancelled operation' }, 'throws when user declines' ) @@ -271,7 +271,7 @@ t.test('trust-cmd via trust github with --no-yes', async t => { }) await t.rejects( - npm.exec('trust', ['github', packageName, '--file', 'workflow.yml', '--repository', 'owner/repo']), + npm.exec('trust', ['github', packageName, '--file', 'workflow.yml', '--repository', 'npm/cli']), { message: 'User cancelled operation' }, 'throws when --no-yes flag is set' ) @@ -300,7 +300,7 @@ t.test('trust-cmd via trust github with invalid answer', async t => { }) await t.rejects( - npm.exec('trust', ['github', packageName, '--file', 'workflow.yml', '--repository', 'owner/repo']), + npm.exec('trust', ['github', packageName, '--file', 'workflow.yml', '--repository', 'npm/cli']), { message: 'User cancelled operation' }, 'throws when user gives invalid answer' ) @@ -336,7 +336,7 @@ t.test('trust-cmd via trust github with user confirmation Y uppercase', async t registry.trustCreate({ packageName }) - await npm.exec('trust', ['github', packageName, '--file', 'workflow.yml', '--repository', 'owner/repo']) + 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 => { @@ -362,7 +362,7 @@ t.test('trust-cmd via trust github with user enters empty string', async t => { }) await t.rejects( - npm.exec('trust', ['github', packageName, '--file', 'workflow.yml', '--repository', 'owner/repo']), + npm.exec('trust', ['github', packageName, '--file', 'workflow.yml', '--repository', 'npm/cli']), { message: 'User cancelled operation' }, 'throws when user enters empty string' ) @@ -374,7 +374,7 @@ t.test('trust-cmd via trust github with mismatched repo type', async t => { 'package.json': JSON.stringify({ name: packageName, version: '1.0.0', - repository: 'https://gitlab.com/owner/repo', + repository: 'https://gitlab.com/npm/cli', }), }, config: { @@ -459,7 +459,7 @@ t.test('trust-cmd via trust github with user confirmation yes spelled out', asyn registry.trustCreate({ packageName }) - await npm.exec('trust', ['github', packageName, '--file', 'workflow.yml', '--repository', 'owner/repo']) + 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 => { @@ -492,7 +492,7 @@ t.test('trust-cmd via trust github showing response with id and type', async t = id: 'config-id-123', type: 'github', claims: { - repository: 'owner/repo', + repository: 'npm/cli', workflow_ref: { file: 'workflow.yml', }, @@ -500,7 +500,7 @@ t.test('trust-cmd via trust github showing response with id and type', async t = }, }) - await npm.exec('trust', ['github', packageName, '--file', 'workflow.yml', '--repository', 'owner/repo']) + await npm.exec('trust', ['github', packageName, '--file', 'workflow.yml', '--repository', 'npm/cli']) const output = joinedOutput() t.ok(output.includes('type:'), 'output shows type field') @@ -586,7 +586,7 @@ t.test('trust-cmd via trust github showing fromPackageJson indicator', async t = 'package.json': JSON.stringify({ name: packageName, version: '1.0.0', - repository: 'https://github.com/owner/repo', + repository: 'https://github.com/npm/cli', }), }, config: { @@ -611,7 +611,7 @@ t.test('trust-cmd via trust github showing fromPackageJson indicator', async t = id: 'config-id-123', type: 'github', claims: { - repository: 'owner/repo', + repository: 'npm/cli', workflow_ref: { file: 'workflow.yml', }, @@ -655,7 +655,7 @@ t.test('trust-cmd via trust github showing URLs for fields', async t => { id: 'config-id-123', type: 'github', claims: { - repository: 'owner/repo', + repository: 'npm/cli', workflow_ref: { file: 'workflow.yml', }, @@ -663,10 +663,10 @@ t.test('trust-cmd via trust github showing URLs for fields', async t => { }, }) - await npm.exec('trust', ['github', packageName, '--file', 'workflow.yml', '--repository', 'owner/repo']) + await npm.exec('trust', ['github', packageName, '--file', 'workflow.yml', '--repository', 'npm/cli']) const output = joinedOutput() - t.ok(output.includes('https://github.com/owner/repo'), 'output shows repository URL') + 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 => { @@ -684,7 +684,7 @@ t.test('trust-cmd via trust github with yes=false flag', async t => { }) await t.rejects( - npm.exec('trust', ['github', packageName, '--file', 'workflow.yml', '--repository', 'owner/repo']), + npm.exec('trust', ['github', packageName, '--file', 'workflow.yml', '--repository', 'npm/cli']), { message: /User cancelled operation/ }, 'throws when yes is explicitly false' ) @@ -769,18 +769,18 @@ t.test('TrustCommand - logOptions with fromPackageJson and urls', async t => { values: { type: 'github', id: 'test-id', - repository: 'owner/repo', + repository: 'npm/cli', file: 'workflow.yml', }, fromPackageJson: { repository: true, }, urls: { - repository: 'https://github.com/owner/repo', - file: 'https://github.com/owner/repo/-/blob/HEAD/workflow.yml', + 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.ok(output.includes('https://github.com/owner/repo'), 'shows URL') + t.match(output, /https:\/\/github\.com\/npm\/cli\b/, 'shows URL') }) From c0315dd3e867dc247e8cb5f07c243cd2971d7a1d Mon Sep 17 00:00:00 2001 From: reggi Date: Mon, 12 Jan 2026 16:26:45 -0500 Subject: [PATCH 08/28] fix: remove flag warnings at command level --- lib/base-cmd.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/lib/base-cmd.js b/lib/base-cmd.js index 9234ccc632a82..cce44a7e87db5 100644 --- a/lib/base-cmd.js +++ b/lib/base-cmd.js @@ -397,6 +397,17 @@ class BaseCommand { throw new Error(`Unknown flag${unknownFlags.length > 1 ? 's' : ''}: ${flagList}`) } + // Remove warnings for command-specific definitions that npm's global config + // doesn't know about (these were queued as "unknown" during config.load()) + for (const def of Object.values(commandDefinitions)) { + this.npm.config.removeWarning(def.key) + if (def.alias && Array.isArray(def.alias)) { + for (const alias of def.alias) { + this.npm.config.removeWarning(alias) + } + } + } + this.npm.config.logWarnings() } From 105268edb4a64130f50de673a4dd3be74563cf95 Mon Sep 17 00:00:00 2001 From: reggi Date: Mon, 12 Jan 2026 17:36:05 -0500 Subject: [PATCH 09/28] fix: positionals --- lib/base-cmd.js | 25 +- lib/subcommands/trust-github.js | 1 + lib/subcommands/trust-gitlab.js | 1 + lib/subcommands/trust-list.js | 1 + lib/subcommands/trust-revoke.js | 1 + .../test/lib/commands/install.js.test.cjs | 6 +- test/lib/base-cmd.js | 216 ++++++++++++++++++ workspaces/config/lib/index.js | 12 +- 8 files changed, 257 insertions(+), 6 deletions(-) diff --git a/lib/base-cmd.js b/lib/base-cmd.js index cce44a7e87db5..ab089ff18c294 100644 --- a/lib/base-cmd.js +++ b/lib/base-cmd.js @@ -14,6 +14,8 @@ class BaseCommand { 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 =rom it without instantiating a command // which would require loading the config @@ -327,7 +329,7 @@ class BaseCommand { // Validate flags - only if command has definitions (new system) if (this.constructor.definitions && Object.keys(this.constructor.definitions).length > 0) { - this.#validateFlags(parsed, commandDefinitions) + this.#validateFlags(parsed, commandDefinitions, remains) } // Check for conflicts between main flags and their aliases @@ -366,7 +368,7 @@ class BaseCommand { } // Validate flags and throw errors for unknown flags or unexpected positionals - #validateFlags (parsed, commandDefinitions) { + #validateFlags (parsed, commandDefinitions, remains) { // Build a set of all valid flag names (global + command-specific + shorthands) const validFlags = new Set([ ...Object.keys(definitions), @@ -408,6 +410,25 @@ class BaseCommand { } } + // 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() } diff --git a/lib/subcommands/trust-github.js b/lib/subcommands/trust-github.js index d2a8e8f8f9b4a..601dff3c480c0 100644 --- a/lib/subcommands/trust-github.js +++ b/lib/subcommands/trust-github.js @@ -6,6 +6,7 @@ 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' diff --git a/lib/subcommands/trust-gitlab.js b/lib/subcommands/trust-gitlab.js index c1ad2fe0f9a51..381273098742a 100644 --- a/lib/subcommands/trust-gitlab.js +++ b/lib/subcommands/trust-gitlab.js @@ -6,6 +6,7 @@ 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' diff --git a/lib/subcommands/trust-list.js b/lib/subcommands/trust-list.js index 904c7fab14feb..f89b32fde303f 100644 --- a/lib/subcommands/trust-list.js +++ b/lib/subcommands/trust-list.js @@ -20,6 +20,7 @@ function bodyToOptions (body) { class TrustList extends TrustCommand { static description = 'List trusted relationships for a package' static name = 'list' + static positionals = 1 // expects at most 1 positional (package name) static usage = [ '[package]', diff --git a/lib/subcommands/trust-revoke.js b/lib/subcommands/trust-revoke.js index 35ac2c258c979..95dbec2b901d7 100644 --- a/lib/subcommands/trust-revoke.js +++ b/lib/subcommands/trust-revoke.js @@ -10,6 +10,7 @@ const { log } = require('proc-log') class TrustRevoke extends BaseCommand { 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=', diff --git a/tap-snapshots/test/lib/commands/install.js.test.cjs b/tap-snapshots/test/lib/commands/install.js.test.cjs index 061eaa53cc016..30331b77af13b 100644 --- a/tap-snapshots/test/lib/commands/install.js.test.cjs +++ b/tap-snapshots/test/lib/commands/install.js.test.cjs @@ -134,7 +134,7 @@ 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:249:27) +verbose stack at Install.checkDevEngines ({CWD}/lib/base-cmd.js:251:27) verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:312:7) verbose stack at MockNpm.exec ({CWD}/lib/npm.js:211:9) error code EBADDEVENGINES @@ -199,7 +199,7 @@ 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:249:27) +verbose stack at Install.checkDevEngines ({CWD}/lib/base-cmd.js:251:27) verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:312:7) verbose stack at MockNpm.exec ({CWD}/lib/npm.js:211:9) error code EBADDEVENGINES @@ -225,7 +225,7 @@ 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:249:27) +verbose stack at Install.checkDevEngines ({CWD}/lib/base-cmd.js:251:27) verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:312:7) verbose stack at MockNpm.exec ({CWD}/lib/npm.js:211:9) error code EBADDEVENGINES diff --git a/test/lib/base-cmd.js b/test/lib/base-cmd.js index 95a30dc847a0c..1d4b77c2539f3 100644 --- a/test/lib/base-cmd.js +++ b/test/lib/base-cmd.js @@ -460,3 +460,219 @@ t.test('base exec() method returns undefined', async t => { 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/workspaces/config/lib/index.js b/workspaces/config/lib/index.js index 18d0db92938ce..bcddfc3cc70ce 100644 --- a/workspaces/config/lib/index.js +++ b/workspaces/config/lib/index.js @@ -540,7 +540,7 @@ class Config { unknownHandler (key, next) { if (next) { - this.queueWarning('unknown', `"${next}" is being parsed as a normal command line argument.`) + this.queueWarning(`unknown:${next}`, `"${next}" is being parsed as a normal command line argument.`) } } @@ -923,6 +923,16 @@ class Config { 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 }) From c7b07f916869f17c8735dab2554327719fbfc2a2 Mon Sep 17 00:00:00 2001 From: reggi Date: Mon, 12 Jan 2026 17:43:47 -0500 Subject: [PATCH 10/28] chore: config tests --- workspaces/config/test/index.js | 37 +++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/workspaces/config/test/index.js b/workspaces/config/test/index.js index bb1da6b6bedc2..f1803bb28a505 100644 --- a/workspaces/config/test/index.js +++ b/workspaces/config/test/index.js @@ -1739,3 +1739,40 @@ t.test('valid getter with invalid config', async t => { 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') +}) From 598cf3a724543f7f868bd83ae3420e2b51326035 Mon Sep 17 00:00:00 2001 From: reggi Date: Tue, 13 Jan 2026 13:49:05 -0500 Subject: [PATCH 11/28] chore: move trust subcommand to folder --- lib/commands/{trust.js => trust/index.js} | 10 +++---- .../trust}/trust-github.js | 2 +- .../trust}/trust-gitlab.js | 2 +- .../trust}/trust-list.js | 4 +-- .../trust}/trust-revoke.js | 4 +-- lib/npm.js | 2 +- .../trust/github.js} | 12 ++++---- .../trust/gitlab.js} | 12 ++++---- .../trust-list.js => commands/trust/list.js} | 18 ++++++------ .../trust/revoke.js} | 28 +++++++++---------- test/lib/docs.js | 16 +++++++++-- 11 files changed, 61 insertions(+), 49 deletions(-) rename lib/commands/{trust.js => trust/index.js} (59%) rename lib/{subcommands => commands/trust}/trust-github.js (98%) rename lib/{subcommands => commands/trust}/trust-gitlab.js (98%) rename lib/{subcommands => commands/trust}/trust-list.js (94%) rename lib/{subcommands => commands/trust}/trust-revoke.js (94%) rename test/lib/{subcommands/trust-github.js => commands/trust/github.js} (90%) rename test/lib/{subcommands/trust-gitlab.js => commands/trust/gitlab.js} (90%) rename test/lib/{subcommands/trust-list.js => commands/trust/list.js} (89%) rename test/lib/{subcommands/trust-revoke.js => commands/trust/revoke.js} (88%) diff --git a/lib/commands/trust.js b/lib/commands/trust/index.js similarity index 59% rename from lib/commands/trust.js rename to lib/commands/trust/index.js index 8db2dc34f31d2..3c9b3dd06dfb4 100644 --- a/lib/commands/trust.js +++ b/lib/commands/trust/index.js @@ -1,14 +1,14 @@ -const BaseCommand = require('../base-cmd.js') +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('../subcommands/trust-github.js'), - gitlab: require('../subcommands/trust-gitlab.js'), - list: require('../subcommands/trust-list.js'), - revoke: require('../subcommands/trust-revoke.js'), + github: require('./trust-github.js'), + gitlab: require('./trust-gitlab.js'), + list: require('./trust-list.js'), + revoke: require('./trust-revoke.js'), } static async completion (opts) { diff --git a/lib/subcommands/trust-github.js b/lib/commands/trust/trust-github.js similarity index 98% rename from lib/subcommands/trust-github.js rename to lib/commands/trust/trust-github.js index 601dff3c480c0..7ebc8443cc191 100644 --- a/lib/subcommands/trust-github.js +++ b/lib/commands/trust/trust-github.js @@ -1,6 +1,6 @@ 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 TrustCommand = require('../../trust-cmd.js') const path = require('node:path') class TrustGitHub extends TrustCommand { diff --git a/lib/subcommands/trust-gitlab.js b/lib/commands/trust/trust-gitlab.js similarity index 98% rename from lib/subcommands/trust-gitlab.js rename to lib/commands/trust/trust-gitlab.js index 381273098742a..c8e5c6e76e9b9 100644 --- a/lib/subcommands/trust-gitlab.js +++ b/lib/commands/trust/trust-gitlab.js @@ -1,6 +1,6 @@ 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 TrustCommand = require('../../trust-cmd.js') const path = require('node:path') class TrustGitLab extends TrustCommand { diff --git a/lib/subcommands/trust-list.js b/lib/commands/trust/trust-list.js similarity index 94% rename from lib/subcommands/trust-list.js rename to lib/commands/trust/trust-list.js index f89b32fde303f..267d6f86ef1b8 100644 --- a/lib/subcommands/trust-list.js +++ b/lib/commands/trust/trust-list.js @@ -1,10 +1,10 @@ const pkgJson = require('@npmcli/package-json') -const { otplease } = require('../utils/auth.js') +const { otplease } = require('../../utils/auth.js') const npmFetch = require('npm-registry-fetch') const npa = require('npm-package-arg') const TrustGithub = require('./trust-github.js') const TrustGitlab = require('./trust-gitlab.js') -const TrustCommand = require('../trust-cmd.js') +const TrustCommand = require('../../trust-cmd.js') const globalDefinitions = require('@npmcli/config/lib/definitions/definitions.js') // Convert trust config body to options object diff --git a/lib/subcommands/trust-revoke.js b/lib/commands/trust/trust-revoke.js similarity index 94% rename from lib/subcommands/trust-revoke.js rename to lib/commands/trust/trust-revoke.js index 95dbec2b901d7..d2dadf54cdefb 100644 --- a/lib/subcommands/trust-revoke.js +++ b/lib/commands/trust/trust-revoke.js @@ -1,9 +1,9 @@ const globalDefinitions = require('@npmcli/config/lib/definitions/definitions.js') const Definition = require('@npmcli/config/lib/definitions/definition.js') const pkgJson = require('@npmcli/package-json') -const { otplease } = require('../utils/auth.js') +const { otplease } = require('../../utils/auth.js') const npmFetch = require('npm-registry-fetch') -const BaseCommand = require('../base-cmd.js') +const BaseCommand = require('../../base-cmd.js') const npa = require('npm-package-arg') const { log } = require('proc-log') diff --git a/lib/npm.js b/lib/npm.js index d0a78844fa3cf..355fb4c7467c5 100644 --- a/lib/npm.js +++ b/lib/npm.js @@ -26,7 +26,7 @@ class Npm { command: c, }) } - return require(`./commands/${command}.js`) + return require(`./commands/${command}`) } unrefPromises = [] diff --git a/test/lib/subcommands/trust-github.js b/test/lib/commands/trust/github.js similarity index 90% rename from test/lib/subcommands/trust-github.js rename to test/lib/commands/trust/github.js index 04e25a36392a5..8a66d9de32df2 100644 --- a/test/lib/subcommands/trust-github.js +++ b/test/lib/commands/trust/github.js @@ -1,11 +1,11 @@ const t = require('tap') -const { load: loadMockNpm } = require('../../fixtures/mock-npm.js') +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('trust-github with all options provided', async t => { +t.test('github with all options provided', async t => { const { npm } = await loadMockNpm(t, { prefixDir: { 'package.json': JSON.stringify({ @@ -38,7 +38,7 @@ t.test('trust-github with all options provided', async t => { await npm.exec('trust', ['github', packageName, '--yes', '--file', 'workflow.yml', '--repository', 'owner/repo', '--environment', 'production']) }) -t.test('trust-github with invalid repository format', async t => { +t.test('github with invalid repository format', async t => { const { npm } = await loadMockNpm(t, { prefixDir: { 'package.json': JSON.stringify({ @@ -66,7 +66,7 @@ t.test('trust-github with invalid repository format', async t => { ) }) -t.test('trust-github with file as path', async t => { +t.test('github with file as path', async t => { const { npm } = await loadMockNpm(t, { prefixDir: { 'package.json': JSON.stringify({ @@ -94,7 +94,7 @@ t.test('trust-github with file as path', async t => { ) }) -t.test('trust-github without environment', async t => { +t.test('github without environment', async t => { const { npm } = await loadMockNpm(t, { prefixDir: { 'package.json': JSON.stringify({ @@ -128,7 +128,7 @@ t.test('trust-github without environment', async t => { }) t.test('bodyToOptions with all fields', t => { - const TrustGitHub = require('../../../lib/subcommands/trust-github.js') + const TrustGitHub = require('../../../../lib/commands/trust/trust-github.js') const body = { id: 'test-id', diff --git a/test/lib/subcommands/trust-gitlab.js b/test/lib/commands/trust/gitlab.js similarity index 90% rename from test/lib/subcommands/trust-gitlab.js rename to test/lib/commands/trust/gitlab.js index 85be57a6fa987..3b8e81411e7e5 100644 --- a/test/lib/subcommands/trust-gitlab.js +++ b/test/lib/commands/trust/gitlab.js @@ -1,11 +1,11 @@ const t = require('tap') -const { load: loadMockNpm } = require('../../fixtures/mock-npm.js') +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('trust-gitlab with all options provided', async t => { +t.test('gitlab with all options provided', async t => { const { npm } = await loadMockNpm(t, { prefixDir: { 'package.json': JSON.stringify({ @@ -38,7 +38,7 @@ t.test('trust-gitlab with all options provided', async t => { await npm.exec('trust', ['gitlab', packageName, '--yes', '--file', '.gitlab-ci.yml', '--project', 'group/subgroup/repo', '--environment', 'production']) }) -t.test('trust-gitlab with invalid project format', async t => { +t.test('gitlab with invalid project format', async t => { const { npm } = await loadMockNpm(t, { prefixDir: { 'package.json': JSON.stringify({ @@ -66,7 +66,7 @@ t.test('trust-gitlab with invalid project format', async t => { ) }) -t.test('trust-gitlab with file as path', async t => { +t.test('gitlab with file as path', async t => { const { npm } = await loadMockNpm(t, { prefixDir: { 'package.json': JSON.stringify({ @@ -94,7 +94,7 @@ t.test('trust-gitlab with file as path', async t => { ) }) -t.test('trust-gitlab without environment', async t => { +t.test('gitlab without environment', async t => { const { npm } = await loadMockNpm(t, { prefixDir: { 'package.json': JSON.stringify({ @@ -128,7 +128,7 @@ t.test('trust-gitlab without environment', async t => { }) t.test('bodyToOptions with all fields', t => { - const TrustGitLab = require('../../../lib/subcommands/trust-gitlab.js') + const TrustGitLab = require('../../../../lib/commands/trust/trust-gitlab.js') const body = { id: 'test-id', diff --git a/test/lib/subcommands/trust-list.js b/test/lib/commands/trust/list.js similarity index 89% rename from test/lib/subcommands/trust-list.js rename to test/lib/commands/trust/list.js index 8a4d0d3de38ad..bd979abfff112 100644 --- a/test/lib/subcommands/trust-list.js +++ b/test/lib/commands/trust/list.js @@ -1,10 +1,10 @@ const t = require('tap') -const { load: loadMockNpm } = require('../../fixtures/mock-npm.js') +const { load: loadMockNpm } = require('../../../fixtures/mock-npm.js') const MockRegistry = require('@npmcli/mock-registry') const packageName = '@npmcli/test-package' -t.test('trust-list with package name argument', async t => { +t.test('list with package name argument', async t => { const { npm } = await loadMockNpm(t, { prefixDir: { 'package.json': JSON.stringify({ @@ -51,7 +51,7 @@ t.test('trust-list with package name argument', async t => { await npm.exec('trust', ['list', packageName]) }) -t.test('trust-list without package name (uses package.json)', async t => { +t.test('list without package name (uses package.json)', async t => { const { npm } = await loadMockNpm(t, { prefixDir: { 'package.json': JSON.stringify({ @@ -88,7 +88,7 @@ t.test('trust-list without package name (uses package.json)', async t => { await npm.exec('trust', ['list']) }) -t.test('trust-list with no trust configurations', async t => { +t.test('list with no trust configurations', async t => { const { npm } = await loadMockNpm(t, { prefixDir: { 'package.json': JSON.stringify({ @@ -112,7 +112,7 @@ t.test('trust-list with no trust configurations', async t => { await npm.exec('trust', ['list', packageName]) }) -t.test('trust-list without package name and no package.json', async t => { +t.test('list without package name and no package.json', async t => { const { npm } = await loadMockNpm(t, { prefixDir: {}, config: { @@ -126,7 +126,7 @@ t.test('trust-list without package name and no package.json', async t => { ) }) -t.test('trust-list without package name and no name in package.json', async t => { +t.test('list without package name and no name in package.json', async t => { const { npm } = await loadMockNpm(t, { prefixDir: { 'package.json': JSON.stringify({ @@ -144,7 +144,7 @@ t.test('trust-list without package name and no name in package.json', async t => ) }) -t.test('trust-list with --json flag', async t => { +t.test('list with --json flag', async t => { const { npm } = await loadMockNpm(t, { prefixDir: { 'package.json': JSON.stringify({ @@ -183,7 +183,7 @@ t.test('trust-list with --json flag', async t => { await npm.exec('trust', ['list', packageName]) }) -t.test('trust-list with scoped package', async t => { +t.test('list with scoped package', async t => { const scopedPackage = '@scope/package' const { npm } = await loadMockNpm(t, { prefixDir: { @@ -221,7 +221,7 @@ t.test('trust-list with scoped package', async t => { await npm.exec('trust', ['list', scopedPackage]) }) -t.test('trust-list with unknown trust type', async t => { +t.test('list with unknown trust type', async t => { const { npm } = await loadMockNpm(t, { prefixDir: { 'package.json': JSON.stringify({ diff --git a/test/lib/subcommands/trust-revoke.js b/test/lib/commands/trust/revoke.js similarity index 88% rename from test/lib/subcommands/trust-revoke.js rename to test/lib/commands/trust/revoke.js index beee7898e157e..290fc61897725 100644 --- a/test/lib/subcommands/trust-revoke.js +++ b/test/lib/commands/trust/revoke.js @@ -1,10 +1,10 @@ const t = require('tap') -const { load: loadMockNpm } = require('../../fixtures/mock-npm.js') +const { load: loadMockNpm } = require('../../../fixtures/mock-npm.js') const MockRegistry = require('@npmcli/mock-registry') const packageName = '@npmcli/test-package' -t.test('trust-revoke with package name argument and id', async t => { +t.test('revoke with package name argument and id', async t => { const { npm } = await loadMockNpm(t, { prefixDir: { 'package.json': JSON.stringify({ @@ -29,7 +29,7 @@ t.test('trust-revoke with package name argument and id', async t => { await npm.exec('trust', ['revoke', packageName, '--id', trustId]) }) -t.test('trust-revoke without package name (uses package.json)', async t => { +t.test('revoke without package name (uses package.json)', async t => { const { npm } = await loadMockNpm(t, { prefixDir: { 'package.json': JSON.stringify({ @@ -54,7 +54,7 @@ t.test('trust-revoke without package name (uses package.json)', async t => { await npm.exec('trust', ['revoke', '--id', trustId]) }) -t.test('trust-revoke with dry-run flag', async t => { +t.test('revoke with dry-run flag', async t => { const { npm } = await loadMockNpm(t, { prefixDir: { 'package.json': JSON.stringify({ @@ -74,7 +74,7 @@ t.test('trust-revoke with dry-run flag', async t => { await npm.exec('trust', ['revoke', packageName, '--id', trustId]) }) -t.test('trust-revoke without package name and no package.json', async t => { +t.test('revoke without package name and no package.json', async t => { const { npm } = await loadMockNpm(t, { prefixDir: {}, config: { @@ -88,7 +88,7 @@ t.test('trust-revoke without package name and no package.json', async t => { ) }) -t.test('trust-revoke without package name and no name in package.json', async t => { +t.test('revoke without package name and no name in package.json', async t => { const { npm } = await loadMockNpm(t, { prefixDir: { 'package.json': JSON.stringify({ @@ -106,7 +106,7 @@ t.test('trust-revoke without package name and no name in package.json', async t ) }) -t.test('trust-revoke without id flag', async t => { +t.test('revoke without id flag', async t => { const { npm } = await loadMockNpm(t, { prefixDir: { 'package.json': JSON.stringify({ @@ -125,7 +125,7 @@ t.test('trust-revoke without id flag', async t => { ) }) -t.test('trust-revoke with scoped package', async t => { +t.test('revoke with scoped package', async t => { const scopedPackage = '@scope/package' const { npm } = await loadMockNpm(t, { prefixDir: { @@ -151,7 +151,7 @@ t.test('trust-revoke with scoped package', async t => { await npm.exec('trust', ['revoke', scopedPackage, '--id', trustId]) }) -t.test('trust-revoke with special characters in id', async t => { +t.test('revoke with special characters in id', async t => { const { npm } = await loadMockNpm(t, { prefixDir: { 'package.json': JSON.stringify({ @@ -176,7 +176,7 @@ t.test('trust-revoke with special characters in id', async t => { await npm.exec('trust', ['revoke', packageName, '--id', trustId]) }) -t.test('trust-revoke with 404 response', async t => { +t.test('revoke with 404 response', async t => { const { npm } = await loadMockNpm(t, { prefixDir: { 'package.json': JSON.stringify({ @@ -209,7 +209,7 @@ t.test('trust-revoke with 404 response', async t => { ) }) -t.test('trust-revoke with 500 response', async t => { +t.test('revoke with 500 response', async t => { const { npm } = await loadMockNpm(t, { prefixDir: { 'package.json': JSON.stringify({ @@ -242,7 +242,7 @@ t.test('trust-revoke with 500 response', async t => { ) }) -t.test('trust-revoke with unscoped package name', async t => { +t.test('revoke with unscoped package name', async t => { const unscopedPackage = 'test-package' const { npm } = await loadMockNpm(t, { prefixDir: { @@ -268,7 +268,7 @@ t.test('trust-revoke with unscoped package name', async t => { await npm.exec('trust', ['revoke', unscopedPackage, '--id', trustId]) }) -t.test('trust-revoke with very long id', async t => { +t.test('revoke with very long id', async t => { const { npm } = await loadMockNpm(t, { prefixDir: { 'package.json': JSON.stringify({ @@ -293,7 +293,7 @@ t.test('trust-revoke with very long id', async t => { await npm.exec('trust', ['revoke', packageName, '--id', trustId]) }) -t.test('trust-revoke with UUID id format', async t => { +t.test('revoke with UUID id format', async t => { const { npm } = await loadMockNpm(t, { prefixDir: { 'package.json': JSON.stringify({ diff --git a/test/lib/docs.js b/test/lib/docs.js index 89310b8ab15b4..dff238cfe562d 100644 --- a/test/lib/docs.js +++ b/test/lib/docs.js @@ -90,8 +90,20 @@ 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') From 6131f242194c6d77d598fad136850a945972bfb4 Mon Sep 17 00:00:00 2001 From: reggi Date: Tue, 13 Jan 2026 13:49:22 -0500 Subject: [PATCH 12/28] chore: config tests fail when registry is not default --- workspaces/config/test/index.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/workspaces/config/test/index.js b/workspaces/config/test/index.js index f1803bb28a505..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 }, }, From 192aa880a4275c205f1974dcd2a96bc630407f4a Mon Sep 17 00:00:00 2001 From: reggi Date: Tue, 13 Jan 2026 13:53:08 -0500 Subject: [PATCH 13/28] chore: drop trust- file prefix --- lib/commands/trust/{trust-github.js => github.js} | 0 lib/commands/trust/{trust-gitlab.js => gitlab.js} | 0 lib/commands/trust/index.js | 8 ++++---- lib/commands/trust/{trust-list.js => list.js} | 4 ++-- lib/commands/trust/{trust-revoke.js => revoke.js} | 0 test/lib/commands/trust/github.js | 2 +- test/lib/commands/trust/gitlab.js | 2 +- 7 files changed, 8 insertions(+), 8 deletions(-) rename lib/commands/trust/{trust-github.js => github.js} (100%) rename lib/commands/trust/{trust-gitlab.js => gitlab.js} (100%) rename lib/commands/trust/{trust-list.js => list.js} (94%) rename lib/commands/trust/{trust-revoke.js => revoke.js} (100%) diff --git a/lib/commands/trust/trust-github.js b/lib/commands/trust/github.js similarity index 100% rename from lib/commands/trust/trust-github.js rename to lib/commands/trust/github.js diff --git a/lib/commands/trust/trust-gitlab.js b/lib/commands/trust/gitlab.js similarity index 100% rename from lib/commands/trust/trust-gitlab.js rename to lib/commands/trust/gitlab.js diff --git a/lib/commands/trust/index.js b/lib/commands/trust/index.js index 3c9b3dd06dfb4..cabcfa7c34cb8 100644 --- a/lib/commands/trust/index.js +++ b/lib/commands/trust/index.js @@ -5,10 +5,10 @@ class Trust extends BaseCommand { static name = 'trust' static subcommands = { - github: require('./trust-github.js'), - gitlab: require('./trust-gitlab.js'), - list: require('./trust-list.js'), - revoke: require('./trust-revoke.js'), + github: require('./github.js'), + gitlab: require('./gitlab.js'), + list: require('./list.js'), + revoke: require('./revoke.js'), } static async completion (opts) { diff --git a/lib/commands/trust/trust-list.js b/lib/commands/trust/list.js similarity index 94% rename from lib/commands/trust/trust-list.js rename to lib/commands/trust/list.js index 267d6f86ef1b8..e32d1c13cc91a 100644 --- a/lib/commands/trust/trust-list.js +++ b/lib/commands/trust/list.js @@ -2,8 +2,8 @@ const pkgJson = require('@npmcli/package-json') const { otplease } = require('../../utils/auth.js') const npmFetch = require('npm-registry-fetch') const npa = require('npm-package-arg') -const TrustGithub = require('./trust-github.js') -const TrustGitlab = require('./trust-gitlab.js') +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') diff --git a/lib/commands/trust/trust-revoke.js b/lib/commands/trust/revoke.js similarity index 100% rename from lib/commands/trust/trust-revoke.js rename to lib/commands/trust/revoke.js diff --git a/test/lib/commands/trust/github.js b/test/lib/commands/trust/github.js index 8a66d9de32df2..76c61a12c584c 100644 --- a/test/lib/commands/trust/github.js +++ b/test/lib/commands/trust/github.js @@ -128,7 +128,7 @@ t.test('github without environment', async t => { }) t.test('bodyToOptions with all fields', t => { - const TrustGitHub = require('../../../../lib/commands/trust/trust-github.js') + const TrustGitHub = require('../../../../lib/commands/trust/github.js') const body = { id: 'test-id', diff --git a/test/lib/commands/trust/gitlab.js b/test/lib/commands/trust/gitlab.js index 3b8e81411e7e5..c74ac59c7eb39 100644 --- a/test/lib/commands/trust/gitlab.js +++ b/test/lib/commands/trust/gitlab.js @@ -128,7 +128,7 @@ t.test('gitlab without environment', async t => { }) t.test('bodyToOptions with all fields', t => { - const TrustGitLab = require('../../../../lib/commands/trust/trust-gitlab.js') + const TrustGitLab = require('../../../../lib/commands/trust/gitlab.js') const body = { id: 'test-id', From 294e0bb2f6522d7eb4e50385284606ddeeeb5ce9 Mon Sep 17 00:00:00 2001 From: reggi Date: Thu, 15 Jan 2026 14:42:30 -0500 Subject: [PATCH 14/28] fix: use stdout for messaging --- lib/commands/trust/list.js | 9 ++++++--- lib/commands/trust/revoke.js | 10 ++++------ lib/trust-cmd.js | 29 +++++++++++++++++++---------- test/lib/trust-cmd.js | 4 ++-- 4 files changed, 31 insertions(+), 21 deletions(-) diff --git a/lib/commands/trust/list.js b/lib/commands/trust/list.js index e32d1c13cc91a..e72da6d409d4d 100644 --- a/lib/commands/trust/list.js +++ b/lib/commands/trust/list.js @@ -6,6 +6,7 @@ const TrustGithub = require('./github.js') const TrustGitlab = require('./gitlab.js') const TrustCommand = require('../../trust-cmd.js') const globalDefinitions = require('@npmcli/config/lib/definitions/definitions.js') +const { output } = require('proc-log') // Convert trust config body to options object function bodyToOptions (body) { @@ -44,14 +45,16 @@ class TrustList extends TrustCommand { })) if (!res || res.length === 0) { - this.warn`No trust configurations found for package (${packageName})` + this.dialogue`No trust configurations found for package (${packageName})` return } - this.notice`Trust configurations for package (${packageName}):` + this.dialogue`Trust configurations for package (${packageName}):` for (const config of res) { const values = bodyToOptions(config) - this.logOptions({ values }) + output.standard() + this.logOptions({ values }, false) } + output.standard() } } diff --git a/lib/commands/trust/revoke.js b/lib/commands/trust/revoke.js index d2dadf54cdefb..1a074b19139a7 100644 --- a/lib/commands/trust/revoke.js +++ b/lib/commands/trust/revoke.js @@ -3,11 +3,10 @@ const Definition = require('@npmcli/config/lib/definitions/definition.js') const pkgJson = require('@npmcli/package-json') const { otplease } = require('../../utils/auth.js') const npmFetch = require('npm-registry-fetch') -const BaseCommand = require('../../base-cmd.js') const npa = require('npm-package-arg') -const { log } = require('proc-log') +const TrustCommand = require('../../trust-cmd.js') -class TrustRevoke extends BaseCommand { +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) @@ -36,8 +35,7 @@ class TrustRevoke extends BaseCommand { if (!id) { throw new Error('ID of the trusted relationship to revoke must be specified with the --id option') } - const chalk = this.npm.chalk - log.notice(`Attempting to revoke trusted configuration for package ${chalk.blue(pkgName)} with id ${chalk.blue(id)}`) + this.dialogue`Attempting to revoke trusted configuration for package ${pkgName} with id ${id}` if (dryRun) { return } @@ -47,7 +45,7 @@ class TrustRevoke extends BaseCommand { ...opts, method: 'DELETE', })) - log.notice('', `Revoked trusted configuration for package ${chalk.blue(pkgName)} with id ${chalk.blue(id)}`) + this.dialogue`Revoked trusted configuration for package ${pkgName} with id ${id}` } } diff --git a/lib/trust-cmd.js b/lib/trust-cmd.js index 1f7b96193d5e0..80af50edae3ce 100644 --- a/lib/trust-cmd.js +++ b/lib/trust-cmd.js @@ -25,9 +25,14 @@ class TrustCommand extends BaseCommand { log.warn('trust', this.warnString(strings, ...values)) } - // Log a notice message with blue formatting - notice (strings, ...values) { - log.notice('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) { @@ -40,7 +45,7 @@ class TrustCommand extends BaseCommand { })) } - logOptions (options) { + logOptions (options, pad = true) { const { values, warnings, fromPackageJson, urls } = { warnings: [], ...options } if (warnings && warnings.length > 0) { for (const warningMsg of warnings) { @@ -79,9 +84,13 @@ class TrustCommand extends BaseCommand { lines.push(parts.join(' ')) } } - output.standard() + if (pad) { + output.standard() + } output.standard(lines.join('\n')) - output.standard() + if (pad) { + output.standard() + } } } @@ -146,9 +155,9 @@ class TrustCommand extends BaseCommand { 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.notice`Establishing trust between ${options.values.package} package and ${providerName}` - this.notice`Anyone with ${providerEntity} write access can publish to ${options.values.package}` - this.notice`Two-factor authentication is required for this operation` + 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` } @@ -160,7 +169,7 @@ class TrustCommand extends BaseCommand { const trustConfig = this.constructor.optionsToBody(options.values) const response = await this.createConfig(options.values.package, [trustConfig]) const body = await response.json(yes) - this.notice`Trust configuration created successfully with the following settings:` + this.dialogue`Trust configuration created successfully with the following settings:` const values = this.constructor.bodyToOptions(body) this.logOptions({ values }) } diff --git a/test/lib/trust-cmd.js b/test/lib/trust-cmd.js index f12f44efe3928..d9a58cf2588ff 100644 --- a/test/lib/trust-cmd.js +++ b/test/lib/trust-cmd.js @@ -95,7 +95,7 @@ t.test('trust-cmd via trust github infers from package.json', async t => { }) t.test('trust-cmd via trust github with dry-run', async t => { - const { npm, logs } = await loadMockNpm(t, { + const { npm, joinedOutput } = await loadMockNpm(t, { prefixDir: { 'package.json': JSON.stringify({ name: packageName, @@ -110,7 +110,7 @@ t.test('trust-cmd via trust github with dry-run', async t => { await npm.exec('trust', ['github', packageName, '--file', 'workflow.yml', '--repository', 'npm/cli']) - t.ok(logs.notice.some(l => l.includes('Establishing trust')), 'shows notice about establishing trust') + t.ok(joinedOutput().includes('Establishing trust'), 'shows notice about establishing trust') }) t.test('trust-cmd via trust github missing package name', async t => { From e68edb4ea39f2e8ed66a415273dd4f4c62f616e7 Mon Sep 17 00:00:00 2001 From: Reggi Date: Mon, 26 Jan 2026 16:04:44 -0500 Subject: [PATCH 15/28] Update lib/base-cmd.js Co-authored-by: Michael Smith --- lib/base-cmd.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/base-cmd.js b/lib/base-cmd.js index ab089ff18c294..b532599ce5648 100644 --- a/lib/base-cmd.js +++ b/lib/base-cmd.js @@ -17,7 +17,7 @@ class BaseCommand { // Number of expected positional arguments (null = unlimited/unchecked) static positionals = null - // this is a static so that we can read =rom it without instantiating a command + // this is a static so that we can read from it without instantiating a command // which would require loading the config static get describeUsage () { return this.getUsage() From 691d793449679b4e971a6a7acdf11b42cfa25862 Mon Sep 17 00:00:00 2001 From: Reggi Date: Mon, 26 Jan 2026 16:05:14 -0500 Subject: [PATCH 16/28] Update lib/trust-cmd.js Co-authored-by: Michael Smith --- lib/trust-cmd.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/trust-cmd.js b/lib/trust-cmd.js index 80af50edae3ce..58f38a80aee4d 100644 --- a/lib/trust-cmd.js +++ b/lib/trust-cmd.js @@ -168,7 +168,7 @@ class TrustCommand extends BaseCommand { await this.confirmOperation(yes) const trustConfig = this.constructor.optionsToBody(options.values) const response = await this.createConfig(options.values.package, [trustConfig]) - const body = await response.json(yes) + const body = await response.json() this.dialogue`Trust configuration created successfully with the following settings:` const values = this.constructor.bodyToOptions(body) this.logOptions({ values }) From 37a709f65085e8b26557b26b027f6ca2680a594c Mon Sep 17 00:00:00 2001 From: reggi Date: Tue, 27 Jan 2026 16:54:41 -0500 Subject: [PATCH 17/28] usage Error for unknown flags removes argv + excludeNpmCwd adds displayResponseBody for list and create adds registry global flag list bodyToOptions becomes static environment issue when creating inside claim --- lib/base-cmd.js | 2 +- lib/commands/trust/github.js | 1 + lib/commands/trust/gitlab.js | 7 ++-- lib/commands/trust/list.js | 36 +++++++------------ lib/commands/trust/revoke.js | 1 + lib/npm.js | 2 -- lib/trust-cmd.js | 19 ++++++++-- .../test/lib/commands/completion.js.test.cjs | 2 ++ .../test/lib/commands/install.js.test.cjs | 12 +++---- tap-snapshots/test/lib/docs.js.test.cjs | 4 +++ test/lib/commands/trust/gitlab.js | 2 +- 11 files changed, 47 insertions(+), 41 deletions(-) diff --git a/lib/base-cmd.js b/lib/base-cmd.js index b532599ce5648..f55a8ce442c8c 100644 --- a/lib/base-cmd.js +++ b/lib/base-cmd.js @@ -396,7 +396,7 @@ class BaseCommand { // Throw error if unknown flags were found if (unknownFlags.length > 0) { const flagList = unknownFlags.map(f => `--${f}`).join(', ') - throw new Error(`Unknown flag${unknownFlags.length > 1 ? 's' : ''}: ${flagList}`) + throw this.usageError(`Unknown flag${unknownFlags.length > 1 ? 's' : ''}: ${flagList}`) } // Remove warnings for command-specific definitions that npm's global config diff --git a/lib/commands/trust/github.js b/lib/commands/trust/github.js index 7ebc8443cc191..dd63442983ead 100644 --- a/lib/commands/trust/github.js +++ b/lib/commands/trust/github.js @@ -22,6 +22,7 @@ class TrustGitHub extends TrustCommand { static definitions = { yes: globalDefinitions.yes, json: globalDefinitions.json, + registry: globalDefinitions.registry, 'dry-run': globalDefinitions['dry-run'], file: new Definition('file', { default: null, diff --git a/lib/commands/trust/gitlab.js b/lib/commands/trust/gitlab.js index c8e5c6e76e9b9..c9b64546969f2 100644 --- a/lib/commands/trust/gitlab.js +++ b/lib/commands/trust/gitlab.js @@ -22,6 +22,7 @@ class TrustGitLab extends TrustCommand { static definitions = { yes: globalDefinitions.yes, json: globalDefinitions.json, + registry: globalDefinitions.registry, 'dry-run': globalDefinitions['dry-run'], file: new Definition('file', { default: null, @@ -71,11 +72,9 @@ class TrustGitLab extends TrustCommand { ci_config_ref_uri: { file, }, + ...(environment) && { environment }, }, } - if (environment) { - trustConfig.environment = environment - } return trustConfig } @@ -83,7 +82,7 @@ class TrustGitLab extends TrustCommand { static bodyToOptions (body) { const file = body.claims?.ci_config_ref_uri?.file const project = body.claims?.project_path - const environment = body.environment + const environment = body.claims?.environment return { ...(body.id) && { id: body.id }, ...(body.type) && { type: body.type }, diff --git a/lib/commands/trust/list.js b/lib/commands/trust/list.js index e72da6d409d4d..b36d82725d7dc 100644 --- a/lib/commands/trust/list.js +++ b/lib/commands/trust/list.js @@ -6,17 +6,6 @@ const TrustGithub = require('./github.js') const TrustGitlab = require('./gitlab.js') const TrustCommand = require('../../trust-cmd.js') const globalDefinitions = require('@npmcli/config/lib/definitions/definitions.js') -const { output } = require('proc-log') - -// Convert trust config body to options object -function bodyToOptions (body) { - if (body.type === 'github') { - return TrustGithub.bodyToOptions(body) - } else if (body.type === 'gitlab') { - return TrustGitlab.bodyToOptions(body) - } - return TrustCommand.bodyToOptions(body) -} class TrustList extends TrustCommand { static description = 'List trusted relationships for a package' @@ -29,6 +18,16 @@ class TrustList extends TrustCommand { 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 (args) { @@ -39,22 +38,11 @@ class TrustList extends TrustCommand { } const spec = npa(packageName) const uri = `/-/package/${spec.escapedName}/trust` - const res = await otplease(this.npm, this.npm.flatOptions, opts => npmFetch.json(uri, { + const body = await otplease(this.npm, this.npm.flatOptions, opts => npmFetch.json(uri, { ...opts, method: 'GET', })) - - if (!res || res.length === 0) { - this.dialogue`No trust configurations found for package (${packageName})` - return - } - this.dialogue`Trust configurations for package (${packageName}):` - for (const config of res) { - const values = bodyToOptions(config) - output.standard() - this.logOptions({ values }, false) - } - output.standard() + this.displayResponseBody({ body, packageName }) } } diff --git a/lib/commands/trust/revoke.js b/lib/commands/trust/revoke.js index 1a074b19139a7..8702210ed6aad 100644 --- a/lib/commands/trust/revoke.js +++ b/lib/commands/trust/revoke.js @@ -17,6 +17,7 @@ class TrustRevoke extends TrustCommand { static definitions = { 'dry-run': globalDefinitions['dry-run'], + registry: globalDefinitions.registry, id: new Definition('id', { default: null, type: String, diff --git a/lib/npm.js b/lib/npm.js index 355fb4c7467c5..d30554d400f07 100644 --- a/lib/npm.js +++ b/lib/npm.js @@ -37,8 +37,6 @@ class Npm { #runId = new Date().toISOString().replace(/[.:]/g, '_') #title = 'npm' #argvClean = [] - #argv = undefined - #excludeNpmCwd = undefined #npmRoot = null #display = null diff --git a/lib/trust-cmd.js b/lib/trust-cmd.js index 58f38a80aee4d..3a77a89555b0e 100644 --- a/lib/trust-cmd.js +++ b/lib/trust-cmd.js @@ -169,9 +169,8 @@ class TrustCommand extends BaseCommand { 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 with the following settings:` - const values = this.constructor.bodyToOptions(body) - this.logOptions({ values }) + 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 }) { @@ -254,6 +253,20 @@ class TrustCommand extends BaseCommand { }, } } + + 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 diff --git a/tap-snapshots/test/lib/commands/completion.js.test.cjs b/tap-snapshots/test/lib/commands/completion.js.test.cjs index 50b21f56f3642..6f6d225dff720 100644 --- a/tap-snapshots/test/lib/commands/completion.js.test.cjs +++ b/tap-snapshots/test/lib/commands/completion.js.test.cjs @@ -102,6 +102,7 @@ Array [ String( --yes --json + --registry --dry-run --file --repository @@ -120,6 +121,7 @@ Array [ String( --yes --json + --registry --dry-run --file --project diff --git a/tap-snapshots/test/lib/commands/install.js.test.cjs b/tap-snapshots/test/lib/commands/install.js.test.cjs index 30331b77af13b..0357507a25a77 100644 --- a/tap-snapshots/test/lib/commands/install.js.test.cjs +++ b/tap-snapshots/test/lib/commands/install.js.test.cjs @@ -135,8 +135,8 @@ verbose stack Error: The developer of this package has specified the following t 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:251:27) -verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:312:7) -verbose stack at MockNpm.exec ({CWD}/lib/npm.js:211:9) +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 @@ -200,8 +200,8 @@ verbose stack Error: The developer of this package has specified the following t 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:251:27) -verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:312:7) -verbose stack at MockNpm.exec ({CWD}/lib/npm.js:211:9) +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 @@ -226,8 +226,8 @@ verbose stack Error: The developer of this package has specified the following t 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:251:27) -verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:312:7) -verbose stack at MockNpm.exec ({CWD}/lib/npm.js:211:9) +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/docs.js.test.cjs b/tap-snapshots/test/lib/docs.js.test.cjs index 6f5c9be1ab2f6..dc27781152083 100644 --- a/tap-snapshots/test/lib/docs.js.test.cjs +++ b/tap-snapshots/test/lib/docs.js.test.cjs @@ -5764,6 +5764,7 @@ Note: This command is unaware of workspaces. #### \`environment\` #### \`yes\` #### \`json\` +#### \`registry\` #### \`dry-run\` #### Synopsis #### Flags @@ -5772,14 +5773,17 @@ Note: This command is unaware of workspaces. #### \`environment\` #### \`yes\` #### \`json\` +#### \`registry\` #### \`dry-run\` #### Synopsis #### Configuration #### \`json\` +#### \`registry\` #### Synopsis #### Flags #### \`id\` #### \`dry-run\` +#### \`registry\` ` exports[`test/lib/docs.js TAP usage undeprecate > must match snapshot 1`] = ` diff --git a/test/lib/commands/trust/gitlab.js b/test/lib/commands/trust/gitlab.js index c74ac59c7eb39..0b60196830c5f 100644 --- a/test/lib/commands/trust/gitlab.js +++ b/test/lib/commands/trust/gitlab.js @@ -138,8 +138,8 @@ t.test('bodyToOptions with all fields', t => { ci_config_ref_uri: { file: '.gitlab-ci.yml', }, + environment: 'prod', }, - environment: 'prod', } const options = TrustGitLab.bodyToOptions(body) From 030a17ebbd978e9944c2df2652a92f364d292ab5 Mon Sep 17 00:00:00 2001 From: reggi Date: Tue, 27 Jan 2026 16:58:53 -0500 Subject: [PATCH 18/28] env --- lib/commands/trust/github.js | 6 ++---- test/lib/commands/trust/github.js | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/lib/commands/trust/github.js b/lib/commands/trust/github.js index dd63442983ead..27bbcefb5d846 100644 --- a/lib/commands/trust/github.js +++ b/lib/commands/trust/github.js @@ -71,11 +71,9 @@ class TrustGitHub extends TrustCommand { workflow_ref: { file, }, + ...(environment) && { environment }, }, } - if (environment) { - trustConfig.environment = environment - } return trustConfig } @@ -83,7 +81,7 @@ class TrustGitHub extends TrustCommand { static bodyToOptions (body) { const file = body.claims?.workflow_ref?.file const repository = body.claims?.repository - const environment = body.environment + const environment = body.claims?.environment return { ...(body.id) && { id: body.id }, ...(body.type) && { type: body.type }, diff --git a/test/lib/commands/trust/github.js b/test/lib/commands/trust/github.js index 76c61a12c584c..a2b16d272bde1 100644 --- a/test/lib/commands/trust/github.js +++ b/test/lib/commands/trust/github.js @@ -138,8 +138,8 @@ t.test('bodyToOptions with all fields', t => { workflow_ref: { file: 'test.yml', }, + environment: 'prod', }, - environment: 'prod', } const options = TrustGitHub.bodyToOptions(body) From 29be3920ef27a9f816cdde628f64703f48b70a10 Mon Sep 17 00:00:00 2001 From: reggi Date: Thu, 29 Jan 2026 15:35:13 -0500 Subject: [PATCH 19/28] put urls on their own line for accessibility --- lib/trust-cmd.js | 16 ++++++++--- test/lib/trust-cmd.js | 62 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/lib/trust-cmd.js b/lib/trust-cmd.js index 3a77a89555b0e..1e5c4e9a55bb6 100644 --- a/lib/trust-cmd.js +++ b/lib/trust-cmd.js @@ -78,9 +78,6 @@ class TrustCommand extends BaseCommand { if (fromPackageJson && fromPackageJson[key]) { parts.push(`(${chalk.yellow(`from package.json`)})`) } - if (urls && urls[key]) { - parts.push(`(${chalk.blue(urls[key])})`) - } lines.push(parts.join(' ')) } } @@ -88,6 +85,19 @@ class TrustCommand extends BaseCommand { output.standard() } output.standard(lines.join('\n')) + // 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')) + } + } if (pad) { output.standard() } diff --git a/test/lib/trust-cmd.js b/test/lib/trust-cmd.js index d9a58cf2588ff..f0c52aadbd2c4 100644 --- a/test/lib/trust-cmd.js +++ b/test/lib/trust-cmd.js @@ -784,3 +784,65 @@ t.test('TrustCommand - logOptions with fromPackageJson and urls', async t => { 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') +}) From de3d07a8037d89947c63ca74069c65196e6bf04a Mon Sep 17 00:00:00 2001 From: reggi Date: Fri, 30 Jan 2026 15:15:34 -0500 Subject: [PATCH 20/28] fix pkg check for list, revoke, fix unknown issue for entity provider --- lib/commands/trust/github.js | 2 +- lib/commands/trust/gitlab.js | 2 +- lib/commands/trust/list.js | 6 ++---- lib/commands/trust/revoke.js | 4 +--- test/lib/commands/trust/list.js | 2 +- test/lib/commands/trust/revoke.js | 2 +- 6 files changed, 7 insertions(+), 11 deletions(-) diff --git a/lib/commands/trust/github.js b/lib/commands/trust/github.js index 27bbcefb5d846..98812894cd5f5 100644 --- a/lib/commands/trust/github.js +++ b/lib/commands/trust/github.js @@ -52,7 +52,7 @@ class TrustGitHub extends TrustCommand { validateEntity (entity) { if (entity.split('/').length !== 2) { - throw new Error(`${this.providerEntity} must be specified in the format owner/repository`) + throw new Error(`${this.constructor.providerEntity} must be specified in the format owner/repository`) } } diff --git a/lib/commands/trust/gitlab.js b/lib/commands/trust/gitlab.js index c9b64546969f2..b21c35812eb1e 100644 --- a/lib/commands/trust/gitlab.js +++ b/lib/commands/trust/gitlab.js @@ -51,7 +51,7 @@ class TrustGitLab extends TrustCommand { validateEntity (entity) { if (entity.split('/').length < 2) { - throw new Error(`${this.providerEntity} must be specified in the format group/project or group/subgroup/project`) + throw new Error(`${this.constructor.providerEntity} must be specified in the format group/project or group/subgroup/project`) } } diff --git a/lib/commands/trust/list.js b/lib/commands/trust/list.js index b36d82725d7dc..789e81c443f54 100644 --- a/lib/commands/trust/list.js +++ b/lib/commands/trust/list.js @@ -1,4 +1,3 @@ -const pkgJson = require('@npmcli/package-json') const { otplease } = require('../../utils/auth.js') const npmFetch = require('npm-registry-fetch') const npa = require('npm-package-arg') @@ -30,9 +29,8 @@ class TrustList extends TrustCommand { return TrustCommand.bodyToOptions(body) } - async exec (args) { - const { content } = await pkgJson.normalize(this.npm.prefix) - const packageName = args[0] || content.name + 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') } diff --git a/lib/commands/trust/revoke.js b/lib/commands/trust/revoke.js index 8702210ed6aad..8726bbeeb08f3 100644 --- a/lib/commands/trust/revoke.js +++ b/lib/commands/trust/revoke.js @@ -1,6 +1,5 @@ const globalDefinitions = require('@npmcli/config/lib/definitions/definitions.js') const Definition = require('@npmcli/config/lib/definitions/definition.js') -const pkgJson = require('@npmcli/package-json') const { otplease } = require('../../utils/auth.js') const npmFetch = require('npm-registry-fetch') const npa = require('npm-package-arg') @@ -27,8 +26,7 @@ class TrustRevoke extends TrustCommand { async exec (positionalArgs, flags) { const dryRun = this.config.get('dry-run') - const { content } = await pkgJson.normalize(this.npm.prefix) - const pkgName = positionalArgs[0] || content.name + 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') } diff --git a/test/lib/commands/trust/list.js b/test/lib/commands/trust/list.js index bd979abfff112..99d25b66bc90c 100644 --- a/test/lib/commands/trust/list.js +++ b/test/lib/commands/trust/list.js @@ -122,7 +122,7 @@ t.test('list without package name and no package.json', async t => { await t.rejects( npm.exec('trust', ['list']), - { message: /Could not read package\.json/ } + { message: /Package name must be specified either as an argument or in the package\.json file/ } ) }) diff --git a/test/lib/commands/trust/revoke.js b/test/lib/commands/trust/revoke.js index 290fc61897725..d44d576664c86 100644 --- a/test/lib/commands/trust/revoke.js +++ b/test/lib/commands/trust/revoke.js @@ -84,7 +84,7 @@ t.test('revoke without package name and no package.json', async t => { await t.rejects( npm.exec('trust', ['revoke', '--id', 'test-id']), - { message: /Could not read package\.json/ } + { message: /Package name must be specified either as an argument or in the package\.json file/ } ) }) From 4f0d3248293193e586b0ee92fa175b6a1d84ce79 Mon Sep 17 00:00:00 2001 From: reggi Date: Tue, 3 Feb 2026 13:26:58 -0500 Subject: [PATCH 21/28] fix redaction of oidc config id in json output --- lib/trust-cmd.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/trust-cmd.js b/lib/trust-cmd.js index 1e5c4e9a55bb6..c267c1373e91c 100644 --- a/lib/trust-cmd.js +++ b/lib/trust-cmd.js @@ -3,7 +3,7 @@ const { otplease } = require('./utils/auth.js') const npmFetch = require('npm-registry-fetch') const npa = require('npm-package-arg') const { read: _read } = require('read') -const { input, output, log } = require('proc-log') +const { input, output, log, META } = require('proc-log') const gitinfo = require('hosted-git-info') const pkgJson = require('@npmcli/package-json') @@ -55,7 +55,7 @@ class TrustCommand extends BaseCommand { const json = this.config.get('json') if (json) { - output.standard(JSON.stringify(options.values, null, 2)) + output.standard(JSON.stringify(options.values, null, 2), { [META]: true, redact: false }) return } @@ -84,7 +84,7 @@ class TrustCommand extends BaseCommand { if (pad) { output.standard() } - output.standard(lines.join('\n')) + 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 = [] From 4d70fa9cadf449d1a8abf57d90ce5de77b6d6478 Mon Sep 17 00:00:00 2001 From: reggi Date: Wed, 4 Feb 2026 11:06:44 -0500 Subject: [PATCH 22/28] new command --- smoke-tests/tap-snapshots/test/index.js.test.cjs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/smoke-tests/tap-snapshots/test/index.js.test.cjs b/smoke-tests/tap-snapshots/test/index.js.test.cjs index 28587418fb518..18b95223523ec 100644 --- a/smoke-tests/tap-snapshots/test/index.js.test.cjs +++ b/smoke-tests/tap-snapshots/test/index.js.test.cjs @@ -88,6 +88,9 @@ 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 From 9b6dc384aea0897adc6c998e126a5cc36d4fa4a8 Mon Sep 17 00:00:00 2001 From: reggi Date: Wed, 4 Feb 2026 11:17:14 -0500 Subject: [PATCH 23/28] snapshot --- tap-snapshots/test/lib/docs.js.test.cjs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tap-snapshots/test/lib/docs.js.test.cjs b/tap-snapshots/test/lib/docs.js.test.cjs index dc27781152083..274712d7bbf77 100644 --- a/tap-snapshots/test/lib/docs.js.test.cjs +++ b/tap-snapshots/test/lib/docs.js.test.cjs @@ -3041,6 +3041,9 @@ Options: --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 @@ -3210,6 +3213,9 @@ Options: --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 @@ -3951,6 +3957,9 @@ Options: --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 @@ -4067,6 +4076,9 @@ Options: --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 @@ -4182,6 +4194,9 @@ Options: --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 @@ -4309,6 +4324,9 @@ Options: --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 From f0e84dce0d6bcfa6db1c9e20c560308d174ddb08 Mon Sep 17 00:00:00 2001 From: reggi Date: Thu, 5 Feb 2026 12:59:30 -0500 Subject: [PATCH 24/28] trust doc / table --- docs/lib/content/commands/npm-trust.md | 28 +++- docs/lib/content/nav.yml | 3 +- docs/lib/index.js | 64 +++++---- docs/lib/template.html | 45 +++++++ docs/test/index.js | 175 ++++++++++++++++++++++++- 5 files changed, 278 insertions(+), 37 deletions(-) diff --git a/docs/lib/content/commands/npm-trust.md b/docs/lib/content/commands/npm-trust.md index 0a295c964ef9a..54792cddc618b 100644 --- a/docs/lib/content/commands/npm-trust.md +++ b/docs/lib/content/commands/npm-trust.md @@ -1,7 +1,7 @@ --- title: npm-trust section: 1 -description: Create a trusted relationship between a package and a OIDC provider +description: Manage trusted publishing relationships between packages and CI/CD providers --- ### Synopsis @@ -10,7 +10,25 @@ description: Create a trusted relationship between a package and a OIDC provider ### Description -Create a trusted relationship between a package and a OIDC provider +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 @@ -18,4 +36,8 @@ Create a trusted relationship between a package and a OIDC provider ### See Also -* [npm help config](/commands/npm-config) +* [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/nav.yml b/docs/lib/content/nav.yml index 0681b7b77e1b8..58bbeeb71f893 100644 --- a/docs/lib/content/nav.yml +++ b/docs/lib/content/nav.yml @@ -188,7 +188,8 @@ description: Manage your authentication tokens - title: npm trust url: /commands/npm-trust - description: Create a trusted relationship between a package and a OIDC provider + 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 diff --git a/docs/lib/index.js b/docs/lib/index.js index 840e4c91a0074..6832f33090966 100644 --- a/docs/lib/index.js +++ b/docs/lib/index.js @@ -127,6 +127,34 @@ const replaceUsage = (src, { path }, commandLoader) => { return src.replace(replacer, synopsis.join('\n')) } +// 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) @@ -185,36 +213,14 @@ const replaceParams = (src, { path }, commandLoader) => { } } - // Add command-specific flags section if any exist - if (commandSpecificParams.length > 0) { - parts.push('#### Flags', '') - parts.push('These flags are specific to this subcommand and are not part of npm\'s global configuration or `.npmrc` files.', '') - - commandSpecificParams.forEach((n) => { - const def = subDefinitions[n] - const shortcuts = def.short ? `\n* Shortcut: \`-${def.short}\`` : '' - const defAliases = def.alias || [] - const aliasText = defAliases.length > 0 - ? `\n* Aliases: ${defAliases.map(a => `\`--${a}\``).join(', ')}` - : '' - parts.push(`${def.describe()}${shortcuts}${aliasText}`, '') - }) - } + // Merge all definitions for table generation + const allDefinitions = { ...definitions, ...subDefinitions } + const allParams = [...commandSpecificParams, ...globalConfigParams] - // Add global config section if any exist - if (globalConfigParams.length > 0) { - if (commandSpecificParams.length === 0) { - parts.push('#### Configuration', '') - } - globalConfigParams.forEach((n) => { - const def = subDefinitions[n] || definitions[n] - const shortcuts = def.short ? `\n* Shortcut: \`-${def.short}\`` : '' - const defAliases = def.alias || [] - const aliasText = defAliases.length > 0 - ? `\n* Aliases: ${defAliases.map(a => `\`--${a}\``).join(', ')}` - : '' - parts.push(`${def.describe()}${shortcuts}${aliasText}`, '') - }) + // Add flags section with all parameters combined + if (allParams.length > 0) { + parts.push('#### Flags', '') + parts.push(generateFlagsTable(allParams, allDefinitions), '') } return parts.join('\n') 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 699484c7cd818..2f5ee6c743a65 100644 --- a/docs/test/index.js +++ b/docs/test/index.js @@ -720,7 +720,7 @@ t.test('replaceParams with name edge cases', async t => { // Tests subcommand code path including line 184 (aliases in subcommand definitions) // npm trust has subcommands with definitions that include aliases (repo, env) await testCommandDoc(t, 'npm-trust', 'Create a trusted relationship between a package and a OIDC provider', { - match: [/Aliases/, /--repo/, /--env/], + match: [/--repo/, /--env/], }) }) }) @@ -855,7 +855,6 @@ description: Test command without params const htmlContent = await readHtmlDoc(html, 'npm-testcmd-alias') t.ok(htmlContent.length > 0, 'generates HTML for command with aliases') t.match(htmlContent, /aliased-flag/, 'includes aliased flag') - t.match(htmlContent, /Aliases/, 'includes aliases section') t.match(htmlContent, /--af/, 'includes first alias') t.match(htmlContent, /--alias-flag/, 'includes second alias') }) @@ -1070,7 +1069,6 @@ description: Test command without params t.ok(htmlContent.length > 0, 'generates HTML for subcommand with short and alias') t.match(htmlContent, /complex-flag/, 'includes complex flag') t.match(htmlContent, /-x/, 'includes short flag') - t.match(htmlContent, /Aliases/, 'includes aliases') t.match(htmlContent, /--cf/, 'includes first alias') t.match(htmlContent, /--cflag/, 'includes second alias') }) @@ -1133,6 +1131,44 @@ description: Test command without params 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 @@ -1170,8 +1206,139 @@ description: Test command without params const htmlContent = await readHtmlDoc(html, 'npm-testcmd-sub-global-alias') t.ok(htmlContent.length > 0, 'generates HTML for subcommand with global aliased param') t.match(htmlContent, /registry/, 'includes registry param') - t.match(htmlContent, /Aliases/, 'includes aliases in global config section') t.match(htmlContent, /--reg/, 'includes first alias') t.match(htmlContent, /--r/, 'includes second alias') }) + + 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') + }) }) + From c72afc7dc92d3ad85b1a547294aa3b0ce7b17657 Mon Sep 17 00:00:00 2001 From: reggi Date: Thu, 5 Feb 2026 13:09:54 -0500 Subject: [PATCH 25/28] snapshot fix --- docs/test/index.js | 1 - tap-snapshots/test/lib/docs.js.test.cjs | 21 +-------------------- 2 files changed, 1 insertion(+), 21 deletions(-) diff --git a/docs/test/index.js b/docs/test/index.js index 2f5ee6c743a65..38feae3173091 100644 --- a/docs/test/index.js +++ b/docs/test/index.js @@ -1341,4 +1341,3 @@ description: Test command without params t.match(htmlContent, /mysub/, 'includes subcommand') }) }) - diff --git a/tap-snapshots/test/lib/docs.js.test.cjs b/tap-snapshots/test/lib/docs.js.test.cjs index 274712d7bbf77..0806e1e82f101 100644 --- a/tap-snapshots/test/lib/docs.js.test.cjs +++ b/tap-snapshots/test/lib/docs.js.test.cjs @@ -5777,31 +5777,12 @@ Note: This command is unaware of workspaces. #### Synopsis #### Flags -#### \`file\` -#### \`repository\` -#### \`environment\` -#### \`yes\` -#### \`json\` -#### \`registry\` -#### \`dry-run\` #### Synopsis #### Flags -#### \`file\` -#### \`project\` -#### \`environment\` -#### \`yes\` -#### \`json\` -#### \`registry\` -#### \`dry-run\` #### Synopsis -#### Configuration -#### \`json\` -#### \`registry\` +#### Flags #### Synopsis #### Flags -#### \`id\` -#### \`dry-run\` -#### \`registry\` ` exports[`test/lib/docs.js TAP usage undeprecate > must match snapshot 1`] = ` From 19d4253242d25dd3f465e3e90ce8312db3a85013 Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Thu, 15 Jan 2026 09:59:07 -0800 Subject: [PATCH 26/28] feat: adds circleci to trust command --- lib/commands/trust/circleci.js | 170 +++++++ lib/commands/trust/index.js | 1 + .../test/lib/commands/completion.js.test.cjs | 1 + tap-snapshots/test/lib/docs.js.test.cjs | 105 ++-- test/lib/commands/trust/circleci.js | 449 ++++++++++++++++++ 5 files changed, 665 insertions(+), 61 deletions(-) create mode 100644 lib/commands/trust/circleci.js create mode 100644 test/lib/commands/trust/circleci.js diff --git a/lib/commands/trust/circleci.js b/lib/commands/trust/circleci.js new file mode 100644 index 0000000000000..3a47d5230a45e --- /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: { + org_id: orgId, + project_id: projectId, + pipeline_definition_id: pipelineDefinitionId, + vcs_origin: vcsOrigin, + }, + } + if (contextIds && contextIds.length > 0) { + trustConfig.claims.context_ids = contextIds + } + return trustConfig + } + + static bodyToOptions (body) { + return { + ...(body.id) && { id: body.id }, + ...(body.type) && { type: body.type }, + ...(body.claims?.org_id) && { orgId: body.claims.org_id }, + ...(body.claims?.project_id) && { projectId: body.claims.project_id }, + ...(body.claims?.pipeline_definition_id) && { + pipelineDefinitionId: body.claims.pipeline_definition_id, + }, + ...(body.claims?.vcs_origin) && { vcsOrigin: body.claims.vcs_origin }, + ...(body.claims?.context_ids) && { contextIds: body.claims.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/index.js b/lib/commands/trust/index.js index cabcfa7c34cb8..9c3bf070a4ce1 100644 --- a/lib/commands/trust/index.js +++ b/lib/commands/trust/index.js @@ -7,6 +7,7 @@ class Trust extends BaseCommand { static subcommands = { github: require('./github.js'), gitlab: require('./gitlab.js'), + circleci: require('./circleci.js'), list: require('./list.js'), revoke: require('./revoke.js'), } diff --git a/tap-snapshots/test/lib/commands/completion.js.test.cjs b/tap-snapshots/test/lib/commands/completion.js.test.cjs index 6f6d225dff720..0dd229f38630a 100644 --- a/tap-snapshots/test/lib/commands/completion.js.test.cjs +++ b/tap-snapshots/test/lib/commands/completion.js.test.cjs @@ -139,6 +139,7 @@ Array [ String( github gitlab + circleci list revoke ), diff --git a/tap-snapshots/test/lib/docs.js.test.cjs b/tap-snapshots/test/lib/docs.js.test.cjs index 0806e1e82f101..9d74cb68595b1 100644 --- a/tap-snapshots/test/lib/docs.js.test.cjs +++ b/tap-snapshots/test/lib/docs.js.test.cjs @@ -216,24 +216,6 @@ upon by the current project. -#### \`allow-git\` - -* Default: "all" -* Type: "all", "none", or "root" - -Limits the ability for npm to fetch dependencies from git references. That -is, dependencies that point to a git repo instead of a version or semver -range. Please note that this could leave your tree incomplete and some -packages may not function as intended or designed. - -\`all\` allows any git dependencies to be fetched and installed. \`none\` -prevents any git dependencies from being fetched and installed. \`root\` only -allows git dependencies defined in your project's package.json to be fetched -installed. Also allows git dependencies to be fetched for other commands -like \`npm view\` - - - #### \`allow-same-version\` * Default: false @@ -2229,7 +2211,6 @@ Array [ "access", "all", "allow-same-version", - "allow-git", "also", "audit", "audit-level", @@ -2404,7 +2385,6 @@ Array [ "access", "all", "allow-same-version", - "allow-git", "also", "audit", "audit-level", @@ -2583,7 +2563,6 @@ Object { "_auth": null, "access": null, "all": false, - "allowGit": "all", "allowSameVersion": false, "audit": true, "auditLevel": null, @@ -3011,9 +2990,8 @@ Options: [--install-strategy ] [--legacy-bundling] [--global-style] [--omit [--omit ...]] [--include [--include ...]] -[--strict-peer-deps] [--foreground-scripts] [--ignore-scripts] -[--allow-git ] [--no-audit] [--no-bin-links] [--no-fund] -[--dry-run] +[--strict-peer-deps] [--foreground-scripts] [--ignore-scripts] [--no-audit] +[--no-bin-links] [--no-fund] [--dry-run] [-w|--workspace [-w|--workspace ...]] [--workspaces] [--include-workspace-root] [--install-links] @@ -3041,9 +3019,6 @@ Options: --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 @@ -3087,7 +3062,6 @@ aliases: clean-install, ic, install-clean, isntall-clean #### \`strict-peer-deps\` #### \`foreground-scripts\` #### \`ignore-scripts\` -#### \`allow-git\` #### \`audit\` #### \`bin-links\` #### \`fund\` @@ -3184,8 +3158,7 @@ Options: [--global-style] [--strict-peer-deps] [--no-package-lock] [--omit [--omit ...]] [--include [--include ...]] -[--ignore-scripts] [--allow-git ] [--no-audit] [--no-bin-links] -[--no-fund] [--dry-run] +[--ignore-scripts] [--no-audit] [--no-bin-links] [--no-fund] [--dry-run] [-w|--workspace [-w|--workspace ...]] [--workspaces] [--include-workspace-root] [--install-links] @@ -3213,9 +3186,6 @@ Options: --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 @@ -3259,7 +3229,6 @@ alias: ddp #### \`omit\` #### \`include\` #### \`ignore-scripts\` -#### \`allow-git\` #### \`audit\` #### \`bin-links\` #### \`fund\` @@ -3909,9 +3878,9 @@ Options: [--global-style] [--omit [--omit ...]] [--include [--include ...]] [--strict-peer-deps] [--prefer-dedupe] [--no-package-lock] [--package-lock-only] -[--foreground-scripts] [--ignore-scripts] [--allow-git ] -[--no-audit] [--before ] [--no-bin-links] [--no-fund] [--dry-run] -[--cpu ] [--os ] [--libc ] +[--foreground-scripts] [--ignore-scripts] [--no-audit] [--before ] +[--no-bin-links] [--no-fund] [--dry-run] [--cpu ] [--os ] +[--libc ] [-w|--workspace [-w|--workspace ...]] [--workspaces] [--include-workspace-root] [--install-links] @@ -3957,9 +3926,6 @@ Options: --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 @@ -4021,7 +3987,6 @@ aliases: add, i, in, ins, inst, insta, instal, isnt, isnta, isntal, isntall #### \`package-lock-only\` #### \`foreground-scripts\` #### \`ignore-scripts\` -#### \`allow-git\` #### \`audit\` #### \`before\` #### \`bin-links\` @@ -4046,9 +4011,8 @@ Options: [--install-strategy ] [--legacy-bundling] [--global-style] [--omit [--omit ...]] [--include [--include ...]] -[--strict-peer-deps] [--foreground-scripts] [--ignore-scripts] -[--allow-git ] [--no-audit] [--no-bin-links] [--no-fund] -[--dry-run] +[--strict-peer-deps] [--foreground-scripts] [--ignore-scripts] [--no-audit] +[--no-bin-links] [--no-fund] [--dry-run] [-w|--workspace [-w|--workspace ...]] [--workspaces] [--include-workspace-root] [--install-links] @@ -4076,9 +4040,6 @@ Options: --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 @@ -4122,7 +4083,6 @@ aliases: cit, clean-install-test, sit #### \`strict-peer-deps\` #### \`foreground-scripts\` #### \`ignore-scripts\` -#### \`allow-git\` #### \`audit\` #### \`bin-links\` #### \`fund\` @@ -4146,9 +4106,9 @@ Options: [--global-style] [--omit [--omit ...]] [--include [--include ...]] [--strict-peer-deps] [--prefer-dedupe] [--no-package-lock] [--package-lock-only] -[--foreground-scripts] [--ignore-scripts] [--allow-git ] -[--no-audit] [--before ] [--no-bin-links] [--no-fund] [--dry-run] -[--cpu ] [--os ] [--libc ] +[--foreground-scripts] [--ignore-scripts] [--no-audit] [--before ] +[--no-bin-links] [--no-fund] [--dry-run] [--cpu ] [--os ] +[--libc ] [-w|--workspace [-w|--workspace ...]] [--workspaces] [--include-workspace-root] [--install-links] @@ -4194,9 +4154,6 @@ Options: --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 @@ -4258,7 +4215,6 @@ alias: it #### \`package-lock-only\` #### \`foreground-scripts\` #### \`ignore-scripts\` -#### \`allow-git\` #### \`audit\` #### \`before\` #### \`bin-links\` @@ -4286,8 +4242,7 @@ Options: [--global-style] [--strict-peer-deps] [--no-package-lock] [--omit [--omit ...]] [--include [--include ...]] -[--ignore-scripts] [--allow-git ] [--no-audit] [--no-bin-links] -[--no-fund] [--dry-run] +[--ignore-scripts] [--no-audit] [--no-bin-links] [--no-fund] [--dry-run] [-w|--workspace [-w|--workspace ...]] [--workspaces] [--include-workspace-root] [--install-links] @@ -4324,9 +4279,6 @@ Options: --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 @@ -4373,7 +4325,6 @@ alias: ln #### \`omit\` #### \`include\` #### \`ignore-scripts\` -#### \`allow-git\` #### \`audit\` #### \`bin-links\` #### \`fund\` @@ -5759,6 +5710,9 @@ Subcommands: 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 @@ -5777,12 +5731,41 @@ Note: This command is unaware of workspaces. #### Synopsis #### Flags +#### \`file\` +#### \`repository\` +#### \`environment\` +#### \`yes\` +#### \`json\` +#### \`registry\` +#### \`dry-run\` #### Synopsis #### Flags +#### \`file\` +#### \`project\` +#### \`environment\` +#### \`yes\` +#### \`json\` +#### \`registry\` +#### \`dry-run\` #### Synopsis #### Flags +#### \`org-id\` +#### \`project-id\` +#### \`pipeline-definition-id\` +#### \`vcs-origin\` +#### \`context-id\` +#### \`yes\` +#### \`json\` +#### \`dry-run\` +#### Synopsis +#### Configuration +#### \`json\` +#### \`registry\` #### Synopsis #### Flags +#### \`id\` +#### \`dry-run\` +#### \`registry\` ` exports[`test/lib/docs.js TAP usage undeprecate > must match snapshot 1`] = ` diff --git a/test/lib/commands/trust/circleci.js b/test/lib/commands/trust/circleci.js new file mode 100644 index 0000000000000..87fc72044ddce --- /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: { + 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_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: { + 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', + }, + } + + 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.org_id, '550e8400-e29b-41d4-a716-446655440000', 'org_id should be set') + t.equal(body.claims.project_id, '7c9e6679-7425-40de-944b-e07fc1f90ae7', 'project_id should be set') + t.equal(body.claims.pipeline_definition_id, '6ba7b810-9dad-11d1-80b4-00c04fd430c8', 'pipeline_definition_id should be set') + t.equal(body.claims.vcs_origin, 'github.com/owner/repo', 'vcs_origin should be set') + t.same(body.claims.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.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.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() +}) From 63945ea5ae766bf0f4de30d8fd2ff8ee2c76ea33 Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Thu, 22 Jan 2026 13:33:32 -0800 Subject: [PATCH 27/28] use full claim for body --- lib/commands/trust/circleci.js | 22 +++++++++---------- test/lib/commands/trust/circleci.js | 34 ++++++++++++++--------------- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/lib/commands/trust/circleci.js b/lib/commands/trust/circleci.js index 3a47d5230a45e..b3f61771a874e 100644 --- a/lib/commands/trust/circleci.js +++ b/lib/commands/trust/circleci.js @@ -75,14 +75,14 @@ class TrustCircleCI extends TrustCommand { const trustConfig = { type: 'circleci', claims: { - org_id: orgId, - project_id: projectId, - pipeline_definition_id: pipelineDefinitionId, - vcs_origin: vcsOrigin, + '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.context_ids = contextIds + trustConfig.claims['oidc.circleci.com/context-ids'] = contextIds } return trustConfig } @@ -91,13 +91,13 @@ class TrustCircleCI extends TrustCommand { return { ...(body.id) && { id: body.id }, ...(body.type) && { type: body.type }, - ...(body.claims?.org_id) && { orgId: body.claims.org_id }, - ...(body.claims?.project_id) && { projectId: body.claims.project_id }, - ...(body.claims?.pipeline_definition_id) && { - pipelineDefinitionId: body.claims.pipeline_definition_id, + ...(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?.vcs_origin) && { vcsOrigin: body.claims.vcs_origin }, - ...(body.claims?.context_ids) && { contextIds: body.claims.context_ids }, + ...(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'] }, } } diff --git a/test/lib/commands/trust/circleci.js b/test/lib/commands/trust/circleci.js index 87fc72044ddce..613609a564fcf 100644 --- a/test/lib/commands/trust/circleci.js +++ b/test/lib/commands/trust/circleci.js @@ -321,11 +321,11 @@ t.test('bodyToOptions with all fields', t => { id: 'test-id', type: 'circleci', claims: { - 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_ids: ['123e4567-e89b-12d3-a456-426614174000'], + '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'], }, } @@ -348,10 +348,10 @@ t.test('bodyToOptions without optional context_ids', t => { id: 'test-id', type: 'circleci', claims: { - 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', + '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', }, } @@ -375,11 +375,11 @@ t.test('optionsToBody with all fields', t => { const body = TrustCircleCI.optionsToBody(options) t.equal(body.type, 'circleci', 'type should be circleci') - t.equal(body.claims.org_id, '550e8400-e29b-41d4-a716-446655440000', 'org_id should be set') - t.equal(body.claims.project_id, '7c9e6679-7425-40de-944b-e07fc1f90ae7', 'project_id should be set') - t.equal(body.claims.pipeline_definition_id, '6ba7b810-9dad-11d1-80b4-00c04fd430c8', 'pipeline_definition_id should be set') - t.equal(body.claims.vcs_origin, 'github.com/owner/repo', 'vcs_origin should be set') - t.same(body.claims.context_ids, ['123e4567-e89b-12d3-a456-426614174000'], 'context_ids should be set') + 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() }) @@ -395,7 +395,7 @@ t.test('optionsToBody without optional contextIds', t => { const body = TrustCircleCI.optionsToBody(options) - t.equal(body.claims.context_ids, undefined, 'context_ids should be undefined') + t.equal(body.claims['oidc.circleci.com/context-ids'], undefined, 'context-ids should be undefined') t.end() }) @@ -415,10 +415,10 @@ t.test('optionsToBody with multiple contextIds', t => { const body = TrustCircleCI.optionsToBody(options) - t.same(body.claims.context_ids, [ + 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') + ], 'context-ids should contain both UUIDs') t.end() }) From 8aad1ca978c94115e0ee0773bedad400b7c17214 Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Thu, 29 Jan 2026 11:12:36 -0800 Subject: [PATCH 28/28] don't redact UUIDs for create/list trust commands --- lib/trust-cmd.js | 3 +- tap-snapshots/test/lib/docs.js.test.cjs | 102 ++++++++++++++---------- 2 files changed, 64 insertions(+), 41 deletions(-) diff --git a/lib/trust-cmd.js b/lib/trust-cmd.js index c267c1373e91c..5fab8df1d21aa 100644 --- a/lib/trust-cmd.js +++ b/lib/trust-cmd.js @@ -55,6 +55,7 @@ class TrustCommand extends BaseCommand { 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 } @@ -95,7 +96,7 @@ class TrustCommand extends BaseCommand { } if (urlLines.length > 0) { output.standard() - output.standard(urlLines.join('\n')) + output.standard(urlLines.join('\n'), { [META]: true, redact: false }) } } if (pad) { diff --git a/tap-snapshots/test/lib/docs.js.test.cjs b/tap-snapshots/test/lib/docs.js.test.cjs index 9d74cb68595b1..efa04e49b026c 100644 --- a/tap-snapshots/test/lib/docs.js.test.cjs +++ b/tap-snapshots/test/lib/docs.js.test.cjs @@ -216,6 +216,24 @@ upon by the current project. +#### \`allow-git\` + +* Default: "all" +* Type: "all", "none", or "root" + +Limits the ability for npm to fetch dependencies from git references. That +is, dependencies that point to a git repo instead of a version or semver +range. Please note that this could leave your tree incomplete and some +packages may not function as intended or designed. + +\`all\` allows any git dependencies to be fetched and installed. \`none\` +prevents any git dependencies from being fetched and installed. \`root\` only +allows git dependencies defined in your project's package.json to be fetched +installed. Also allows git dependencies to be fetched for other commands +like \`npm view\` + + + #### \`allow-same-version\` * Default: false @@ -2211,6 +2229,7 @@ Array [ "access", "all", "allow-same-version", + "allow-git", "also", "audit", "audit-level", @@ -2385,6 +2404,7 @@ Array [ "access", "all", "allow-same-version", + "allow-git", "also", "audit", "audit-level", @@ -2563,6 +2583,7 @@ Object { "_auth": null, "access": null, "all": false, + "allowGit": "all", "allowSameVersion": false, "audit": true, "auditLevel": null, @@ -2990,8 +3011,9 @@ Options: [--install-strategy ] [--legacy-bundling] [--global-style] [--omit [--omit ...]] [--include [--include ...]] -[--strict-peer-deps] [--foreground-scripts] [--ignore-scripts] [--no-audit] -[--no-bin-links] [--no-fund] [--dry-run] +[--strict-peer-deps] [--foreground-scripts] [--ignore-scripts] +[--allow-git ] [--no-audit] [--no-bin-links] [--no-fund] +[--dry-run] [-w|--workspace [-w|--workspace ...]] [--workspaces] [--include-workspace-root] [--install-links] @@ -3019,6 +3041,9 @@ Options: --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 @@ -3062,6 +3087,7 @@ aliases: clean-install, ic, install-clean, isntall-clean #### \`strict-peer-deps\` #### \`foreground-scripts\` #### \`ignore-scripts\` +#### \`allow-git\` #### \`audit\` #### \`bin-links\` #### \`fund\` @@ -3158,7 +3184,8 @@ Options: [--global-style] [--strict-peer-deps] [--no-package-lock] [--omit [--omit ...]] [--include [--include ...]] -[--ignore-scripts] [--no-audit] [--no-bin-links] [--no-fund] [--dry-run] +[--ignore-scripts] [--allow-git ] [--no-audit] [--no-bin-links] +[--no-fund] [--dry-run] [-w|--workspace [-w|--workspace ...]] [--workspaces] [--include-workspace-root] [--install-links] @@ -3186,6 +3213,9 @@ Options: --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 @@ -3229,6 +3259,7 @@ alias: ddp #### \`omit\` #### \`include\` #### \`ignore-scripts\` +#### \`allow-git\` #### \`audit\` #### \`bin-links\` #### \`fund\` @@ -3878,9 +3909,9 @@ Options: [--global-style] [--omit [--omit ...]] [--include [--include ...]] [--strict-peer-deps] [--prefer-dedupe] [--no-package-lock] [--package-lock-only] -[--foreground-scripts] [--ignore-scripts] [--no-audit] [--before ] -[--no-bin-links] [--no-fund] [--dry-run] [--cpu ] [--os ] -[--libc ] +[--foreground-scripts] [--ignore-scripts] [--allow-git ] +[--no-audit] [--before ] [--no-bin-links] [--no-fund] [--dry-run] +[--cpu ] [--os ] [--libc ] [-w|--workspace [-w|--workspace ...]] [--workspaces] [--include-workspace-root] [--install-links] @@ -3926,6 +3957,9 @@ Options: --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 @@ -3987,6 +4021,7 @@ aliases: add, i, in, ins, inst, insta, instal, isnt, isnta, isntal, isntall #### \`package-lock-only\` #### \`foreground-scripts\` #### \`ignore-scripts\` +#### \`allow-git\` #### \`audit\` #### \`before\` #### \`bin-links\` @@ -4011,8 +4046,9 @@ Options: [--install-strategy ] [--legacy-bundling] [--global-style] [--omit [--omit ...]] [--include [--include ...]] -[--strict-peer-deps] [--foreground-scripts] [--ignore-scripts] [--no-audit] -[--no-bin-links] [--no-fund] [--dry-run] +[--strict-peer-deps] [--foreground-scripts] [--ignore-scripts] +[--allow-git ] [--no-audit] [--no-bin-links] [--no-fund] +[--dry-run] [-w|--workspace [-w|--workspace ...]] [--workspaces] [--include-workspace-root] [--install-links] @@ -4040,6 +4076,9 @@ Options: --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 @@ -4083,6 +4122,7 @@ aliases: cit, clean-install-test, sit #### \`strict-peer-deps\` #### \`foreground-scripts\` #### \`ignore-scripts\` +#### \`allow-git\` #### \`audit\` #### \`bin-links\` #### \`fund\` @@ -4106,9 +4146,9 @@ Options: [--global-style] [--omit [--omit ...]] [--include [--include ...]] [--strict-peer-deps] [--prefer-dedupe] [--no-package-lock] [--package-lock-only] -[--foreground-scripts] [--ignore-scripts] [--no-audit] [--before ] -[--no-bin-links] [--no-fund] [--dry-run] [--cpu ] [--os ] -[--libc ] +[--foreground-scripts] [--ignore-scripts] [--allow-git ] +[--no-audit] [--before ] [--no-bin-links] [--no-fund] [--dry-run] +[--cpu ] [--os ] [--libc ] [-w|--workspace [-w|--workspace ...]] [--workspaces] [--include-workspace-root] [--install-links] @@ -4154,6 +4194,9 @@ Options: --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 @@ -4215,6 +4258,7 @@ alias: it #### \`package-lock-only\` #### \`foreground-scripts\` #### \`ignore-scripts\` +#### \`allow-git\` #### \`audit\` #### \`before\` #### \`bin-links\` @@ -4242,7 +4286,8 @@ Options: [--global-style] [--strict-peer-deps] [--no-package-lock] [--omit [--omit ...]] [--include [--include ...]] -[--ignore-scripts] [--no-audit] [--no-bin-links] [--no-fund] [--dry-run] +[--ignore-scripts] [--allow-git ] [--no-audit] [--no-bin-links] +[--no-fund] [--dry-run] [-w|--workspace [-w|--workspace ...]] [--workspaces] [--include-workspace-root] [--install-links] @@ -4279,6 +4324,9 @@ Options: --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 @@ -4325,6 +4373,7 @@ alias: ln #### \`omit\` #### \`include\` #### \`ignore-scripts\` +#### \`allow-git\` #### \`audit\` #### \`bin-links\` #### \`fund\` @@ -5731,41 +5780,14 @@ Note: This command is unaware of workspaces. #### Synopsis #### Flags -#### \`file\` -#### \`repository\` -#### \`environment\` -#### \`yes\` -#### \`json\` -#### \`registry\` -#### \`dry-run\` #### Synopsis #### Flags -#### \`file\` -#### \`project\` -#### \`environment\` -#### \`yes\` -#### \`json\` -#### \`registry\` -#### \`dry-run\` #### Synopsis #### Flags -#### \`org-id\` -#### \`project-id\` -#### \`pipeline-definition-id\` -#### \`vcs-origin\` -#### \`context-id\` -#### \`yes\` -#### \`json\` -#### \`dry-run\` #### Synopsis -#### Configuration -#### \`json\` -#### \`registry\` +#### Flags #### Synopsis #### Flags -#### \`id\` -#### \`dry-run\` -#### \`registry\` ` exports[`test/lib/docs.js TAP usage undeprecate > must match snapshot 1`] = `