PR-URL: https://github.com/nodejs/node/pull/63463 Reviewed-By: Luigi Pinca <luigipinca@gmail.com> Reviewed-By: Michaël Zasso <targos@protonmail.com> Reviewed-By: Colin Ihrig <cjihrig@gmail.com> Reviewed-By: Jordan Harband <ljharb@gmail.com>
345 lines
11 KiB
JavaScript
345 lines
11 KiB
JavaScript
const BaseCommand = require('./base-cmd.js')
|
|
const { otplease } = require('./utils/auth.js')
|
|
const npmFetch = require('npm-registry-fetch')
|
|
const npa = require('npm-package-arg')
|
|
const { read: _read } = require('read')
|
|
const { input, output, log, META } = require('proc-log')
|
|
const gitinfo = require('hosted-git-info')
|
|
const pkgJson = require('@npmcli/package-json')
|
|
const Definition = require('@npmcli/config/lib/definitions/definition.js')
|
|
|
|
const NPM_FRONTEND = 'https://www.npmjs.com'
|
|
|
|
const PERMISSIONS = {
|
|
CREATE_PACKAGE: 'createPackage',
|
|
CREATE_STAGED_PACKAGE: 'createStagedPackage',
|
|
}
|
|
|
|
const trustDefinitions = {
|
|
'allow-publish': new Definition('allow-publish', {
|
|
default: false,
|
|
type: Boolean,
|
|
description: 'Allow npm publish for this trusted publisher configuration',
|
|
}),
|
|
'allow-stage-publish': new Definition('allow-stage-publish', {
|
|
default: false,
|
|
type: Boolean,
|
|
description: 'Allow npm stage publish for this trusted publisher configuration',
|
|
alias: ['allow-staged-publish'],
|
|
}),
|
|
}
|
|
|
|
class TrustCommand extends BaseCommand {
|
|
// Helper to format template strings with color
|
|
// Blue text with reset color for interpolated values
|
|
warnString (strings, ...values) {
|
|
const chalk = this.npm.chalk
|
|
const message = strings.reduce((result, str, i) => {
|
|
return result + chalk.blue(str) + (values[i] ? chalk.reset(values[i]) : '')
|
|
}, '')
|
|
return message
|
|
}
|
|
|
|
// Log a warning message with blue formatting
|
|
warn (strings, ...values) {
|
|
log.warn('trust', this.warnString(strings, ...values))
|
|
}
|
|
|
|
// dialogue is non-log text that is different from our usual npm prefix logging
|
|
// it should always show to the user unless --json is specified
|
|
// it's not controled by log levels
|
|
dialogue (strings, ...values) {
|
|
const json = this.config.get('json')
|
|
if (!json) {
|
|
output.standard(this.warnString(strings, ...values))
|
|
}
|
|
}
|
|
|
|
createConfig (pkg, body) {
|
|
const spec = npa(pkg)
|
|
const uri = `/-/package/${spec.escapedName}/trust`
|
|
return otplease(this.npm, this.npm.flatOptions, opts => npmFetch(uri, {
|
|
...opts,
|
|
method: 'POST',
|
|
body: body,
|
|
}))
|
|
}
|
|
|
|
static permissionLabels = {
|
|
[PERMISSIONS.CREATE_PACKAGE]: 'publish',
|
|
[PERMISSIONS.CREATE_STAGED_PACKAGE]: 'stage publish',
|
|
}
|
|
|
|
static formatPermissions (permissions) {
|
|
if (!Array.isArray(permissions) || permissions.length === 0) {
|
|
return null
|
|
}
|
|
return permissions
|
|
.map(p => TrustCommand.permissionLabels[p] || p)
|
|
.join(', ')
|
|
}
|
|
|
|
logOptions (options, pad = true) {
|
|
const { values, warnings, fromPackageJson, urls, permissions } = { warnings: [], ...options }
|
|
if (warnings && warnings.length > 0) {
|
|
for (const warningMsg of warnings) {
|
|
log.warn('trust', warningMsg)
|
|
}
|
|
}
|
|
|
|
const json = this.config.get('json')
|
|
if (json) {
|
|
const jsonValues = { ...options.values }
|
|
if (permissions) {
|
|
jsonValues.permissions = permissions
|
|
}
|
|
// Disable redaction: trust config values (e.g. CircleCI UUIDs) are not secrets
|
|
output.standard(JSON.stringify(jsonValues, null, 2), { [META]: true, redact: false })
|
|
return
|
|
}
|
|
|
|
const chalk = this.npm.chalk
|
|
const { type, id, ...rest } = values || {}
|
|
|
|
if (values) {
|
|
const lines = []
|
|
if (type) {
|
|
lines.push(`type: ${chalk.green(type)}`)
|
|
}
|
|
if (id) {
|
|
lines.push(`id: ${chalk.green(id)}`)
|
|
}
|
|
for (const [key, value] of Object.entries(rest)) {
|
|
if (value !== null && value !== undefined) {
|
|
const parts = [
|
|
`${chalk.reset(key)}: ${chalk.green(value)}`,
|
|
]
|
|
if (fromPackageJson && fromPackageJson[key]) {
|
|
parts.push(`(${chalk.yellow(`from package.json`)})`)
|
|
}
|
|
lines.push(parts.join(' '))
|
|
}
|
|
}
|
|
const formattedPermissions = TrustCommand.formatPermissions(permissions)
|
|
if (formattedPermissions) {
|
|
lines.push(`${chalk.reset('permissions')}: ${chalk.green(formattedPermissions)}`)
|
|
}
|
|
if (pad) {
|
|
output.standard()
|
|
}
|
|
output.standard(lines.join('\n'), { [META]: true, redact: false })
|
|
// Print URLs on their own lines after config, following the same order as rest keys
|
|
if (urls) {
|
|
const urlLines = []
|
|
for (const key of Object.keys(rest)) {
|
|
if (urls[key]) {
|
|
urlLines.push(chalk.blue(urls[key]))
|
|
}
|
|
}
|
|
if (urlLines.length > 0) {
|
|
output.standard()
|
|
output.standard(urlLines.join('\n'), { [META]: true, redact: false })
|
|
}
|
|
}
|
|
if (pad) {
|
|
output.standard()
|
|
}
|
|
}
|
|
}
|
|
|
|
async confirmOperation (yes) {
|
|
// Ask for confirmation unless --yes flag is set
|
|
if (yes === true) {
|
|
return
|
|
}
|
|
if (yes === false) {
|
|
throw new Error('User cancelled operation')
|
|
}
|
|
const confirm = await input.read(
|
|
() => _read({ prompt: 'Do you want to proceed? (y/N) ', default: 'n' })
|
|
)
|
|
const normalized = confirm.toLowerCase()
|
|
if (['y', 'yes'].includes(normalized)) {
|
|
return
|
|
}
|
|
throw new Error('User cancelled operation')
|
|
}
|
|
|
|
getFrontendUrl ({ pkgName }) {
|
|
if (this.registryIsDefault) {
|
|
return new URL(`/package/${pkgName}`, NPM_FRONTEND).toString()
|
|
}
|
|
return null
|
|
}
|
|
|
|
getRepositoryFromPackageJson (pkg) {
|
|
const info = gitinfo.fromUrl(pkg.repository?.url || pkg?.repository)
|
|
if (!info) {
|
|
return null
|
|
}
|
|
const repository = info.user + '/' + info.project
|
|
const type = info.type
|
|
return { repository, type }
|
|
}
|
|
|
|
async optionalPkgJson () {
|
|
try {
|
|
const { content } = await pkgJson.normalize(this.npm.prefix)
|
|
return content
|
|
} catch (err) {
|
|
return {}
|
|
}
|
|
}
|
|
|
|
get registryIsDefault () {
|
|
return this.npm.config.defaults.registry === this.npm.config.get('registry')
|
|
}
|
|
|
|
// generic
|
|
static bodyToOptions (body) {
|
|
return {
|
|
...(body.id) && { id: body.id },
|
|
...(body.type) && { type: body.type },
|
|
}
|
|
}
|
|
|
|
async createConfigCommand ({ positionalArgs, flags }) {
|
|
const { providerName, providerEntity, providerHostname } = this.constructor
|
|
const dryRun = this.config.get('dry-run')
|
|
const yes = this.config.get('yes') // deep-lore this allows for --no-yes
|
|
|
|
const allowPublish = flags['allow-publish']
|
|
const allowStagePublish = flags['allow-stage-publish']
|
|
|
|
if (!allowPublish && !allowStagePublish) {
|
|
throw new Error('At least one permission flag is required (--allow-publish, --allow-stage-publish)')
|
|
}
|
|
|
|
const permissions = []
|
|
if (allowPublish) {
|
|
permissions.push(PERMISSIONS.CREATE_PACKAGE)
|
|
}
|
|
if (allowStagePublish) {
|
|
permissions.push(PERMISSIONS.CREATE_STAGED_PACKAGE)
|
|
}
|
|
|
|
const options = await this.flagsToOptions({ positionalArgs, flags, providerHostname })
|
|
this.dialogue`Establishing trust between ${options.values.package} package and ${providerName}`
|
|
this.dialogue`Anyone with ${providerEntity} write access can publish to ${options.values.package}`
|
|
this.dialogue`Two-factor authentication is required for this operation`
|
|
if (!this.registryIsDefault) {
|
|
this.warn`Registry ${this.npm.config.get('registry')} may not support trusted publishing`
|
|
}
|
|
this.logOptions({ ...options, permissions })
|
|
if (dryRun) {
|
|
return
|
|
}
|
|
await this.confirmOperation(yes)
|
|
const trustConfig = this.constructor.optionsToBody(options.values)
|
|
trustConfig.permissions = permissions
|
|
const response = await this.createConfig(options.values.package, [trustConfig])
|
|
const body = await response.json()
|
|
this.dialogue`Trust configuration created successfully for ${options.values.package} with the following settings:`
|
|
this.displayResponseBody({ body, packageName: options.values.package })
|
|
}
|
|
|
|
async flagsToOptions ({ positionalArgs, flags, providerHostname }) {
|
|
const { entityKey, name, providerEntity, providerFile } = this.constructor
|
|
const content = await this.optionalPkgJson()
|
|
const pkgPositional = positionalArgs[0]
|
|
const pkgJsonName = content.name
|
|
const git = this.getRepositoryFromPackageJson(content)
|
|
// the provided positional matches package.json name or no positional provided
|
|
const matchPkg = (!pkgPositional || pkgPositional === pkgJsonName)
|
|
const pkgName = pkgPositional || pkgJsonName
|
|
const usedPkgNameFromPkgJson = !pkgPositional && Boolean(pkgJsonName)
|
|
const invalidPkgJsonProviderType = matchPkg && git && git?.type !== name
|
|
|
|
let entity
|
|
let entitySource
|
|
|
|
if (flags[entityKey]) {
|
|
entity = flags[entityKey]
|
|
entitySource = 'flag'
|
|
} else if (!invalidPkgJsonProviderType && git?.repository) {
|
|
entity = git.repository
|
|
entitySource = 'package.json'
|
|
}
|
|
const mismatchPkgJsonRepository = matchPkg && git && entity !== git.repository
|
|
const usedRepositoryInPkgJson = entitySource === 'package.json'
|
|
|
|
const warnings = []
|
|
if (!pkgName) {
|
|
throw new Error('Package name must be specified either as an argument or in package.json file')
|
|
}
|
|
|
|
if (!flags.file) {
|
|
throw new Error(`${providerFile} must be specified with the file option`)
|
|
}
|
|
if (!flags.file.endsWith('.yml') && !flags.file.endsWith('.yaml')) {
|
|
throw new Error(`${providerFile} must end in .yml or .yaml`)
|
|
}
|
|
|
|
this.validateFile?.(flags.file)
|
|
|
|
if (invalidPkgJsonProviderType) {
|
|
const message = this.warnString`Repository in package.json is not a ${providerEntity}`
|
|
if (!flags[entityKey]) {
|
|
throw new Error(message)
|
|
} else {
|
|
warnings.push(message)
|
|
}
|
|
} else {
|
|
if (mismatchPkgJsonRepository) {
|
|
warnings.push(this.warnString`Repository in package.json (${git.repository}) differs from provided ${providerEntity} (${entity})`)
|
|
}
|
|
}
|
|
|
|
if (!entity && matchPkg) {
|
|
throw new Error(`${providerEntity} must be specified with ${entityKey} option or inferred from the package.json repository field`)
|
|
}
|
|
if (!entity) {
|
|
throw new Error(`${providerEntity} must be specified with ${entityKey} option`)
|
|
}
|
|
|
|
this.validateEntity(entity)
|
|
|
|
return {
|
|
values: {
|
|
package: pkgName,
|
|
file: flags.file,
|
|
[entityKey]: entity,
|
|
...(flags.environment && { environment: flags.environment }),
|
|
},
|
|
fromPackageJson: {
|
|
[entityKey]: usedRepositoryInPkgJson,
|
|
package: usedPkgNameFromPkgJson,
|
|
},
|
|
warnings: warnings,
|
|
urls: {
|
|
package: this.getFrontendUrl({ pkgName }),
|
|
[entityKey]: this.getEntityUrl({ providerHostname, entity }),
|
|
file: this.getEntityUrl({ providerHostname, entity, file: flags.file }),
|
|
},
|
|
}
|
|
}
|
|
|
|
displayResponseBody ({ body, packageName }) {
|
|
if (!body || body.length === 0) {
|
|
this.dialogue`No trust configurations found for package (${packageName})`
|
|
return
|
|
}
|
|
const items = Array.isArray(body) ? body : [body]
|
|
for (const config of items) {
|
|
const values = this.constructor.bodyToOptions(config)
|
|
const permissions = config.permissions
|
|
output.standard()
|
|
this.logOptions({ values, permissions }, false)
|
|
}
|
|
output.standard()
|
|
}
|
|
}
|
|
|
|
module.exports = TrustCommand
|
|
module.exports.NPM_FRONTEND = NPM_FRONTEND
|
|
module.exports.trustDefinitions = trustDefinitions
|