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