From 58adf513ed89affa87174362ceb26328eba5caa3 Mon Sep 17 00:00:00 2001 From: reggi Date: Tue, 7 Apr 2026 15:28:18 -0400 Subject: [PATCH 1/3] feat: npm stage Adds staged publishing support with subcommands: publish, list, view, approve, reject, and download. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/lib/content/commands/npm-stage.md | 142 +++++++ docs/lib/content/nav.yml | 3 + lib/commands/publish.js | 51 ++- lib/commands/stage/approve.js | 35 ++ lib/commands/stage/download.js | 69 ++++ lib/commands/stage/index.js | 17 + lib/commands/stage/list.js | 72 ++++ lib/commands/stage/publish.js | 13 + lib/commands/stage/reject.js | 37 ++ lib/commands/stage/view.js | 34 ++ lib/commands/trust/circleci.js | 8 +- lib/utils/cmd-list.js | 1 + lib/utils/display.js | 7 +- lib/utils/key-values.js | 42 ++ lib/utils/validate-uuid.js | 10 + tap-snapshots/TAP.test.cjs | 465 +++++++++++++++++++++++ test/lib/commands/stage/approve.js | 31 ++ test/lib/commands/stage/download.js | 107 ++++++ test/lib/commands/stage/index.js | 320 ++++++++++++++++ test/lib/commands/stage/list.js | 147 +++++++ test/lib/commands/stage/reject.js | 31 ++ test/lib/commands/stage/view.js | 59 +++ test/lib/utils/key-values.js | 67 ++++ workspaces/libnpmpublish/lib/publish.js | 17 +- workspaces/libnpmpublish/test/publish.js | 25 ++ 25 files changed, 1786 insertions(+), 24 deletions(-) create mode 100644 docs/lib/content/commands/npm-stage.md create mode 100644 lib/commands/stage/approve.js create mode 100644 lib/commands/stage/download.js create mode 100644 lib/commands/stage/index.js create mode 100644 lib/commands/stage/list.js create mode 100644 lib/commands/stage/publish.js create mode 100644 lib/commands/stage/reject.js create mode 100644 lib/commands/stage/view.js create mode 100644 lib/utils/key-values.js create mode 100644 lib/utils/validate-uuid.js create mode 100644 tap-snapshots/TAP.test.cjs create mode 100644 test/lib/commands/stage/approve.js create mode 100644 test/lib/commands/stage/download.js create mode 100644 test/lib/commands/stage/index.js create mode 100644 test/lib/commands/stage/list.js create mode 100644 test/lib/commands/stage/reject.js create mode 100644 test/lib/commands/stage/view.js create mode 100644 test/lib/utils/key-values.js diff --git a/docs/lib/content/commands/npm-stage.md b/docs/lib/content/commands/npm-stage.md new file mode 100644 index 0000000000000..c68f0af4a7672 --- /dev/null +++ b/docs/lib/content/commands/npm-stage.md @@ -0,0 +1,142 @@ +--- +title: npm-stage +section: 1 +description: Stage packages for publishing +--- + +### Synopsis + + + +### Description + +Staged publishing allows package maintainers to require proof-of-presence +for all publishes. Proof-of-presence is where a human is involved, +interjects, and provides authentication (2FA) during an action — in this +case, publishing an npm package. + +Typically when maintainers use automated workflows to publish, +proof-of-presence is lacking as there's no convenient way to interject the +process and provide 2FA, as is the case for publishing with a granular +access token with bypass and the trusted publishing flow. Staged publishing +allows users to have their automated workflows stage a package without a 2FA +prompt, deferring the act of 2FA, allowing the maintainer to approve the +staged package and publish at a later point. + +The `npm stage publish` command packs the current working directory and +places that version of the package into the registry in a state where it's +not available for public access, allowing maintainers to approve the package +at a later point in time. The act of staging does not prompt for 2FA and can be done with any token +type, the act of approving will. + +Key behaviors: + +* Staged packages share the same semver version unique index as published + packages — you cannot publish a version that already exists as a staged + version for that package. +* You can still publish packages normally while you have staged packages + pending. +* You can stage multiple versions of the same package. +* `npm stage publish` has parity with `npm publish` and will respect + `"private": true` in `package.json`, refusing to stage the package. + +### Prerequisites + +Before using `npm stage` commands, ensure the following requirements are met: + +* **Write permissions on the package:** You must have write access to the + package you're configuring. +* **Package must exist:** The package you're configuring must already exist + on the npm registry. +* **2FA enabled on your account:** Commands that require 2FA will prompt you + to authenticate. If you don't already have 2FA enabled on your account, + you must enable it before using these commands. + +### Subcommands + +* `npm stage publish []` - Stage a package for publishing +* `npm stage list []` - List all staged package versions +* `npm stage view ` - View details of a specific staged package +* `npm stage approve ` - Approve a staged package for publishing +* `npm stage reject ` - Reject a staged package +* `npm stage download ` - Download the tarball for inspection + +### 2FA Requirements by Subcommand + +| Command | Requires 2FA | Notes | +| --- | --- | --- | +| `npm stage publish` | No | Designed for automated workflows; defers 2FA to approval | +| `npm stage list` | Yes | Prompts for 2FA to view staged packages | +| `npm stage view` | Yes | Prompts for 2FA to view staged package details | +| `npm stage approve` | Yes | Prompts for 2FA to publish the staged package | +| `npm stage reject` | Yes | Prompts for 2FA to permanently remove the staged package | +| `npm stage download` | No | Downloads the tarball for local inspection | + +### Tag Behavior + +The `--tag` flag follows the same logic as `npm publish`. If no tag is +provided, the `latest` tag is used by default. For pre-release versions +(e.g., `1.0.0-beta.1`) and non-latest semver versions, the tag must be +explicitly provided — otherwise the CLI will error, just as `npm publish` +would. + +The tag is an immutable property of the staged package. Once a package is +staged with a given tag, the tag cannot be changed. If you need to stage the +same version with a different tag, you must first reject the existing staged +package using `npm stage reject` and then re-stage it with the desired tag. + +### Token Behavior + +The key difference with staged publishing is that `npm stage publish` never +requires a 2FA prompt, regardless of token type. This is what makes it +suitable for automated workflows. The goal of `npm stage publish` is +deferring proof-of-presence to a later point in time. + +| Token Type | `npm stage publish` | `npm publish` | +| --- | --- | --- | +| GAT with bypass | Can stage | Can publish (if allowed by package publishing access) | +| GAT without bypass | Can stage | 2FA prompt (if allowed by package publishing access) | +| Session token | Can stage | 2FA prompt | +| Trust token (OIDC) | Can stage (if allowed) | Can publish (if allowed) | + +### Trust Relationship Permissions + +With staged publishing, trust relationships now support granular command +permissions. Shortlived tokens issued through trust relationships can only be +used with `npm stage publish` and `npm publish`. Shortlived tokens cannot run +`npm stage` subcommands. + +`npm trust ` supports `--allow-publish` and `--allow-stage-publish` +to control which commands are available through each trust relationship. + +### Best Practices + +**Note:** The addition of staged publishing does not make your account or org +more secure. Maintainers must still use the best practices listed below. + +1. **Delete Granular Access Tokens (GAT) with bypass 2FA enabled.** + Now with staged publishing, we've eliminated the need for a GAT token + that can bypass 2FA. We encourage you to delete all your tokens with + bypass enabled and switch to using a trust relationship in your automated + workflows, or create a GAT without bypass and use `npm stage publish`. + +2. **Disallow tokens from publishing at the package level.** + All packages have their own access controls under "package access" + allowing packages to be published with bypass tokens, which is no longer + a necessity. We encourage you to select "Require two-factor + authentication and disallow tokens (recommended)" for all your packages + on the package access page. + +3. **Configure trust relationship permissions to prevent `npm publish`.** + We encourage you to only enable `npm stage publish` on your trust + relationships and disable `npm publish`. + +### Configuration + + + +### See Also + +* [npm publish](/commands/npm-publish) +* [npm unpublish](/commands/npm-unpublish) +* [npm trust](/commands/npm-trust) diff --git a/docs/lib/content/nav.yml b/docs/lib/content/nav.yml index 92fb860f6cd6e..224354d32789b 100644 --- a/docs/lib/content/nav.yml +++ b/docs/lib/content/nav.yml @@ -162,6 +162,9 @@ - title: npm shrinkwrap url: /commands/npm-shrinkwrap description: Lock down dependency versions for publication + - title: npm stage + url: /commands/npm-stage + description: Stage packages for publishing - title: npm start url: /commands/npm-start description: Start a package diff --git a/lib/commands/publish.js b/lib/commands/publish.js index 79e2d46ef0db8..6a5f92502ec74 100644 --- a/lib/commands/publish.js +++ b/lib/commands/publish.js @@ -1,4 +1,4 @@ -const { log, output } = require('proc-log') +const { log, output, META } = require('proc-log') const semver = require('semver') const pack = require('libnpmpack') const libpub = require('libnpmpublish').publish @@ -14,11 +14,17 @@ const { getContents, logTar } = require('../utils/tar.js') const { flatten } = require('@npmcli/config/lib/definitions') const pkgJson = require('@npmcli/package-json') const BaseCommand = require('../base-cmd.js') -const { oidc } = require('../../lib/utils/oidc.js') +const { oidc } = require('../utils/oidc.js') class Publish extends BaseCommand { static description = 'Publish a package' static name = 'publish' + static stage = false + + get isStage () { + return this.constructor.stage + } + static params = [ 'tag', 'access', @@ -60,13 +66,18 @@ class Publish extends BaseCommand { if (err.code !== 'EPRIVATE') { throw err } - log.warn('publish', `Skipping workspace ${this.npm.chalk.cyan(name)}, marked as ${this.npm.chalk.bold('private')}`) + log.warn(this.#command, `Skipping workspace ${this.npm.chalk.cyan(name)}, marked as ${this.npm.chalk.bold('private')}`) } } } + get #command () { + return this.isStage ? 'stage' : 'publish' + } + async #publish (args, { workspace } = {}) { - log.verbose('publish', replaceInfo(args)) + const command = this.#command + log.verbose(command, replaceInfo(args)) const unicode = this.npm.config.get('unicode') const dryRun = this.npm.config.get('dry-run') @@ -138,7 +149,6 @@ class Publish extends BaseCommand { const noCreds = !(creds.token || creds.username || creds.certfile && creds.keyfile) const outputRegistry = replaceInfo(registry) - // if a workspace package is marked private then we skip it if (workspace && manifest.private) { throw Object.assign( new Error(`This package has been marked as private @@ -150,7 +160,7 @@ class Publish extends BaseCommand { if (noCreds) { const msg = `This command requires you to be logged in to ${outputRegistry}` if (dryRun) { - log.warn('', `${msg} (dry-run)`) + log.warn(command, `${msg} (dry-run)`) } else { throw Object.assign(new Error(msg), { code: 'ENEEDAUTH' }) } @@ -171,19 +181,29 @@ class Publish extends BaseCommand { } const access = opts.access === null ? 'default' : opts.access - let msg = `Publishing to ${outputRegistry} with tag ${defaultTag} and ${access} access` + const verb = this.isStage ? 'Staging' : 'Publishing' + let msg = `${verb} to ${outputRegistry} with tag ${defaultTag} and ${access} access` if (dryRun) { msg = `${msg} (dry-run)` } log.notice('', msg) + let stageId if (!dryRun) { - await otplease(this.npm, opts, o => libpub(manifest, tarballData, o)) + if (this.isStage) { + const res = await libpub(manifest, tarballData, { ...opts, command, stage: true }) + stageId = res.stageId + } else { + await otplease(this.npm, opts, o => libpub(manifest, tarballData, o)) + } } // In json mode we don't log until the publish has completed as this will add it to the output only if completes successfully if (json) { + if (stageId) { + pkgContents.stageId = stageId + } logPkg() } @@ -204,7 +224,15 @@ class Publish extends BaseCommand { } if (!json && !silent) { - output.standard(`+ ${pkgContents.id}`) + if (this.isStage) { + const stagedMsg = stageId + ? `+ ${pkgContents.id} (staged with id ${stageId})` + : `+ ${pkgContents.id} (staged)` + output.standard(stagedMsg, { [META]: true, redact: false }) + log.notice(command, `package ${pkgContents.id} has been staged with tag ${defaultTag}`) + } else { + output.standard(`+ ${pkgContents.id}`) + } } } @@ -240,13 +268,14 @@ class Publish extends BaseCommand { // otherwise, get the full metadata from whatever it is // XXX can't pacote read the manifest from a directory? async #getManifest (spec, opts, logWarnings = false) { + const command = this.#command let manifest if (spec.type === 'directory') { const changes = [] const pkg = await pkgJson.fix(spec.fetchSpec, { changes }) if (changes.length && logWarnings) { - log.warn('publish', 'npm auto-corrected some errors in your package.json when publishing. Please run "npm pkg fix" to address these errors.') - log.warn('publish', `errors corrected:\n${changes.join('\n')}`) + log.warn(command, 'npm auto-corrected some errors in your package.json when publishing. Please run "npm pkg fix" to address these errors.') + log.warn(command, `errors corrected:\n${changes.join('\n')}`) } // Prepare is the special function for publishing, different than normalize const { content } = await pkg.prepare() diff --git a/lib/commands/stage/approve.js b/lib/commands/stage/approve.js new file mode 100644 index 0000000000000..619015d0c8a55 --- /dev/null +++ b/lib/commands/stage/approve.js @@ -0,0 +1,35 @@ +const { log, output, META } = require('proc-log') +const npmFetch = require('npm-registry-fetch') +const { otplease } = require('../../utils/auth.js') +const { validateUUID } = require('../../utils/validate-uuid.js') +const BaseCommand = require('../../base-cmd.js') + +class StageApprove extends BaseCommand { + static description = 'Approve a staged package, publishing it to the npm registry' + static name = 'approve' + static usage = [''] + static params = ['otp', 'registry'] + static positionals = 1 + + async exec (args) { + if (!args[0]) { + throw this.usageError('Missing required ') + } + const stageId = args[0] + validateUUID(stageId, 'stage-id') + const opts = { ...this.npm.flatOptions } + + log.notice('', `Approving staged package ${stageId}`) + + await otplease(this.npm, opts, o => + npmFetch.json(`/-/stage/${stageId}/approve`, { + ...o, + method: 'POST', + }) + ) + + output.standard(`Staged package ${stageId} approved and published successfully.`, { [META]: true, redact: false }) + } +} + +module.exports = StageApprove diff --git a/lib/commands/stage/download.js b/lib/commands/stage/download.js new file mode 100644 index 0000000000000..660f207c4f988 --- /dev/null +++ b/lib/commands/stage/download.js @@ -0,0 +1,69 @@ +const { log, output, META } = require('proc-log') +const { writeFile } = require('node:fs/promises') +const { resolve } = require('node:path') +const tar = require('tar') +const npmFetch = require('npm-registry-fetch') +const { getContents, logTar } = require('../../utils/tar.js') +const { validateUUID } = require('../../utils/validate-uuid.js') +const BaseCommand = require('../../base-cmd.js') + +class StageDownload extends BaseCommand { + static description = 'Download the tarball of a staged package for inspection' + static name = 'download' + static usage = [''] + static params = ['json', 'registry'] + static positionals = 1 + + async exec (args) { + if (!args[0]) { + throw this.usageError('Missing required ') + } + const stageId = args[0] + validateUUID(stageId, 'stage-id') + const opts = { ...this.npm.flatOptions } + const unicode = this.npm.config.get('unicode') + const json = this.npm.config.get('json') + + log.notice('', `Downloading staged package ${stageId}`) + + const res = await npmFetch(`/-/stage/${stageId}/tarball`, opts) + const data = Buffer.from(await res.arrayBuffer()) + + const manifest = await this.#readManifestFromTarball(data) + const pkgContents = await getContents(manifest, data) + logTar(pkgContents, { unicode, json }) + + const safeName = manifest.name.replace('@', '').replace('/', '-') + const filename = `${safeName}-${stageId}.tgz` + const dest = resolve(process.cwd(), filename) + + await writeFile(dest, data) + if (!json) { + output.standard(filename, { [META]: true, redact: false }) + } + } + + async #readManifestFromTarball (tarballData) { + let manifestJson + const stream = tar.t({ + onentry (entry) { + if (entry.path === 'package/package.json') { + const chunks = [] + entry.on('data', c => chunks.push(c)) + entry.on('end', () => { + manifestJson = JSON.parse(Buffer.concat(chunks).toString()) + }) + } else { + entry.resume() + } + }, + }) + stream.end(tarballData) + if (!manifestJson) { + throw new Error('Could not read package.json from tarball') + } + return manifestJson + } +} + +module.exports = StageDownload diff --git a/lib/commands/stage/index.js b/lib/commands/stage/index.js new file mode 100644 index 0000000000000..4605df9768c81 --- /dev/null +++ b/lib/commands/stage/index.js @@ -0,0 +1,17 @@ +const BaseCommand = require('../../base-cmd.js') + +class Stage extends BaseCommand { + static description = 'Stage packages for publishing, deferring proof-of-presence (2FA) to a later point in time' + static name = 'stage' + + static subcommands = { + publish: require('./publish.js'), + list: require('./list.js'), + view: require('./view.js'), + approve: require('./approve.js'), + reject: require('./reject.js'), + download: require('./download.js'), + } +} + +module.exports = Stage diff --git a/lib/commands/stage/list.js b/lib/commands/stage/list.js new file mode 100644 index 0000000000000..e6b6a93fcee9b --- /dev/null +++ b/lib/commands/stage/list.js @@ -0,0 +1,72 @@ +const { output } = require('proc-log') +const npa = require('npm-package-arg') +const npmFetch = require('npm-registry-fetch') +const { logStageItem } = require('../../utils/key-values.js') +const BaseCommand = require('../../base-cmd.js') + +class StageList extends BaseCommand { + static description = 'List all staged package versions' + static name = 'list' + static usage = ['[]'] + static params = ['json', 'registry'] + + async exec (args) { + let packageFilter = null + if (args[0]) { + const spec = npa(args[0]) + if (spec.rawSpec !== '*') { + throw this.usageError('Version specifiers are not supported for listing staged packages') + } + packageFilter = spec.name + } + const opts = { ...this.npm.flatOptions } + const json = this.npm.config.get('json') + + const allItems = await this.#fetchAllPages(opts, packageFilter) + + if (json) { + output.buffer(allItems) + return + } + + if (allItems.length === 0) { + if (packageFilter) { + output.standard(`No staged versions of package name "${packageFilter}".`) + } else { + output.standard('No staged packages found.') + } + return + } + + for (let i = 0; i < allItems.length; i++) { + if (i > 0) { + output.standard('') + } + logStageItem(allItems[i], { chalk: this.npm.chalk }) + } + } + + async #fetchAllPages (opts, packageFilter) { + const items = [] + let page = 0 + const perPage = 100 + while (true) { + const query = { page, perPage } + if (packageFilter) { + query.package = packageFilter + } + const res = await npmFetch.json('/-/stage', { + ...opts, + query, + }) + items.push(...res.items) + if (items.length >= res.total || res.items.length < perPage) { + break + } + page++ + } + return items + } +} + +module.exports = StageList diff --git a/lib/commands/stage/publish.js b/lib/commands/stage/publish.js new file mode 100644 index 0000000000000..d58e1b753b8b2 --- /dev/null +++ b/lib/commands/stage/publish.js @@ -0,0 +1,13 @@ +const Publish = require('../publish.js') + +class StagePublish extends Publish { + static description = 'Stage a package for publishing, deferring proof-of-presence (2FA) to a later point in time' + static name = 'stage' + static stage = true + static params = Publish.params + static usage = Publish.usage + static workspaces = true + static ignoreImplicitWorkspace = false +} + +module.exports = StagePublish diff --git a/lib/commands/stage/reject.js b/lib/commands/stage/reject.js new file mode 100644 index 0000000000000..2f29a95ea4e96 --- /dev/null +++ b/lib/commands/stage/reject.js @@ -0,0 +1,37 @@ +const { log, output, META } = require('proc-log') +const npmFetch = require('npm-registry-fetch') +const { otplease } = require('../../utils/auth.js') +const { validateUUID } = require('../../utils/validate-uuid.js') +const BaseCommand = require('../../base-cmd.js') + +class StageReject extends BaseCommand { + static description = 'Reject a staged package, removing it from the registry' + static name = 'reject' + static usage = [''] + static params = ['otp', 'registry'] + static positionals = 1 + + async exec (args) { + if (!args[0]) { + throw this.usageError('Missing required ') + } + const stageId = args[0] + validateUUID(stageId, 'stage-id') + const opts = { ...this.npm.flatOptions } + + log.notice('', `Rejecting staged package ${stageId}`) + log.warn('', 'Rejecting will permanently delete this staged publish record and tarball from the registry.') + + await otplease(this.npm, opts, o => + npmFetch(`/-/stage/${stageId}`, { + ...o, + method: 'DELETE', + ignoreBody: true, + }) + ) + + output.standard(`Staged package ${stageId} has been rejected.`, { [META]: true, redact: false }) + } +} + +module.exports = StageReject diff --git a/lib/commands/stage/view.js b/lib/commands/stage/view.js new file mode 100644 index 0000000000000..ba403014dbf50 --- /dev/null +++ b/lib/commands/stage/view.js @@ -0,0 +1,34 @@ +const { output } = require('proc-log') +const npmFetch = require('npm-registry-fetch') +const { logStageItem } = require('../../utils/key-values.js') +const { validateUUID } = require('../../utils/validate-uuid.js') +const BaseCommand = require('../../base-cmd.js') + +class StageView extends BaseCommand { + static description = 'View details of a specific staged package' + static name = 'view' + static usage = [''] + static params = ['json', 'registry'] + static positionals = 1 + + async exec (args) { + if (!args[0]) { + throw this.usageError('Missing required ') + } + const stageId = args[0] + validateUUID(stageId, 'stage-id') + const opts = { ...this.npm.flatOptions } + const json = this.npm.config.get('json') + + const item = await npmFetch.json(`/-/stage/${stageId}`, opts) + + if (json) { + output.buffer(item) + return + } + + logStageItem(item, { chalk: this.npm.chalk }) + } +} + +module.exports = StageView diff --git a/lib/commands/trust/circleci.js b/lib/commands/trust/circleci.js index 34d25b8018268..3c706d4ee76b0 100644 --- a/lib/commands/trust/circleci.js +++ b/lib/commands/trust/circleci.js @@ -1,9 +1,7 @@ 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 +const { validateUUID } = require('../../utils/validate-uuid.js') class TrustCircleCI extends TrustCommand { static description = 'Create a trusted relationship between a package and CircleCI' @@ -54,9 +52,7 @@ class TrustCircleCI extends TrustCommand { ] validateUuid (value, fieldName) { - if (!UUID_REGEX.test(value)) { - throw new Error(`${fieldName} must be a valid UUID`) - } + validateUUID(value, fieldName) } validateVcsOrigin (value) { diff --git a/lib/utils/cmd-list.js b/lib/utils/cmd-list.js index f72e7c09da4c7..1043282913f9b 100644 --- a/lib/utils/cmd-list.js +++ b/lib/utils/cmd-list.js @@ -54,6 +54,7 @@ const commands = [ 'search', 'set', 'shrinkwrap', + 'stage', 'start', 'stop', 'team', diff --git a/lib/utils/display.js b/lib/utils/display.js index 2ea597eb47ade..eb8861e0e5d21 100644 --- a/lib/utils/display.js +++ b/lib/utils/display.js @@ -293,7 +293,12 @@ class Display { if (this.#json) { const json = getJsonBuffer(meta, this.#outputState.buffer) if (json) { - this.#writeOutput(output.KEYS.standard, meta, JSON.stringify(json, null, 2)) + // JSON output is structured data for programmatic + // consumption and should not have its values redacted + const jsonMeta = { ...meta, redact: false } + this.#writeOutput( + output.KEYS.standard, jsonMeta, JSON.stringify(json, null, 2) + ) } } else { this.#outputState.buffer.forEach((item) => this.#writeOutput(...item)) diff --git a/lib/utils/key-values.js b/lib/utils/key-values.js new file mode 100644 index 0000000000000..5160f4ee18a54 --- /dev/null +++ b/lib/utils/key-values.js @@ -0,0 +1,42 @@ +const { output, META } = require('proc-log') + +const defaultPredicate = (key, value, chalk) => { + if (value === null || value === undefined) { + return null + } + return chalk.green(value) +} + +function logObject (values, { chalk, json, predicate = defaultPredicate }) { + if (json) { + output.standard(JSON.stringify(values, null, 2), { [META]: true, redact: false }) + return + } + + const lines = [] + for (const [key, value] of Object.entries(values)) { + const formatted = predicate(key, value, chalk) + if (formatted !== null) { + lines.push(`${chalk.cyan(key)}: ${formatted}`) + } + } + if (lines.length) { + output.standard(lines.join('\n'), { [META]: true, redact: false }) + } +} + +function logStageItem (item, { chalk }) { + const { id, packageName, version, tag, createdAt, actor, actorType, shasum, ...rest } = item + logObject({ + id, + 'package name': packageName, + version, + tag, + 'date staged': createdAt, + 'staged by': `${actor} (${actorType})`, + shasum, + ...rest, + }, { chalk }) +} + +module.exports = { logObject, logStageItem, defaultPredicate } diff --git a/lib/utils/validate-uuid.js b/lib/utils/validate-uuid.js new file mode 100644 index 0000000000000..d5842429303f6 --- /dev/null +++ b/lib/utils/validate-uuid.js @@ -0,0 +1,10 @@ +// 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 + +const validateUUID = (value, fieldName) => { + if (!UUID_REGEX.test(value)) { + throw new Error(`${fieldName} must be a valid UUID`) + } +} + +module.exports = { UUID_REGEX, validateUUID } diff --git a/tap-snapshots/TAP.test.cjs b/tap-snapshots/TAP.test.cjs new file mode 100644 index 0000000000000..7017b77675e4b --- /dev/null +++ b/tap-snapshots/TAP.test.cjs @@ -0,0 +1,465 @@ +/* IMPORTANT + * This snapshot file is auto-generated, but designed for humans. + * It should be checked into source control and tracked carefully. + * Re-generate by setting TAP_SNAPSHOT=1 and running tests. + * Make sure to inspect the output below. Do not ignore changes! + */ +'use strict' +exports[` TAP npm.load workspace-aware configs and commands > should exec workspaces version of commands 1`] = ` +Lifecycle scripts included in a@1.0.0: + test + echo test a + +Lifecycle scripts included in b@1.0.0: + test + echo test b +` + +exports[` TAP usage set process.stdout.columns column width 0 > must match snapshot 1`] = ` +npm + +Usage: + +npm install install all the dependencies in your project +npm install add the dependency to your project +npm test run this project's tests +npm run run the script named +npm -h quick help on +npm -l display usage info for all commands +npm help search for help on +npm help npm more involved overview + +All commands: + + 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, + stage, star, stars, start, stop, team, test, token, trust, + undeprecate, uninstall, unpublish, unstar, update, version, + view, whoami + +Specify configs in the ini-formatted file: + {USERCONFIG} +or on the command line via: npm --key=value + +More configuration info: npm help config +Configuration fields: npm help 7 config + +npm@{VERSION} {NPMROOT} +` + +exports[` TAP usage set process.stdout.columns column width 1 > must match snapshot 1`] = ` +npm + +Usage: + +npm install install all the dependencies in your project +npm install add the dependency to your project +npm test run this project's tests +npm run run the script named +npm -h quick help on +npm -l display usage info for all commands +npm help search for help on +npm help npm more involved overview + +All commands: + + 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, stage, star, + stars, start, stop, + team, test, token, + trust, undeprecate, + uninstall, unpublish, + unstar, update, version, + view, whoami + +Specify configs in the ini-formatted file: + {USERCONFIG} +or on the command line via: npm --key=value + +More configuration info: npm help config +Configuration fields: npm help 7 config + +npm@{VERSION} {NPMROOT} +` + +exports[` TAP usage set process.stdout.columns column width 10 > must match snapshot 1`] = ` +npm + +Usage: + +npm install install all the dependencies in your project +npm install add the dependency to your project +npm test run this project's tests +npm run run the script named +npm -h quick help on +npm -l display usage info for all commands +npm help search for help on +npm help npm more involved overview + +All commands: + + 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, stage, star, + stars, start, stop, + team, test, token, + trust, undeprecate, + uninstall, unpublish, + unstar, update, version, + view, whoami + +Specify configs in the ini-formatted file: + {USERCONFIG} +or on the command line via: npm --key=value + +More configuration info: npm help config +Configuration fields: npm help 7 config + +npm@{VERSION} {NPMROOT} +` + +exports[` TAP usage set process.stdout.columns column width 100 > must match snapshot 1`] = ` +npm + +Usage: + +npm install install all the dependencies in your project +npm install add the dependency to your project +npm test run this project's tests +npm run run the script named +npm -h quick help on +npm -l display usage info for all commands +npm help search for help on +npm help npm more involved overview + +All commands: + + 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, + stage, star, stars, start, stop, team, test, token, trust, + undeprecate, uninstall, unpublish, unstar, update, version, + view, whoami + +Specify configs in the ini-formatted file: + {USERCONFIG} +or on the command line via: npm --key=value + +More configuration info: npm help config +Configuration fields: npm help 7 config + +npm@{VERSION} {NPMROOT} +` + +exports[` TAP usage set process.stdout.columns column width 24 > must match snapshot 1`] = ` +npm + +Usage: + +npm install install all the dependencies in your project +npm install add the dependency to your project +npm test run this project's tests +npm run run the script named +npm -h quick help on +npm -l display usage info for all commands +npm help search for help on +npm help npm more involved overview + +All commands: + + 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, stage, star, + stars, start, stop, + team, test, token, + trust, undeprecate, + uninstall, unpublish, + unstar, update, version, + view, whoami + +Specify configs in the ini-formatted file: + {USERCONFIG} +or on the command line via: npm --key=value + +More configuration info: npm help config +Configuration fields: npm help 7 config + +npm@{VERSION} {NPMROOT} +` + +exports[` TAP usage set process.stdout.columns column width 40 > must match snapshot 1`] = ` +npm + +Usage: + +npm install install all the dependencies in your project +npm install add the dependency to your project +npm test run this project's tests +npm run run the script named +npm -h quick help on +npm -l display usage info for all commands +npm help search for help on +npm help npm more involved overview + +All commands: + + 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, stage, star, + stars, start, stop, + team, test, token, + trust, undeprecate, + uninstall, unpublish, + unstar, update, version, + view, whoami + +Specify configs in the ini-formatted file: + {USERCONFIG} +or on the command line via: npm --key=value + +More configuration info: npm help config +Configuration fields: npm help 7 config + +npm@{VERSION} {NPMROOT} +` + +exports[` TAP usage set process.stdout.columns column width 41 > must match snapshot 1`] = ` +npm + +Usage: + +npm install install all the dependencies in your project +npm install add the dependency to your project +npm test run this project's tests +npm run run the script named +npm -h quick help on +npm -l display usage info for all commands +npm help search for help on +npm help npm more involved overview + +All commands: + + 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, + stage, star, stars, + start, stop, team, test, + token, trust, + undeprecate, uninstall, + unpublish, unstar, + update, version, view, + whoami + +Specify configs in the ini-formatted file: + {USERCONFIG} +or on the command line via: npm --key=value + +More configuration info: npm help config +Configuration fields: npm help 7 config + +npm@{VERSION} {NPMROOT} +` + +exports[` TAP usage set process.stdout.columns column width 75 > must match snapshot 1`] = ` +npm + +Usage: + +npm install install all the dependencies in your project +npm install add the dependency to your project +npm test run this project's tests +npm run run the script named +npm -h quick help on +npm -l display usage info for all commands +npm help search for help on +npm help npm more involved overview + +All commands: + + 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, + stage, star, stars, start, stop, team, test, token, trust, + undeprecate, uninstall, unpublish, unstar, update, version, + view, whoami + +Specify configs in the ini-formatted file: + {USERCONFIG} +or on the command line via: npm --key=value + +More configuration info: npm help config +Configuration fields: npm help 7 config + +npm@{VERSION} {NPMROOT} +` + +exports[` TAP usage set process.stdout.columns column width 76 > must match snapshot 1`] = ` +npm + +Usage: + +npm install install all the dependencies in your project +npm install add the dependency to your project +npm test run this project's tests +npm run run the script named +npm -h quick help on +npm -l display usage info for all commands +npm help search for help on +npm help npm more involved overview + +All commands: + + 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, + stage, star, stars, start, stop, team, test, token, trust, + undeprecate, uninstall, unpublish, unstar, update, version, + view, whoami + +Specify configs in the ini-formatted file: + {USERCONFIG} +or on the command line via: npm --key=value + +More configuration info: npm help config +Configuration fields: npm help 7 config + +npm@{VERSION} {NPMROOT} +` + +exports[` TAP usage set process.stdout.columns column width 90 > must match snapshot 1`] = ` +npm + +Usage: + +npm install install all the dependencies in your project +npm install add the dependency to your project +npm test run this project's tests +npm run run the script named +npm -h quick help on +npm -l display usage info for all commands +npm help search for help on +npm help npm more involved overview + +All commands: + + 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, + stage, star, stars, start, stop, team, test, token, trust, + undeprecate, uninstall, unpublish, unstar, update, version, + view, whoami + +Specify configs in the ini-formatted file: + {USERCONFIG} +or on the command line via: npm --key=value + +More configuration info: npm help config +Configuration fields: npm help 7 config + +npm@{VERSION} {NPMROOT} +` diff --git a/test/lib/commands/stage/approve.js b/test/lib/commands/stage/approve.js new file mode 100644 index 0000000000000..c5a24113ded04 --- /dev/null +++ b/test/lib/commands/stage/approve.js @@ -0,0 +1,31 @@ +const t = require('tap') +const { load: loadMockNpm } = require('../../../fixtures/mock-npm.js') +const MockRegistry = require('@npmcli/mock-registry') + +const token = 'test-auth-token' +const authConfig = { '//registry.npmjs.org/:_authToken': token } +const stageId = '1de6f3db-2ed9-4d72-b3dd-8f0e2b474a2f' + +t.test('approves a staged package', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + config: { ...authConfig, otp: '123456' }, + }) + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: token, + }) + registry.nock.post(`/-/stage/${stageId}/approve`) + .reply(201, { message: 'Package version approved and published successfully.' }) + await npm.exec('stage', ['approve', stageId]) + t.match(joinedOutput(), /approved and published successfully/) +}) + +t.test('throws usageError without stage-id', async t => { + const { npm } = await loadMockNpm(t, { + config: authConfig, + }) + await t.rejects(npm.exec('stage', ['approve']), { + code: 'EUSAGE', + }) +}) diff --git a/test/lib/commands/stage/download.js b/test/lib/commands/stage/download.js new file mode 100644 index 0000000000000..b4d748e8fee8a --- /dev/null +++ b/test/lib/commands/stage/download.js @@ -0,0 +1,107 @@ +const t = require('tap') +const fs = require('node:fs') +const path = require('node:path') +const { load: loadMockNpm } = require('../../../fixtures/mock-npm.js') +const MockRegistry = require('@npmcli/mock-registry') +const mockGlobals = require('@npmcli/mock-globals') +const libpack = require('libnpmpack') + +const token = 'test-auth-token' +const authConfig = { '//registry.npmjs.org/:_authToken': token } +const stageId = '1de6f3db-2ed9-4d72-b3dd-8f0e2b474a2f' + +t.test('downloads a staged tarball', async t => { + const { npm, joinedOutput, prefix } = await loadMockNpm(t, { + config: authConfig, + prefixDir: { + 'package.json': JSON.stringify({ + name: '@npmcli/test-package', + version: '1.0.0', + }), + 'index.js': 'module.exports = 42', + }, + }) + const tarballData = await libpack(prefix) + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: token, + }) + registry.nock.get(`/-/stage/${stageId}/tarball`) + .reply(200, tarballData, { 'content-type': 'application/octet-stream' }) + + mockGlobals(t, { 'process.cwd': () => prefix }) + + await npm.exec('stage', ['download', stageId]) + const out = joinedOutput() + const expectedFilename = `npmcli-test-package-${stageId}.tgz` + t.match(out, expectedFilename) + t.ok(fs.existsSync(path.join(prefix, expectedFilename))) +}) + +t.test('downloads with --json', async t => { + const { npm, joinedOutput, prefix } = await loadMockNpm(t, { + config: { ...authConfig, json: true }, + prefixDir: { + 'package.json': JSON.stringify({ + name: '@npmcli/test-package', + version: '1.0.0', + }), + }, + }) + const tarballData = await libpack(prefix) + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: token, + }) + registry.nock.get(`/-/stage/${stageId}/tarball`) + .reply(200, tarballData, { 'content-type': 'application/octet-stream' }) + + mockGlobals(t, { 'process.cwd': () => prefix }) + + await npm.exec('stage', ['download', stageId]) + const out = joinedOutput() + t.notMatch(out, `npmcli-test-package-${stageId}.tgz`) + const expectedFilename = `npmcli-test-package-${stageId}.tgz` + t.ok(fs.existsSync(path.join(prefix, expectedFilename))) +}) + +t.test('throws usageError without stage-id', async t => { + const { npm } = await loadMockNpm(t, { + config: authConfig, + }) + await t.rejects(npm.exec('stage', ['download']), { + code: 'EUSAGE', + }) +}) + +t.test('throws on invalid uuid', async t => { + const { npm } = await loadMockNpm(t, { + config: authConfig, + }) + await t.rejects(npm.exec('stage', ['download', 'not-a-uuid']), { + message: /stage-id must be a valid UUID/, + }) +}) + +t.test('throws when tarball has no package.json', async t => { + const { npm, prefix } = await loadMockNpm(t, { + config: authConfig, + prefixDir: {}, + }) + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: token, + }) + // Empty tar (two 512-byte zero blocks) has no entries + registry.nock.get(`/-/stage/${stageId}/tarball`) + .reply(200, Buffer.alloc(1024), { 'content-type': 'application/octet-stream' }) + + mockGlobals(t, { 'process.cwd': () => prefix }) + + await t.rejects(npm.exec('stage', ['download', stageId]), { + message: /Could not read package.json from tarball/, + }) +}) diff --git a/test/lib/commands/stage/index.js b/test/lib/commands/stage/index.js new file mode 100644 index 0000000000000..b0af4e55d5ddd --- /dev/null +++ b/test/lib/commands/stage/index.js @@ -0,0 +1,320 @@ +const t = require('tap') +const { load: loadMockNpm } = require('../../../fixtures/mock-npm') +const MockRegistry = require('@npmcli/mock-registry') +const path = require('node:path') +const fs = require('node:fs') + +const pkg = '@npmcli/test-package' +const token = 'test-auth-token' +const authConfig = { '//registry.npmjs.org/:_authToken': token } + +const pkgJson = { + name: pkg, + description: 'npm test package', + version: '1.0.0', +} + +t.test('stages a package from cwd', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + config: authConfig, + prefixDir: { + 'package.json': JSON.stringify(pkgJson), + }, + }) + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: token, + }) + registry.nock.post('/-/stage/package/@npmcli%2ftest-package').reply(201, {}) + await npm.exec('stage', ['publish']) + t.match(joinedOutput(), /\+ @npmcli\/test-package@1\.0\.0 \(staged\)/) +}) + +t.test('stages with --dry-run', async t => { + const { npm, joinedOutput, logs } = await loadMockNpm(t, { + config: { ...authConfig, 'dry-run': true }, + prefixDir: { + 'package.json': JSON.stringify(pkgJson), + }, + }) + await npm.exec('stage', ['publish']) + t.ok(logs.notice.some(n => /Staging to .* \(dry-run\)/.test(n))) + t.match(joinedOutput(), /\+ @npmcli\/test-package@1\.0\.0 \(staged\)/) +}) + +t.test('stages with --json', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + config: { ...authConfig, json: true }, + prefixDir: { + 'package.json': JSON.stringify(pkgJson), + }, + }) + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: token, + }) + registry.nock.post('/-/stage/package/@npmcli%2ftest-package').reply(201, {}) + await npm.exec('stage', ['publish']) + const out = JSON.parse(joinedOutput()) + t.equal(out.name, pkg) + t.equal(out.version, '1.0.0') +}) + +t.test('stages with --json includes stageId', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + config: { ...authConfig, json: true }, + prefixDir: { + 'package.json': JSON.stringify(pkgJson), + }, + }) + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: token, + }) + const stageId = 'f8e7a45b-7a5f-4f31-8e6d-9dd1c6ef38c0' + registry.nock.post('/-/stage/package/@npmcli%2ftest-package').reply(201, { stageId }) + await npm.exec('stage', ['publish']) + const out = JSON.parse(joinedOutput()) + t.equal(out.name, pkg) + t.equal(out.stageId, stageId) +}) + +t.test('throws on invalid semver tag', async t => { + const { npm } = await loadMockNpm(t, { + config: { ...authConfig, tag: '1.0.0' }, + prefixDir: { + 'package.json': JSON.stringify(pkgJson), + }, + }) + await t.rejects(npm.exec('stage', ['publish']), { + message: /Tag name must not be a valid SemVer range/, + }) +}) + +t.test('throws ENEEDAUTH with no credentials', async t => { + const { npm } = await loadMockNpm(t, { + config: {}, + prefixDir: { + 'package.json': JSON.stringify(pkgJson), + }, + }) + await t.rejects(npm.exec('stage', ['publish']), { + code: 'ENEEDAUTH', + }) +}) + +t.test('warns on --dry-run with no credentials', async t => { + const { npm, logs } = await loadMockNpm(t, { + config: { 'dry-run': true }, + prefixDir: { + 'package.json': JSON.stringify(pkgJson), + }, + }) + await npm.exec('stage', ['publish']) + t.match(logs.warn, [/requires you to be logged in.*\(dry-run\)/]) +}) + +t.test('stages a package with positional arg', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + config: authConfig, + prefixDir: { + 'package.json': JSON.stringify(pkgJson), + }, + }) + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: token, + }) + registry.nock.post('/-/stage/package/@npmcli%2ftest-package').reply(201, {}) + await npm.exec('stage', ['publish', '.']) + t.match(joinedOutput(), /\+ @npmcli\/test-package@1\.0\.0 \(staged\)/) +}) + +t.test('respects ignore-scripts', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + config: { ...authConfig, 'ignore-scripts': true }, + prefixDir: { + 'package.json': JSON.stringify({ + ...pkgJson, + scripts: { + prepublishOnly: 'exit 1', + publish: 'exit 1', + postpublish: 'exit 1', + }, + }), + }, + }) + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: token, + }) + registry.nock.post('/-/stage/package/@npmcli%2ftest-package').reply(201, {}) + await npm.exec('stage', ['publish']) + t.match(joinedOutput(), /\(staged\)/) +}) + +t.test('foreground-scripts can be set to false', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + config: { ...authConfig, 'foreground-scripts': false }, + prefixDir: { + 'package.json': JSON.stringify(pkgJson), + }, + }) + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: token, + }) + registry.nock.post('/-/stage/package/@npmcli%2ftest-package').reply(201, {}) + await npm.exec('stage', ['publish']) + t.match(joinedOutput(), /\(staged\)/) +}) + +t.test('runs lifecycle scripts', async t => { + const { npm, prefix } = await loadMockNpm(t, { + config: authConfig, + prefixDir: { + 'package.json': JSON.stringify({ + ...pkgJson, + scripts: { + prepublishOnly: 'touch scripts-prepublishonly', + publish: 'touch scripts-publish', + postpublish: 'touch scripts-postpublish', + }, + }), + }, + }) + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: token, + }) + registry.nock.post('/-/stage/package/@npmcli%2ftest-package').reply(201, {}) + await npm.exec('stage', ['publish']) + t.equal(fs.existsSync(path.join(prefix, 'scripts-prepublishonly')), true) + t.equal(fs.existsSync(path.join(prefix, 'scripts-publish')), true) + t.equal(fs.existsSync(path.join(prefix, 'scripts-postpublish')), true) +}) + +t.test('respects publishConfig', async t => { + const alternateRegistry = 'https://other.registry.npmjs.org' + const { npm, joinedOutput, logs } = await loadMockNpm(t, { + config: { + [`${alternateRegistry.slice(6)}/:_authToken`]: 'test-other-token', + }, + prefixDir: { + 'package.json': JSON.stringify({ + ...pkgJson, + publishConfig: { + registry: alternateRegistry, + other: 'unknown-key', + }, + }), + }, + }) + const registry = new MockRegistry({ + tap: t, + registry: alternateRegistry, + authorization: 'test-other-token', + }) + registry.nock.post('/-/stage/package/@npmcli%2ftest-package').reply(201, {}) + await npm.exec('stage', ['publish']) + t.match(joinedOutput(), /\(staged\)/) + t.match(logs.warn, [/Unknown publishConfig/]) +}) + +t.test('warns about auto-corrected package.json errors', async t => { + const { npm, logs } = await loadMockNpm(t, { + config: authConfig, + prefixDir: { + 'package.json': JSON.stringify({ + name: pkg, + version: '1.0.0', + repository: 'github:user/repo', + }), + }, + }) + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: token, + }) + registry.nock.post('/-/stage/package/@npmcli%2ftest-package').reply(201, {}) + await npm.exec('stage', ['publish']) + t.ok(logs.warn.some(w => /auto-corrected/.test(w))) + t.ok(logs.warn.some(w => /errors corrected/.test(w))) +}) + +t.test('stages with basic auth (username)', async t => { + const basic = Buffer.from('test-user:test-password').toString('base64') + const { npm, joinedOutput } = await loadMockNpm(t, { + config: { + '//registry.npmjs.org/:username': 'test-user', + '//registry.npmjs.org/:_password': Buffer.from('test-password').toString('base64'), + }, + prefixDir: { + 'package.json': JSON.stringify(pkgJson), + }, + }) + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + basic, + }) + registry.nock.post('/-/stage/package/@npmcli%2ftest-package').reply(201, {}) + await npm.exec('stage', ['publish']) + t.match(joinedOutput(), /\(staged\)/) +}) + +t.test('stages with cert auth', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + config: { + '//registry.npmjs.org/:certfile': '/path/to/cert', + '//registry.npmjs.org/:keyfile': '/path/to/key', + }, + prefixDir: { + 'package.json': JSON.stringify(pkgJson), + }, + }) + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + }) + registry.nock.post('/-/stage/package/@npmcli%2ftest-package').reply(201, {}) + await npm.exec('stage', ['publish']) + t.match(joinedOutput(), /\(staged\)/) +}) + +t.test('throws EPRIVATE for private packages', async t => { + const { npm } = await loadMockNpm(t, { + config: authConfig, + prefixDir: { + 'package.json': JSON.stringify({ ...pkgJson, private: true }), + }, + }) + await t.rejects(npm.exec('stage', ['publish']), { + code: 'EPRIVATE', + }) +}) + +t.test('outputs stageId when returned', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + config: authConfig, + prefixDir: { + 'package.json': JSON.stringify(pkgJson), + }, + }) + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: token, + }) + registry.nock.post('/-/stage/package/@npmcli%2ftest-package').reply(201, { stageId: 'abc-123' }) + await npm.exec('stage', ['publish']) + t.match(joinedOutput(), /staged with id abc-123/) +}) diff --git a/test/lib/commands/stage/list.js b/test/lib/commands/stage/list.js new file mode 100644 index 0000000000000..e66680db8277f --- /dev/null +++ b/test/lib/commands/stage/list.js @@ -0,0 +1,147 @@ +const t = require('tap') +const { load: loadMockNpm } = require('../../../fixtures/mock-npm.js') +const MockRegistry = require('@npmcli/mock-registry') + +const token = 'test-auth-token' +const authConfig = { '//registry.npmjs.org/:_authToken': token } + +const stageItems = [ + { + id: '1de6f3db-2ed9-4d72-b3dd-8f0e2b474a2f', + packageName: '@npmcli/example-package', + version: '1.2.3', + tag: 'latest', + createdAt: '2026-03-16T09:00:00.000Z', + actor: 'octocat', + actorType: 'user', + shasum: '4f7f5f1d5bcf2f72f6e4d6c4f3b2812d8a2f6c19', + }, + { + id: 'f8e7a45b-7a5f-4f31-8e6d-9dd1c6ef38c0', + packageName: 'example-lib', + version: '0.4.0', + tag: 'next', + createdAt: '2026-03-15T18:22:11.000Z', + actor: 'npm-bot', + actorType: 'trusted automation', + shasum: '8eb3b4e9b6e3d0d2c86be1e6d4f43f4be62e80ad', + }, +] + +t.test('lists all staged packages', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + config: { ...authConfig }, + }) + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: token, + }) + registry.nock.get('/-/stage?page=0&perPage=100') + .reply(200, { items: stageItems, page: 0, perPage: 100, total: 2 }) + await npm.exec('stage', ['list']) + const out = joinedOutput() + t.match(out, 'package name: @npmcli/example-package') + t.match(out, 'package name: example-lib') + t.match(out, 'version: 1.2.3') + t.match(out, 'version: 0.4.0') +}) + +t.test('lists with package filter', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + config: { ...authConfig }, + }) + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: token, + }) + registry.nock.get('/-/stage?page=0&perPage=100&package=%40npmcli%2Fexample-package') + .reply(200, { items: [stageItems[0]], page: 0, perPage: 100, total: 1 }) + await npm.exec('stage', ['list', '@npmcli/example-package']) + const out = joinedOutput() + t.match(out, '@npmcli/example-package') + t.notMatch(out, 'example-lib') +}) + +t.test('lists with --json', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + config: { ...authConfig, json: true }, + }) + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: token, + }) + registry.nock.get('/-/stage?page=0&perPage=100') + .reply(200, { items: stageItems, page: 0, perPage: 100, total: 2 }) + await npm.exec('stage', ['list']) + const out = JSON.parse(joinedOutput()) + t.equal(out.length, 2) + t.equal(out[0].packageName, '@npmcli/example-package') + t.equal(out[0].id, '1de6f3db-2ed9-4d72-b3dd-8f0e2b474a2f', 'uuid id is not redacted') +}) + +t.test('shows message when no packages', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + config: { ...authConfig }, + }) + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: token, + }) + registry.nock.get('/-/stage?page=0&perPage=100') + .reply(200, { items: [], page: 0, perPage: 100, total: 0 }) + await npm.exec('stage', ['list']) + t.match(joinedOutput(), 'No staged packages found.') +}) + +t.test('shows filtered message when no packages with filter', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + config: { ...authConfig }, + }) + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: token, + }) + registry.nock.get('/-/stage?page=0&perPage=100&package=nonexistent') + .reply(200, { items: [], page: 0, perPage: 100, total: 0 }) + await npm.exec('stage', ['list', 'nonexistent']) + t.match(joinedOutput(), 'No staged versions of package name "nonexistent".') +}) + +t.test('throws on version specifier', async t => { + const { npm } = await loadMockNpm(t, { + config: { ...authConfig }, + }) + await t.rejects(npm.exec('stage', ['list', 'area@1.0.0']), { + code: 'EUSAGE', + }) +}) + +t.test('paginates through multiple pages', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + config: { ...authConfig }, + }) + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: token, + }) + // page 0: 100 items, total 101 + const page0Items = Array.from({ length: 100 }, (_, i) => ({ + ...stageItems[0], + id: `id-${i}`, + version: `1.0.${i}`, + })) + registry.nock.get('/-/stage?page=0&perPage=100') + .reply(200, { items: page0Items, page: 0, perPage: 100, total: 101 }) + registry.nock.get('/-/stage?page=1&perPage=100') + .reply(200, { items: [stageItems[1]], page: 1, perPage: 100, total: 101 }) + await npm.exec('stage', ['list']) + const out = joinedOutput() + t.match(out, 'version: 1.0.0') + t.match(out, 'version: 0.4.0') +}) diff --git a/test/lib/commands/stage/reject.js b/test/lib/commands/stage/reject.js new file mode 100644 index 0000000000000..fe7e639ca769a --- /dev/null +++ b/test/lib/commands/stage/reject.js @@ -0,0 +1,31 @@ +const t = require('tap') +const { load: loadMockNpm } = require('../../../fixtures/mock-npm.js') +const MockRegistry = require('@npmcli/mock-registry') + +const token = 'test-auth-token' +const authConfig = { '//registry.npmjs.org/:_authToken': token } +const stageId = '1de6f3db-2ed9-4d72-b3dd-8f0e2b474a2f' + +t.test('rejects a staged package', async t => { + const { npm, joinedOutput, logs } = await loadMockNpm(t, { + config: { ...authConfig, otp: '123456' }, + }) + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: token, + }) + registry.nock.delete(`/-/stage/${stageId}`).reply(204) + await npm.exec('stage', ['reject', stageId]) + t.match(joinedOutput(), /has been rejected/) + t.match(logs.warn, [/permanently delete/]) +}) + +t.test('throws usageError without stage-id', async t => { + const { npm } = await loadMockNpm(t, { + config: authConfig, + }) + await t.rejects(npm.exec('stage', ['reject']), { + code: 'EUSAGE', + }) +}) diff --git a/test/lib/commands/stage/view.js b/test/lib/commands/stage/view.js new file mode 100644 index 0000000000000..a91bd093f66bc --- /dev/null +++ b/test/lib/commands/stage/view.js @@ -0,0 +1,59 @@ +const t = require('tap') +const { load: loadMockNpm } = require('../../../fixtures/mock-npm.js') +const MockRegistry = require('@npmcli/mock-registry') + +const token = 'test-auth-token' +const authConfig = { '//registry.npmjs.org/:_authToken': token } + +const stageItem = { + id: '1de6f3db-2ed9-4d72-b3dd-8f0e2b474a2f', + packageName: '@npmcli/example-package', + version: '1.2.3', + tag: 'latest', + createdAt: '2026-03-16T09:00:00.000Z', + actor: 'octocat', + actorType: 'user', + shasum: '4f7f5f1d5bcf2f72f6e4d6c4f3b2812d8a2f6c19', +} + +t.test('views a staged package', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + config: authConfig, + }) + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: token, + }) + registry.nock.get(`/-/stage/${stageItem.id}`).reply(200, stageItem) + await npm.exec('stage', ['view', stageItem.id]) + const out = joinedOutput() + t.match(out, /id:/) + t.match(out, 'package name: @npmcli/example-package') + t.match(out, 'version: 1.2.3') +}) + +t.test('views with --json', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + config: { ...authConfig, json: true }, + }) + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: token, + }) + registry.nock.get(`/-/stage/${stageItem.id}`).reply(200, stageItem) + await npm.exec('stage', ['view', stageItem.id]) + const out = JSON.parse(joinedOutput()) + t.ok(out.id) + t.equal(out.packageName, '@npmcli/example-package') +}) + +t.test('throws usageError without stage-id', async t => { + const { npm } = await loadMockNpm(t, { + config: authConfig, + }) + await t.rejects(npm.exec('stage', ['view']), { + code: 'EUSAGE', + }) +}) diff --git a/test/lib/utils/key-values.js b/test/lib/utils/key-values.js new file mode 100644 index 0000000000000..4f242c8dcc768 --- /dev/null +++ b/test/lib/utils/key-values.js @@ -0,0 +1,67 @@ +const t = require('tap') +const { logObject, logStageItem, defaultPredicate } = require('../../../lib/utils/key-values.js') +const { load: loadMockNpm } = require('../../fixtures/mock-npm.js') + +t.test('defaultPredicate skips null and undefined', t => { + const chalk = { green: v => v } + t.equal(defaultPredicate('k', null, chalk), null) + t.equal(defaultPredicate('k', undefined, chalk), null) + t.equal(defaultPredicate('k', 'val', chalk), 'val') + t.end() +}) + +t.test('logObject json mode', async t => { + const { joinedOutput } = await loadMockNpm(t) + const chalk = { cyan: v => v, green: v => v } + logObject({ a: 1, b: 2 }, { chalk, json: true }) + const out = JSON.parse(joinedOutput()) + t.same(out, { a: 1, b: 2 }) +}) + +t.test('logObject skips null values with default predicate', async t => { + const { joinedOutput } = await loadMockNpm(t) + const chalk = { cyan: v => v, green: v => v } + logObject({ a: 'yes', b: null, c: 'also' }, { chalk }) + const out = joinedOutput() + t.match(out, /a: yes/) + t.match(out, /c: also/) + t.notMatch(out, /b:/) +}) + +t.test('logStageItem includes extra properties', async t => { + const { joinedOutput } = await loadMockNpm(t) + const chalk = { cyan: v => v, green: v => v } + logStageItem({ + id: 'abc', + packageName: 'pkg', + version: '1.0.0', + tag: 'latest', + createdAt: '2026-01-01', + actor: 'user', + actorType: 'human', + shasum: 'sha1', + extra: 'bonus', + }, { chalk }) + const out = joinedOutput() + t.match(out, /extra: bonus/) + t.match(out, /package name: pkg/) +}) + +t.test('logObject with custom predicate', async t => { + const { joinedOutput } = await loadMockNpm(t) + const chalk = { cyan: v => v, green: v => v } + logObject({ a: 'one', b: 'two' }, { + chalk, + predicate: (key, value) => `[${value}]`, + }) + const out = joinedOutput() + t.match(out, /a: \[one\]/) + t.match(out, /b: \[two\]/) +}) + +t.test('logObject with all values skipped produces no output', async t => { + const { joinedOutput } = await loadMockNpm(t) + const chalk = { cyan: v => v, green: v => v } + logObject({ a: null, b: undefined }, { chalk }) + t.equal(joinedOutput(), '') +}) diff --git a/workspaces/libnpmpublish/lib/publish.js b/workspaces/libnpmpublish/lib/publish.js index 933e142422b6c..fcbc1dc79abe8 100644 --- a/workspaces/libnpmpublish/lib/publish.js +++ b/workspaces/libnpmpublish/lib/publish.js @@ -50,15 +50,20 @@ Remove the 'private' field from the package.json to publish it.`), opts ) - const res = await npmFetch(spec.escapedName, { + const stageRoute = `/-/stage/package/${spec.escapedName}` + const res = await npmFetch(opts.stage ? stageRoute : spec.escapedName, { ...opts, - method: 'PUT', + method: opts.stage ? 'POST' : 'PUT', body: metadata, - ignoreBody: true, + ignoreBody: !opts.stage, }) if (transparencyLogUrl) { res.transparencyLogUrl = transparencyLogUrl } + if (opts.stage) { + const json = await res.json() + return { ...res, stageId: json.stageId } + } return res } @@ -86,7 +91,7 @@ const patchManifest = async (_manifest, opts) => { } const buildMetadata = async (registry, manifest, tarballData, spec, opts) => { - const { access, defaultTag, algorithms, provenance, provenanceFile } = opts + const { access, defaultTag, algorithms, provenance, provenanceFile, command = 'publish' } = opts const root = { _id: manifest.name, name: manifest.name, @@ -141,14 +146,14 @@ const buildMetadata = async (registry, manifest, tarballData, spec, opts) => { provenanceBundle = await generateProvenance([subject], opts) /* eslint-disable-next-line max-len */ - log.notice('publish', `Signed provenance statement with source and build information from ${ciInfo.name}`) + log.notice(command, `Signed provenance statement with source and build information from ${ciInfo.name}`) const tlogEntry = provenanceBundle?.verificationMaterial?.tlogEntries[0] /* istanbul ignore else */ if (tlogEntry) { transparencyLogUrl = `${TLOG_BASE_URL}?logIndex=${tlogEntry.logIndex}` log.notice( - 'publish', + command, `Provenance statement published to transparency log: ${transparencyLogUrl}` ) } diff --git a/workspaces/libnpmpublish/test/publish.js b/workspaces/libnpmpublish/test/publish.js index e06d807ce74f9..0a38f73f59f12 100644 --- a/workspaces/libnpmpublish/test/publish.js +++ b/workspaces/libnpmpublish/test/publish.js @@ -1105,6 +1105,31 @@ t.test('publish existing package with provenance in gitlab', async t => { ]) }) +t.test('stage publish returns stageId', async t => { + const { publish } = t.mock('..') + const registry = new MockRegistry({ + tap: t, + registry: opts.registry, + authorization: token, + }) + const manifest = { + name: '@npmcli/libnpmpublish-test', + version: '1.0.0', + description: 'test libnpmpublish package', + } + const spec = npa(manifest.name) + + registry.nock + .post(`/-/stage/package/${spec.escapedName}`) + .reply(201, { stageId: 'test-stage-id' }) + + const ret = await publish(manifest, tarData, { + ...opts, + stage: true, + }) + t.equal(ret.stageId, 'test-stage-id', 'stageId returned from response') +}) + t.test('gitlab provenance, no token available', async t => { mockGlobals(t, { 'process.env': { From f78cf334c11fd8566170da6f43c993c1656ace13 Mon Sep 17 00:00:00 2001 From: Tea Reggi Date: Wed, 8 Apr 2026 10:52:43 -0400 Subject: [PATCH 2/3] fix: list and view dont require 2fa --- docs/lib/content/commands/npm-stage.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/lib/content/commands/npm-stage.md b/docs/lib/content/commands/npm-stage.md index c68f0af4a7672..563861f92239e 100644 --- a/docs/lib/content/commands/npm-stage.md +++ b/docs/lib/content/commands/npm-stage.md @@ -66,8 +66,8 @@ Before using `npm stage` commands, ensure the following requirements are met: | Command | Requires 2FA | Notes | | --- | --- | --- | | `npm stage publish` | No | Designed for automated workflows; defers 2FA to approval | -| `npm stage list` | Yes | Prompts for 2FA to view staged packages | -| `npm stage view` | Yes | Prompts for 2FA to view staged package details | +| `npm stage list` | No | View staged packages | +| `npm stage view` | No | View staged package details | | `npm stage approve` | Yes | Prompts for 2FA to publish the staged package | | `npm stage reject` | Yes | Prompts for 2FA to permanently remove the staged package | | `npm stage download` | No | Downloads the tarball for local inspection | From 95be212ad0824f39e10c182ad41d415ed25c76b3 Mon Sep 17 00:00:00 2001 From: reggi Date: Tue, 21 Apr 2026 12:28:18 -0400 Subject: [PATCH 3/3] fix: access JSON output under package name key in stage tests The logTar utility wraps JSON output under the package name key, so tests need to access out[pkg] instead of out directly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../tap-snapshots/test/index.js.test.cjs | 2 +- .../smoke-tests/test/index.js.test.cjs | 14 ++++ .../test/lib/commands/publish.js.test.cjs | 3 +- tap-snapshots/test/lib/docs.js.test.cjs | 56 +++++++++++++ tap-snapshots/test/lib/npm.js.test.cjs | 78 +++++++++---------- test/lib/commands/stage/index.js | 8 +- 6 files changed, 116 insertions(+), 45 deletions(-) create mode 100644 tap-snapshots/smoke-tests/test/index.js.test.cjs diff --git a/smoke-tests/tap-snapshots/test/index.js.test.cjs b/smoke-tests/tap-snapshots/test/index.js.test.cjs index b3247b148a40b..0150188774c17 100644 --- a/smoke-tests/tap-snapshots/test/index.js.test.cjs +++ b/smoke-tests/tap-snapshots/test/index.js.test.cjs @@ -27,7 +27,7 @@ All commands: 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, start, + restart, root, run, sbom, search, set, shrinkwrap, stage, start, stop, team, test, token, trust, undeprecate, uninstall, unpublish, update, version, view, whoami diff --git a/tap-snapshots/smoke-tests/test/index.js.test.cjs b/tap-snapshots/smoke-tests/test/index.js.test.cjs new file mode 100644 index 0000000000000..e0f2928c93f33 --- /dev/null +++ b/tap-snapshots/smoke-tests/test/index.js.test.cjs @@ -0,0 +1,14 @@ +/* IMPORTANT + * This snapshot file is auto-generated, but designed for humans. + * It should be checked into source control and tracked carefully. + * Re-generate by setting TAP_SNAPSHOT=1 and running tests. + * Make sure to inspect the output below. Do not ignore changes! + */ +'use strict' +exports[`smoke-tests/test/index.js TAP basic npm (no args) > should have expected no args output 1`] = ` + +` + +exports[`smoke-tests/test/index.js TAP basic npm outdated > should have expected outdated output 1`] = ` + +` diff --git a/tap-snapshots/test/lib/commands/publish.js.test.cjs b/tap-snapshots/test/lib/commands/publish.js.test.cjs index cb9ce5b20a8f6..5fcccd5317c73 100644 --- a/tap-snapshots/test/lib/commands/publish.js.test.cjs +++ b/tap-snapshots/test/lib/commands/publish.js.test.cjs @@ -205,6 +205,7 @@ Object { "man/man1/npm-search.1", "man/man1/npm-set.1", "man/man1/npm-shrinkwrap.1", + "man/man1/npm-stage.1", "man/man1/npm-start.1", "man/man1/npm-stop.1", "man/man1/npm-team.1", @@ -256,7 +257,7 @@ exports[`test/lib/commands/publish.js TAP no auth dry-run > must match snapshot exports[`test/lib/commands/publish.js TAP no auth dry-run > warns about auth being needed 1`] = ` Array [ - "This command requires you to be logged in to https://registry.npmjs.org/ (dry-run)", + "publish This command requires you to be logged in to https://registry.npmjs.org/ (dry-run)", ] ` diff --git a/tap-snapshots/test/lib/docs.js.test.cjs b/tap-snapshots/test/lib/docs.js.test.cjs index a388bae55f9d6..37c1f8eb8bc4c 100644 --- a/tap-snapshots/test/lib/docs.js.test.cjs +++ b/tap-snapshots/test/lib/docs.js.test.cjs @@ -147,6 +147,7 @@ Array [ "search", "set", "shrinkwrap", + "stage", "start", "stop", "team", @@ -5487,6 +5488,61 @@ Note: This command is unaware of workspaces. NO PARAMS ` +exports[`test/lib/docs.js TAP usage stage > must match snapshot 1`] = ` +Stage packages for publishing, deferring proof-of-presence (2FA) to a later point in time + +Usage: +npm stage +npm stage publish +npm stage list [] +npm stage view +npm stage approve +npm stage reject +npm stage download + +Subcommands: + publish + Stage a package for publishing, deferring proof-of-presence (2FA) to a later point in time + + list + List all staged package versions + + view + View details of a specific staged package + + approve + Approve a staged package, publishing it to the npm registry + + reject + Reject a staged package, removing it from the registry + + download + Download the tarball of a staged package for inspection + +Run "npm stage --help" for more info on a subcommand. + +Run "npm help stage" for more info + +\`\`\`bash +npm stage +\`\`\` + +Note: This command is unaware of workspaces. + +#### Synopsis +#### Flags +#### Synopsis +#### Flags +#### Synopsis +#### Flags +#### Synopsis +#### Flags +#### Synopsis +#### Flags +#### Synopsis +#### Flags +` + exports[`test/lib/docs.js TAP usage start > must match snapshot 1`] = ` Start a package diff --git a/tap-snapshots/test/lib/npm.js.test.cjs b/tap-snapshots/test/lib/npm.js.test.cjs index 7e90e3ca7012d..4487456324b3d 100644 --- a/tap-snapshots/test/lib/npm.js.test.cjs +++ b/tap-snapshots/test/lib/npm.js.test.cjs @@ -37,9 +37,9 @@ All commands: 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, start, - stop, team, test, token, trust, undeprecate, uninstall, - unpublish, update, version, view, whoami + restart, root, run, sbom, search, set, shrinkwrap, stage, + start, stop, team, test, token, trust, undeprecate, + uninstall, unpublish, update, version, view, whoami Specify configs in the ini-formatted file: {USERCONFIG} @@ -86,12 +86,12 @@ All commands: query, rebuild, repo, restart, root, run, sbom, search, set, - shrinkwrap, start, stop, - team, test, token, - trust, undeprecate, - uninstall, unpublish, - update, version, view, - whoami + shrinkwrap, stage, + start, stop, team, test, + token, trust, + undeprecate, uninstall, + unpublish, update, + version, view, whoami Specify configs in the ini-formatted file: {USERCONFIG} @@ -138,12 +138,12 @@ All commands: query, rebuild, repo, restart, root, run, sbom, search, set, - shrinkwrap, start, stop, - team, test, token, - trust, undeprecate, - uninstall, unpublish, - update, version, view, - whoami + shrinkwrap, stage, + start, stop, team, test, + token, trust, + undeprecate, uninstall, + unpublish, update, + version, view, whoami Specify configs in the ini-formatted file: {USERCONFIG} @@ -177,9 +177,9 @@ All commands: 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, start, - stop, team, test, token, trust, undeprecate, uninstall, - unpublish, update, version, view, whoami + restart, root, run, sbom, search, set, shrinkwrap, stage, + start, stop, team, test, token, trust, undeprecate, + uninstall, unpublish, update, version, view, whoami Specify configs in the ini-formatted file: {USERCONFIG} @@ -226,12 +226,12 @@ All commands: query, rebuild, repo, restart, root, run, sbom, search, set, - shrinkwrap, start, stop, - team, test, token, - trust, undeprecate, - uninstall, unpublish, - update, version, view, - whoami + shrinkwrap, stage, + start, stop, team, test, + token, trust, + undeprecate, uninstall, + unpublish, update, + version, view, whoami Specify configs in the ini-formatted file: {USERCONFIG} @@ -278,12 +278,12 @@ All commands: query, rebuild, repo, restart, root, run, sbom, search, set, - shrinkwrap, start, stop, - team, test, token, - trust, undeprecate, - uninstall, unpublish, - update, version, view, - whoami + shrinkwrap, stage, + start, stop, team, test, + token, trust, + undeprecate, uninstall, + unpublish, update, + version, view, whoami Specify configs in the ini-formatted file: {USERCONFIG} @@ -329,8 +329,8 @@ All commands: query, rebuild, repo, restart, root, run, sbom, search, set, shrinkwrap, - start, stop, team, test, - token, trust, + stage, start, stop, team, + test, token, trust, undeprecate, uninstall, unpublish, update, version, view, whoami @@ -368,7 +368,7 @@ 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, - start, stop, team, test, token, trust, undeprecate, + stage, start, stop, team, test, token, trust, undeprecate, uninstall, unpublish, update, version, view, whoami Specify configs in the ini-formatted file: @@ -403,9 +403,9 @@ All commands: 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, start, - stop, team, test, token, trust, undeprecate, uninstall, - unpublish, update, version, view, whoami + restart, root, run, sbom, search, set, shrinkwrap, stage, + start, stop, team, test, token, trust, undeprecate, + uninstall, unpublish, update, version, view, whoami Specify configs in the ini-formatted file: {USERCONFIG} @@ -439,9 +439,9 @@ All commands: 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, start, - stop, team, test, token, trust, undeprecate, uninstall, - unpublish, update, version, view, whoami + restart, root, run, sbom, search, set, shrinkwrap, stage, + start, stop, team, test, token, trust, undeprecate, + uninstall, unpublish, update, version, view, whoami Specify configs in the ini-formatted file: {USERCONFIG} diff --git a/test/lib/commands/stage/index.js b/test/lib/commands/stage/index.js index b0af4e55d5ddd..2e40c286aaf90 100644 --- a/test/lib/commands/stage/index.js +++ b/test/lib/commands/stage/index.js @@ -58,8 +58,8 @@ t.test('stages with --json', async t => { registry.nock.post('/-/stage/package/@npmcli%2ftest-package').reply(201, {}) await npm.exec('stage', ['publish']) const out = JSON.parse(joinedOutput()) - t.equal(out.name, pkg) - t.equal(out.version, '1.0.0') + t.equal(out[pkg].name, pkg) + t.equal(out[pkg].version, '1.0.0') }) t.test('stages with --json includes stageId', async t => { @@ -78,8 +78,8 @@ t.test('stages with --json includes stageId', async t => { registry.nock.post('/-/stage/package/@npmcli%2ftest-package').reply(201, { stageId }) await npm.exec('stage', ['publish']) const out = JSON.parse(joinedOutput()) - t.equal(out.name, pkg) - t.equal(out.stageId, stageId) + t.equal(out[pkg].name, pkg) + t.equal(out[pkg].stageId, stageId) }) t.test('throws on invalid semver tag', async t => {