1const columns = require('cli-columns') 2const fs = require('fs') 3const jsonParse = require('json-parse-even-better-errors') 4const log = require('../utils/log-shim.js') 5const npa = require('npm-package-arg') 6const { resolve } = require('path') 7const formatBytes = require('../utils/format-bytes.js') 8const relativeDate = require('tiny-relative-date') 9const semver = require('semver') 10const { inspect, promisify } = require('util') 11const { packument } = require('pacote') 12 13const readFile = promisify(fs.readFile) 14const readJson = async file => jsonParse(await readFile(file, 'utf8')) 15 16const Queryable = require('../utils/queryable.js') 17const BaseCommand = require('../base-command.js') 18class View extends BaseCommand { 19 static description = 'View registry info' 20 static name = 'view' 21 static params = [ 22 'json', 23 'workspace', 24 'workspaces', 25 'include-workspace-root', 26 ] 27 28 static workspaces = true 29 static ignoreImplicitWorkspace = false 30 static usage = ['[<package-spec>] [<field>[.subfield]...]'] 31 32 static async completion (opts, npm) { 33 if (opts.conf.argv.remain.length <= 2) { 34 // There used to be registry completion here, but it stopped 35 // making sense somewhere around 50,000 packages on the registry 36 return 37 } 38 // have the package, get the fields 39 const config = { 40 ...npm.flatOptions, 41 fullMetadata: true, 42 preferOnline: true, 43 } 44 const spec = npa(opts.conf.argv.remain[2]) 45 const pckmnt = await packument(spec, config) 46 const defaultTag = npm.config.get('tag') 47 const dv = pckmnt.versions[pckmnt['dist-tags'][defaultTag]] 48 pckmnt.versions = Object.keys(pckmnt.versions).sort(semver.compareLoose) 49 50 return getFields(pckmnt).concat(getFields(dv)) 51 52 function getFields (d, f, pref) { 53 f = f || [] 54 pref = pref || [] 55 Object.keys(d).forEach((k) => { 56 if (k.charAt(0) === '_' || k.indexOf('.') !== -1) { 57 return 58 } 59 const p = pref.concat(k).join('.') 60 f.push(p) 61 if (Array.isArray(d[k])) { 62 d[k].forEach((val, i) => { 63 const pi = p + '[' + i + ']' 64 if (val && typeof val === 'object') { 65 getFields(val, f, [p]) 66 } else { 67 f.push(pi) 68 } 69 }) 70 return 71 } 72 if (typeof d[k] === 'object') { 73 getFields(d[k], f, [p]) 74 } 75 }) 76 return f 77 } 78 } 79 80 async exec (args) { 81 if (!args.length) { 82 args = ['.'] 83 } 84 let pkg = args.shift() 85 const local = /^\.@/.test(pkg) || pkg === '.' 86 87 if (local) { 88 if (this.npm.global) { 89 throw new Error('Cannot use view command in global mode.') 90 } 91 const dir = this.npm.prefix 92 const manifest = await readJson(resolve(dir, 'package.json')) 93 if (!manifest.name) { 94 throw new Error('Invalid package.json, no "name" field') 95 } 96 // put the version back if it existed 97 pkg = `${manifest.name}${pkg.slice(1)}` 98 } 99 100 let wholePackument = false 101 if (!args.length) { 102 args = [''] 103 wholePackument = true 104 } 105 const [pckmnt, data] = await this.getData(pkg, args) 106 107 if (!this.npm.config.get('json') && wholePackument) { 108 // pretty view (entire packument) 109 data.map((v) => this.prettyView(pckmnt, v[Object.keys(v)[0]][''])) 110 } else { 111 // JSON formatted output (JSON or specific attributes from packument) 112 let reducedData = data.reduce(reducer, {}) 113 if (wholePackument) { 114 // No attributes 115 reducedData = cleanBlanks(reducedData) 116 log.silly('view', reducedData) 117 } 118 // disable the progress bar entirely, as we can't meaningfully update it 119 // if we may have partial lines printed. 120 log.disableProgress() 121 122 const msg = await this.jsonData(reducedData, pckmnt._id) 123 if (msg !== '') { 124 this.npm.output(msg) 125 } 126 } 127 } 128 129 async execWorkspaces (args) { 130 if (!args.length) { 131 args = ['.'] 132 } 133 134 const pkg = args.shift() 135 136 const local = /^\.@/.test(pkg) || pkg === '.' 137 if (!local) { 138 log.warn('Ignoring workspaces for specified package(s)') 139 return this.exec([pkg, ...args]) 140 } 141 let wholePackument = false 142 if (!args.length) { 143 wholePackument = true 144 args = [''] // getData relies on this 145 } 146 const results = {} 147 await this.setWorkspaces() 148 for (const name of this.workspaceNames) { 149 const wsPkg = `${name}${pkg.slice(1)}` 150 const [pckmnt, data] = await this.getData(wsPkg, args) 151 152 let reducedData = data.reduce(reducer, {}) 153 if (wholePackument) { 154 // No attributes 155 reducedData = cleanBlanks(reducedData) 156 log.silly('view', reducedData) 157 } 158 159 if (!this.npm.config.get('json')) { 160 if (wholePackument) { 161 data.map((v) => this.prettyView(pckmnt, v[Object.keys(v)[0]][''])) 162 } else { 163 this.npm.output(`${name}:`) 164 const msg = await this.jsonData(reducedData, pckmnt._id) 165 if (msg !== '') { 166 this.npm.output(msg) 167 } 168 } 169 } else { 170 const msg = await this.jsonData(reducedData, pckmnt._id) 171 if (msg !== '') { 172 results[name] = JSON.parse(msg) 173 } 174 } 175 } 176 if (Object.keys(results).length > 0) { 177 this.npm.output(JSON.stringify(results, null, 2)) 178 } 179 } 180 181 async getData (pkg, args) { 182 const opts = { 183 ...this.npm.flatOptions, 184 preferOnline: true, 185 fullMetadata: true, 186 } 187 188 const spec = npa(pkg) 189 190 // get the data about this package 191 let version = this.npm.config.get('tag') 192 // rawSpec is the git url if this is from git 193 if (spec.type !== 'git' && spec.type !== 'directory' && spec.rawSpec !== '*') { 194 version = spec.rawSpec 195 } 196 197 const pckmnt = await packument(spec, opts) 198 199 if (pckmnt['dist-tags']?.[version]) { 200 version = pckmnt['dist-tags'][version] 201 } 202 203 if (pckmnt.time && pckmnt.time.unpublished) { 204 const u = pckmnt.time.unpublished 205 const er = new Error(`Unpublished on ${u.time}`) 206 er.statusCode = 404 207 er.code = 'E404' 208 er.pkgid = pckmnt._id 209 throw er 210 } 211 212 const data = [] 213 const versions = pckmnt.versions || {} 214 pckmnt.versions = Object.keys(versions).sort(semver.compareLoose) 215 216 // remove readme unless we asked for it 217 if (args.indexOf('readme') === -1) { 218 delete pckmnt.readme 219 } 220 221 Object.keys(versions).forEach((v) => { 222 if (semver.satisfies(v, version, true)) { 223 args.forEach(arg => { 224 // remove readme unless we asked for it 225 if (args.indexOf('readme') !== -1) { 226 delete versions[v].readme 227 } 228 229 data.push(showFields(pckmnt, versions[v], arg)) 230 }) 231 } 232 }) 233 234 // No data has been pushed because no data is matching the specified version 235 if (data.length === 0 && version !== 'latest') { 236 const er = new Error(`No match found for version ${version}`) 237 er.statusCode = 404 238 er.code = 'E404' 239 er.pkgid = `${pckmnt._id}@${version}` 240 throw er 241 } 242 243 if ( 244 !this.npm.config.get('json') && 245 args.length === 1 && 246 args[0] === '' 247 ) { 248 pckmnt.version = version 249 } 250 251 return [pckmnt, data] 252 } 253 254 async jsonData (data, name) { 255 const versions = Object.keys(data) 256 let msg = '' 257 let msgJson = [] 258 const includeVersions = versions.length > 1 259 let includeFields 260 const json = this.npm.config.get('json') 261 262 versions.forEach((v) => { 263 const fields = Object.keys(data[v]) 264 includeFields = includeFields || (fields.length > 1) 265 if (json) { 266 msgJson.push({}) 267 } 268 fields.forEach((f) => { 269 let d = cleanup(data[v][f]) 270 if (fields.length === 1 && json) { 271 msgJson[msgJson.length - 1][f] = d 272 } 273 274 if (includeVersions || includeFields || typeof d !== 'string') { 275 if (json) { 276 msgJson[msgJson.length - 1][f] = d 277 } else { 278 d = inspect(d, { 279 showHidden: false, 280 depth: 5, 281 colors: this.npm.color, 282 maxArrayLength: null, 283 }) 284 } 285 } else if (typeof d === 'string' && json) { 286 d = JSON.stringify(d) 287 } 288 289 if (!json) { 290 if (f && includeFields) { 291 f += ' = ' 292 } 293 msg += (includeVersions ? name + '@' + v + ' ' : '') + 294 (includeFields ? f : '') + d + '\n' 295 } 296 }) 297 }) 298 299 if (json) { 300 if (msgJson.length && Object.keys(msgJson[0]).length === 1) { 301 const k = Object.keys(msgJson[0])[0] 302 msgJson = msgJson.map(m => m[k]) 303 } 304 if (msgJson.length === 1) { 305 msg = JSON.stringify(msgJson[0], null, 2) + '\n' 306 } else if (msgJson.length > 1) { 307 msg = JSON.stringify(msgJson, null, 2) + '\n' 308 } 309 } 310 311 return msg.trim() 312 } 313 314 prettyView (packu, manifest) { 315 // More modern, pretty printing of default view 316 const unicode = this.npm.config.get('unicode') 317 const chalk = this.npm.chalk 318 const tags = [] 319 320 Object.keys(packu['dist-tags']).forEach((t) => { 321 const version = packu['dist-tags'][t] 322 tags.push(`${chalk.bold.green(t)}: ${version}`) 323 }) 324 const unpackedSize = manifest.dist.unpackedSize && 325 formatBytes(manifest.dist.unpackedSize, true) 326 const licenseField = manifest.license || 'Proprietary' 327 const info = { 328 name: chalk.green(manifest.name), 329 version: chalk.green(manifest.version), 330 bins: Object.keys(manifest.bin || {}), 331 versions: chalk.yellow(packu.versions.length + ''), 332 description: manifest.description, 333 deprecated: manifest.deprecated, 334 keywords: packu.keywords || [], 335 license: typeof licenseField === 'string' 336 ? licenseField 337 : (licenseField.type || 'Proprietary'), 338 deps: Object.keys(manifest.dependencies || {}).map((dep) => { 339 return `${chalk.yellow(dep)}: ${manifest.dependencies[dep]}` 340 }), 341 publisher: manifest._npmUser && unparsePerson({ 342 name: chalk.yellow(manifest._npmUser.name), 343 email: chalk.cyan(manifest._npmUser.email), 344 }), 345 modified: !packu.time ? undefined 346 : chalk.yellow(relativeDate(packu.time[manifest.version])), 347 maintainers: (packu.maintainers || []).map((u) => unparsePerson({ 348 name: chalk.yellow(u.name), 349 email: chalk.cyan(u.email), 350 })), 351 repo: ( 352 manifest.bugs && (manifest.bugs.url || manifest.bugs) 353 ) || ( 354 manifest.repository && (manifest.repository.url || manifest.repository) 355 ), 356 site: ( 357 manifest.homepage && (manifest.homepage.url || manifest.homepage) 358 ), 359 tags, 360 tarball: chalk.cyan(manifest.dist.tarball), 361 shasum: chalk.yellow(manifest.dist.shasum), 362 integrity: 363 manifest.dist.integrity && chalk.yellow(manifest.dist.integrity), 364 fileCount: 365 manifest.dist.fileCount && chalk.yellow(manifest.dist.fileCount), 366 unpackedSize: unpackedSize && chalk.yellow(unpackedSize), 367 } 368 if (info.license.toLowerCase().trim() === 'proprietary') { 369 info.license = chalk.bold.red(info.license) 370 } else { 371 info.license = chalk.green(info.license) 372 } 373 374 this.npm.output('') 375 this.npm.output( 376 chalk.underline.bold(`${info.name}@${info.version}`) + 377 ' | ' + info.license + 378 ' | deps: ' + (info.deps.length ? chalk.cyan(info.deps.length) : chalk.green('none')) + 379 ' | versions: ' + info.versions 380 ) 381 info.description && this.npm.output(info.description) 382 if (info.repo || info.site) { 383 info.site && this.npm.output(chalk.cyan(info.site)) 384 } 385 386 const warningSign = unicode ? ' ⚠️ ' : '!!' 387 info.deprecated && this.npm.output( 388 `\n${chalk.bold.red('DEPRECATED')}${ 389 warningSign 390 } - ${info.deprecated}` 391 ) 392 393 if (info.keywords.length) { 394 this.npm.output('') 395 this.npm.output('keywords:', chalk.yellow(info.keywords.join(', '))) 396 } 397 398 if (info.bins.length) { 399 this.npm.output('') 400 this.npm.output('bin:', chalk.yellow(info.bins.join(', '))) 401 } 402 403 this.npm.output('') 404 this.npm.output('dist') 405 this.npm.output('.tarball:', info.tarball) 406 this.npm.output('.shasum:', info.shasum) 407 info.integrity && this.npm.output('.integrity:', info.integrity) 408 info.unpackedSize && this.npm.output('.unpackedSize:', info.unpackedSize) 409 410 const maxDeps = 24 411 if (info.deps.length) { 412 this.npm.output('') 413 this.npm.output('dependencies:') 414 this.npm.output(columns(info.deps.slice(0, maxDeps), { padding: 1 })) 415 if (info.deps.length > maxDeps) { 416 this.npm.output(`(...and ${info.deps.length - maxDeps} more.)`) 417 } 418 } 419 420 if (info.maintainers && info.maintainers.length) { 421 this.npm.output('') 422 this.npm.output('maintainers:') 423 info.maintainers.forEach((u) => this.npm.output('-', u)) 424 } 425 426 this.npm.output('') 427 this.npm.output('dist-tags:') 428 this.npm.output(columns(info.tags)) 429 430 if (info.publisher || info.modified) { 431 let publishInfo = 'published' 432 if (info.modified) { 433 publishInfo += ` ${info.modified}` 434 } 435 if (info.publisher) { 436 publishInfo += ` by ${info.publisher}` 437 } 438 this.npm.output('') 439 this.npm.output(publishInfo) 440 } 441 } 442} 443module.exports = View 444 445function cleanBlanks (obj) { 446 const clean = {} 447 Object.keys(obj).forEach((version) => { 448 clean[version] = obj[version][''] 449 }) 450 return clean 451} 452 453// takes an array of objects and merges them into one object 454function reducer (acc, cur) { 455 if (cur) { 456 Object.keys(cur).forEach((v) => { 457 acc[v] = acc[v] || {} 458 Object.keys(cur[v]).forEach((t) => { 459 acc[v][t] = cur[v][t] 460 }) 461 }) 462 } 463 464 return acc 465} 466 467// return whatever was printed 468function showFields (data, version, fields) { 469 const o = {} 470 ;[data, version].forEach((s) => { 471 Object.keys(s).forEach((k) => { 472 o[k] = s[k] 473 }) 474 }) 475 476 const queryable = new Queryable(o) 477 const s = queryable.query(fields) 478 const res = { [version.version]: s } 479 480 if (s) { 481 return res 482 } 483} 484 485function cleanup (data) { 486 if (Array.isArray(data)) { 487 return data.map(cleanup) 488 } 489 490 if (!data || typeof data !== 'object') { 491 return data 492 } 493 494 const keys = Object.keys(data) 495 if (keys.length <= 3 && 496 data.name && 497 (keys.length === 1 || 498 (keys.length === 3 && data.email && data.url) || 499 (keys.length === 2 && (data.email || data.url)))) { 500 data = unparsePerson(data) 501 } 502 503 return data 504} 505 506function unparsePerson (d) { 507 return d.name + 508 (d.email ? ' <' + d.email + '>' : '') + 509 (d.url ? ' (' + d.url + ')' : '') 510} 511