trust-cmd.js 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  1. const BaseCommand = require('./base-cmd.js')
  2. const { otplease } = require('./utils/auth.js')
  3. const npmFetch = require('npm-registry-fetch')
  4. const npa = require('npm-package-arg')
  5. const { read: _read } = require('read')
  6. const { input, output, log, META } = require('proc-log')
  7. const gitinfo = require('hosted-git-info')
  8. const pkgJson = require('@npmcli/package-json')
  9. const NPM_FRONTEND = 'https://www.npmjs.com'
  10. class TrustCommand extends BaseCommand {
  11. // Helper to format template strings with color
  12. // Blue text with reset color for interpolated values
  13. warnString (strings, ...values) {
  14. const chalk = this.npm.chalk
  15. const message = strings.reduce((result, str, i) => {
  16. return result + chalk.blue(str) + (values[i] ? chalk.reset(values[i]) : '')
  17. }, '')
  18. return message
  19. }
  20. // Log a warning message with blue formatting
  21. warn (strings, ...values) {
  22. log.warn('trust', this.warnString(strings, ...values))
  23. }
  24. // dialogue is non-log text that is different from our usual npm prefix logging
  25. // it should always show to the user unless --json is specified
  26. // it's not controled by log levels
  27. dialogue (strings, ...values) {
  28. const json = this.config.get('json')
  29. if (!json) {
  30. output.standard(this.warnString(strings, ...values))
  31. }
  32. }
  33. createConfig (pkg, body) {
  34. const spec = npa(pkg)
  35. const uri = `/-/package/${spec.escapedName}/trust`
  36. return otplease(this.npm, this.npm.flatOptions, opts => npmFetch(uri, {
  37. ...opts,
  38. method: 'POST',
  39. body: body,
  40. }))
  41. }
  42. logOptions (options, pad = true) {
  43. const { values, warnings, fromPackageJson, urls } = { warnings: [], ...options }
  44. if (warnings && warnings.length > 0) {
  45. for (const warningMsg of warnings) {
  46. log.warn('trust', warningMsg)
  47. }
  48. }
  49. const json = this.config.get('json')
  50. if (json) {
  51. // Disable redaction: trust config values (e.g. CircleCI UUIDs) are not secrets
  52. output.standard(JSON.stringify(options.values, null, 2), { [META]: true, redact: false })
  53. return
  54. }
  55. const chalk = this.npm.chalk
  56. const { type, id, ...rest } = values || {}
  57. if (values) {
  58. const lines = []
  59. if (type) {
  60. lines.push(`type: ${chalk.green(type)}`)
  61. }
  62. if (id) {
  63. lines.push(`id: ${chalk.green(id)}`)
  64. }
  65. for (const [key, value] of Object.entries(rest)) {
  66. if (value !== null && value !== undefined) {
  67. const parts = [
  68. `${chalk.reset(key)}: ${chalk.green(value)}`,
  69. ]
  70. if (fromPackageJson && fromPackageJson[key]) {
  71. parts.push(`(${chalk.yellow(`from package.json`)})`)
  72. }
  73. lines.push(parts.join(' '))
  74. }
  75. }
  76. if (pad) {
  77. output.standard()
  78. }
  79. output.standard(lines.join('\n'), { [META]: true, redact: false })
  80. // Print URLs on their own lines after config, following the same order as rest keys
  81. if (urls) {
  82. const urlLines = []
  83. for (const key of Object.keys(rest)) {
  84. if (urls[key]) {
  85. urlLines.push(chalk.blue(urls[key]))
  86. }
  87. }
  88. if (urlLines.length > 0) {
  89. output.standard()
  90. output.standard(urlLines.join('\n'), { [META]: true, redact: false })
  91. }
  92. }
  93. if (pad) {
  94. output.standard()
  95. }
  96. }
  97. }
  98. async confirmOperation (yes) {
  99. // Ask for confirmation unless --yes flag is set
  100. if (yes === true) {
  101. return
  102. }
  103. if (yes === false) {
  104. throw new Error('User cancelled operation')
  105. }
  106. const confirm = await input.read(
  107. () => _read({ prompt: 'Do you want to proceed? (y/N) ', default: 'n' })
  108. )
  109. const normalized = confirm.toLowerCase()
  110. if (['y', 'yes'].includes(normalized)) {
  111. return
  112. }
  113. throw new Error('User cancelled operation')
  114. }
  115. getFrontendUrl ({ pkgName }) {
  116. if (this.registryIsDefault) {
  117. return new URL(`/package/${pkgName}`, NPM_FRONTEND).toString()
  118. }
  119. return null
  120. }
  121. getRepositoryFromPackageJson (pkg) {
  122. const info = gitinfo.fromUrl(pkg.repository?.url || pkg?.repository)
  123. if (!info) {
  124. return null
  125. }
  126. const repository = info.user + '/' + info.project
  127. const type = info.type
  128. return { repository, type }
  129. }
  130. async optionalPkgJson () {
  131. try {
  132. const { content } = await pkgJson.normalize(this.npm.prefix)
  133. return content
  134. } catch (err) {
  135. return {}
  136. }
  137. }
  138. get registryIsDefault () {
  139. return this.npm.config.defaults.registry === this.npm.config.get('registry')
  140. }
  141. // generic
  142. static bodyToOptions (body) {
  143. return {
  144. ...(body.id) && { id: body.id },
  145. ...(body.type) && { type: body.type },
  146. }
  147. }
  148. async createConfigCommand ({ positionalArgs, flags }) {
  149. const { providerName, providerEntity, providerHostname } = this.constructor
  150. const dryRun = this.config.get('dry-run')
  151. const yes = this.config.get('yes') // deep-lore this allows for --no-yes
  152. const options = await this.flagsToOptions({ positionalArgs, flags, providerHostname })
  153. this.dialogue`Establishing trust between ${options.values.package} package and ${providerName}`
  154. this.dialogue`Anyone with ${providerEntity} write access can publish to ${options.values.package}`
  155. this.dialogue`Two-factor authentication is required for this operation`
  156. if (!this.registryIsDefault) {
  157. this.warn`Registry ${this.npm.config.get('registry')} may not support trusted publishing`
  158. }
  159. this.logOptions(options)
  160. if (dryRun) {
  161. return
  162. }
  163. await this.confirmOperation(yes)
  164. const trustConfig = this.constructor.optionsToBody(options.values)
  165. const response = await this.createConfig(options.values.package, [trustConfig])
  166. const body = await response.json()
  167. this.dialogue`Trust configuration created successfully for ${options.values.package} with the following settings:`
  168. this.displayResponseBody({ body, packageName: options.values.package })
  169. }
  170. async flagsToOptions ({ positionalArgs, flags, providerHostname }) {
  171. const { entityKey, name, providerEntity, providerFile } = this.constructor
  172. const content = await this.optionalPkgJson()
  173. const pkgPositional = positionalArgs[0]
  174. const pkgJsonName = content.name
  175. const git = this.getRepositoryFromPackageJson(content)
  176. // the provided positional matches package.json name or no positional provided
  177. const matchPkg = (!pkgPositional || pkgPositional === pkgJsonName)
  178. const pkgName = pkgPositional || pkgJsonName
  179. const usedPkgNameFromPkgJson = !pkgPositional && Boolean(pkgJsonName)
  180. const invalidPkgJsonProviderType = matchPkg && git && git?.type !== name
  181. let entity
  182. let entitySource
  183. if (flags[entityKey]) {
  184. entity = flags[entityKey]
  185. entitySource = 'flag'
  186. } else if (!invalidPkgJsonProviderType && git?.repository) {
  187. entity = git.repository
  188. entitySource = 'package.json'
  189. }
  190. const mismatchPkgJsonRepository = matchPkg && git && entity !== git.repository
  191. const usedRepositoryInPkgJson = entitySource === 'package.json'
  192. const warnings = []
  193. if (!pkgName) {
  194. throw new Error('Package name must be specified either as an argument or in package.json file')
  195. }
  196. if (!flags.file) {
  197. throw new Error(`${providerFile} must be specified with the file option`)
  198. }
  199. if (!flags.file.endsWith('.yml') && !flags.file.endsWith('.yaml')) {
  200. throw new Error(`${providerFile} must end in .yml or .yaml`)
  201. }
  202. this.validateFile?.(flags.file)
  203. if (invalidPkgJsonProviderType) {
  204. const message = this.warnString`Repository in package.json is not a ${providerEntity}`
  205. if (!flags[entityKey]) {
  206. throw new Error(message)
  207. } else {
  208. warnings.push(message)
  209. }
  210. } else {
  211. if (mismatchPkgJsonRepository) {
  212. warnings.push(this.warnString`Repository in package.json (${git.repository}) differs from provided ${providerEntity} (${entity})`)
  213. }
  214. }
  215. if (!entity && matchPkg) {
  216. throw new Error(`${providerEntity} must be specified with ${entityKey} option or inferred from the package.json repository field`)
  217. }
  218. if (!entity) {
  219. throw new Error(`${providerEntity} must be specified with ${entityKey} option`)
  220. }
  221. this.validateEntity(entity)
  222. return {
  223. values: {
  224. package: pkgName,
  225. file: flags.file,
  226. [entityKey]: entity,
  227. ...(flags.environment && { environment: flags.environment }),
  228. },
  229. fromPackageJson: {
  230. [entityKey]: usedRepositoryInPkgJson,
  231. package: usedPkgNameFromPkgJson,
  232. },
  233. warnings: warnings,
  234. urls: {
  235. package: this.getFrontendUrl({ pkgName }),
  236. [entityKey]: this.getEntityUrl({ providerHostname, entity }),
  237. file: this.getEntityUrl({ providerHostname, entity, file: flags.file }),
  238. },
  239. }
  240. }
  241. displayResponseBody ({ body, packageName }) {
  242. if (!body || body.length === 0) {
  243. this.dialogue`No trust configurations found for package (${packageName})`
  244. return
  245. }
  246. const items = Array.isArray(body) ? body : [body]
  247. for (const config of items) {
  248. const values = this.constructor.bodyToOptions(config)
  249. output.standard()
  250. this.logOptions({ values }, false)
  251. }
  252. output.standard()
  253. }
  254. }
  255. module.exports = TrustCommand
  256. module.exports.NPM_FRONTEND = NPM_FRONTEND