view.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488
  1. const { readFile } = require('node:fs/promises')
  2. const jsonParse = require('json-parse-even-better-errors')
  3. const { log, output, META } = require('proc-log')
  4. const npa = require('npm-package-arg')
  5. const { resolve } = require('node:path')
  6. const formatBytes = require('../utils/format-bytes.js')
  7. const relativeDate = require('tiny-relative-date')
  8. const semver = require('semver')
  9. const { inspect } = require('node:util')
  10. const { packument } = require('pacote')
  11. const Queryable = require('../utils/queryable.js')
  12. const BaseCommand = require('../base-cmd.js')
  13. const { getError } = require('../utils/error-message.js')
  14. const { jsonError, outputError } = require('../utils/output-error.js')
  15. const readJson = file => readFile(file, 'utf8').then(jsonParse)
  16. class View extends BaseCommand {
  17. static description = 'View registry info'
  18. static name = 'view'
  19. static params = [
  20. 'json',
  21. 'workspace',
  22. 'workspaces',
  23. 'include-workspace-root',
  24. ]
  25. static workspaces = true
  26. static ignoreImplicitWorkspace = false
  27. static usage = ['[<package-spec>] [<field>[.subfield]...]']
  28. static async completion (opts, npm) {
  29. if (opts.conf.argv.remain.length <= 2) {
  30. // There used to be registry completion here, but it stopped making sense somewhere around 50,000 packages on the registry
  31. return
  32. }
  33. // have the package, get the fields
  34. const config = {
  35. ...npm.flatOptions,
  36. fullMetadata: true,
  37. preferOnline: true,
  38. _isRoot: true,
  39. }
  40. const spec = npa(opts.conf.argv.remain[2])
  41. const pckmnt = await packument(spec, config)
  42. const defaultTag = npm.config.get('tag')
  43. const dv = pckmnt.versions[pckmnt['dist-tags'][defaultTag]]
  44. pckmnt.versions = Object.keys(pckmnt.versions).sort(semver.compareLoose)
  45. return getCompletionFields(pckmnt).concat(getCompletionFields(dv))
  46. }
  47. async exec (args) {
  48. let { pkg, local, rest } = parseArgs(args)
  49. if (local) {
  50. if (this.npm.global) {
  51. throw new Error('Cannot use view command in global mode.')
  52. }
  53. const dir = this.npm.prefix
  54. const manifest = await readJson(resolve(dir, 'package.json'))
  55. if (!manifest.name) {
  56. throw new Error('Invalid package.json, no "name" field')
  57. }
  58. // put the version back if it existed
  59. pkg = `${manifest.name}${pkg.slice(1)}`
  60. }
  61. await this.#viewPackage(pkg, rest)
  62. }
  63. async execWorkspaces (args) {
  64. const { pkg, local, rest } = parseArgs(args)
  65. if (!local) {
  66. log.warn('Ignoring workspaces for specified package(s)')
  67. return this.exec([pkg, ...rest])
  68. }
  69. const json = this.npm.config.get('json')
  70. await this.setWorkspaces()
  71. for (const name of this.workspaceNames) {
  72. try {
  73. await this.#viewPackage(`${name}${pkg.slice(1)}`, rest, { workspace: true })
  74. } catch (e) {
  75. const err = getError(e, { npm: this.npm, command: this })
  76. if (err.code !== 'E404') {
  77. throw e
  78. }
  79. if (json) {
  80. output.buffer({ [META]: true, jsonError: { [name]: jsonError(err, this.npm) } })
  81. } else {
  82. outputError(err)
  83. }
  84. process.exitCode = err.exitCode
  85. }
  86. }
  87. }
  88. async #viewPackage (name, args, { workspace } = {}) {
  89. const wholePackument = !args.length
  90. const json = this.npm.config.get('json')
  91. // If we are viewing many packages and outputting individual fields then output the name before doing any async activity
  92. if (!json && !wholePackument && workspace) {
  93. output.standard(`${name}:`)
  94. }
  95. const [pckmnt, data] = await this.#getData(name, args, wholePackument)
  96. if (!json && wholePackument) {
  97. // pretty view (entire packument)
  98. for (const v of data) {
  99. output.standard(this.#prettyView(pckmnt, Object.values(v)[0][Queryable.ALL]))
  100. }
  101. return
  102. }
  103. const res = this.#packageOutput(cleanData(data, wholePackument), pckmnt._id)
  104. if (res) {
  105. if (json) {
  106. output.buffer(workspace ? { [name]: res } : res)
  107. } else {
  108. output.standard(res)
  109. }
  110. }
  111. }
  112. async #getData (pkg, args) {
  113. const spec = npa(pkg)
  114. const pckmnt = await packument(spec, {
  115. ...this.npm.flatOptions,
  116. preferOnline: true,
  117. fullMetadata: true,
  118. _isRoot: true,
  119. })
  120. // get the data about this package
  121. let version = this.npm.config.get('tag')
  122. // rawSpec is the git url if this is from git
  123. if (spec.type !== 'git' && spec.type !== 'directory' && spec.rawSpec !== '*') {
  124. version = spec.rawSpec
  125. }
  126. if (pckmnt['dist-tags']?.[version]) {
  127. version = pckmnt['dist-tags'][version]
  128. }
  129. if (pckmnt.time?.unpublished) {
  130. const u = pckmnt.time.unpublished
  131. throw Object.assign(new Error(`Unpublished on ${u.time}`), {
  132. statusCode: 404,
  133. code: 'E404',
  134. pkgid: pckmnt._id,
  135. })
  136. }
  137. const versions = pckmnt.versions || {}
  138. pckmnt.versions = Object.keys(versions).filter(v => {
  139. if (semver.valid(v)) {
  140. return true
  141. }
  142. log.info('view', `Ignoring invalid version: ${v}`)
  143. return false
  144. }).sort(semver.compareLoose)
  145. // remove readme unless we asked for it
  146. if (args.indexOf('readme') === -1) {
  147. delete pckmnt.readme
  148. }
  149. const data = Object.entries(versions)
  150. .filter(([v]) => semver.satisfies(v, version, true))
  151. .flatMap(([, v]) => {
  152. // remove readme unless we asked for it
  153. if (args.indexOf('readme') !== -1) {
  154. delete v.readme
  155. }
  156. return showFields({
  157. data: pckmnt,
  158. version: v,
  159. fields: args,
  160. json: this.npm.config.get('json'),
  161. })
  162. })
  163. // No data has been pushed because no data is matching the specified version
  164. if (!data.length && version !== 'latest') {
  165. throw Object.assign(new Error(`No match found for version ${version}`), {
  166. statusCode: 404,
  167. code: 'E404',
  168. pkgid: `${pckmnt._id}@${version}`,
  169. })
  170. }
  171. return [pckmnt, data]
  172. }
  173. #packageOutput (data, name) {
  174. const json = this.npm.config.get('json')
  175. const versions = Object.keys(data)
  176. const includeVersions = versions.length > 1
  177. let includeFields
  178. const res = versions.flatMap((v) => {
  179. const fields = Object.entries(data[v])
  180. includeFields ||= (fields.length > 1)
  181. const msg = json ? {} : []
  182. for (let [f, d] of fields) {
  183. d = cleanup(d)
  184. if (json) {
  185. msg[f] = d
  186. continue
  187. }
  188. if (includeVersions || includeFields || typeof d !== 'string') {
  189. d = inspect(d, {
  190. showHidden: false,
  191. depth: 5,
  192. colors: this.npm.color,
  193. maxArrayLength: null,
  194. })
  195. }
  196. if (f && includeFields) {
  197. f += ' = '
  198. }
  199. msg.push(`${includeVersions ? `${name}@${v} ` : ''}${includeFields ? f : ''}${d}`)
  200. }
  201. return msg
  202. })
  203. if (json) {
  204. // TODO(BREAKING_CHANGE): all unwrapping should be removed.
  205. // Users should know based on their arguments if they can expect an array or an object.
  206. // And this unwrapping can break that assumption.
  207. // e.g. `npm view abbrev@^2` should always return an array, but currently since there is only one version matching `^2` this will return a single object instead.
  208. const first = Object.keys(res[0] || {})
  209. const jsonRes = first.length === 1 ? res.map(m => m[first[0]]) : res
  210. if (jsonRes.length === 0) {
  211. return
  212. }
  213. if (jsonRes.length === 1) {
  214. return jsonRes[0]
  215. }
  216. return jsonRes
  217. }
  218. return res.join('\n').trim()
  219. }
  220. #prettyView (packu, manifest) {
  221. // More modern, pretty printing of default view
  222. const unicode = this.npm.config.get('unicode')
  223. const chalk = this.npm.chalk
  224. const deps = Object.entries(manifest.dependencies || {}).map(([k, dep]) =>
  225. `${chalk.blue(k)}: ${dep}`
  226. )
  227. // Sort dist-tags by publish time when available, then by tag name, keeping `latest` at the top of the list.
  228. const distTags = Object.entries(packu['dist-tags'])
  229. .sort(([aTag, aVer], [bTag, bVer]) => {
  230. const timeMap = packu.time || {}
  231. const aTime = aTag === 'latest' ? Infinity : Date.parse(timeMap[aVer] || 0)
  232. const bTime = bTag === 'latest' ? Infinity : Date.parse(timeMap[bVer] || 0)
  233. if (aTime === bTime) {
  234. return aTag > bTag ? -1 : 1
  235. }
  236. return aTime > bTime ? -1 : 1
  237. })
  238. .map(([k, t]) => `${chalk.blue(k)}: ${t}`)
  239. const site = manifest.homepage?.url || manifest.homepage
  240. const bins = Object.keys(manifest.bin || {})
  241. const licenseField = manifest.license || 'Proprietary'
  242. const license = typeof licenseField === 'string'
  243. ? licenseField
  244. : (licenseField.type || 'Proprietary')
  245. const res = []
  246. res.push('')
  247. res.push([
  248. chalk.underline.cyan(`${manifest.name}@${manifest.version}`),
  249. license.toLowerCase().trim() === 'proprietary'
  250. ? chalk.red(license)
  251. : chalk.green(license),
  252. `deps: ${deps.length ? chalk.cyan(deps.length) : chalk.cyan('none')}`,
  253. `versions: ${chalk.cyan(packu.versions.length + '')}`,
  254. ].join(' | '))
  255. manifest.description && res.push(manifest.description)
  256. if (site) {
  257. res.push(chalk.blue(site))
  258. }
  259. manifest.deprecated && res.push(
  260. `\n${chalk.redBright('DEPRECATED')}${unicode ? ' ⚠️ ' : '!!'} - ${manifest.deprecated}`
  261. )
  262. if (packu.keywords?.length) {
  263. res.push(`\nkeywords: ${
  264. packu.keywords.map(k => chalk.cyan(k)).join(', ')
  265. }`)
  266. }
  267. if (bins.length) {
  268. res.push(`\nbin: ${chalk.cyan(bins.join(', '))}`)
  269. }
  270. res.push('\ndist')
  271. res.push(`.tarball: ${chalk.blue(manifest.dist.tarball)}`)
  272. res.push(`.shasum: ${chalk.green(manifest.dist.shasum)}`)
  273. if (manifest.dist.integrity) {
  274. res.push(`.integrity: ${chalk.green(manifest.dist.integrity)}`)
  275. }
  276. if (manifest.dist.unpackedSize) {
  277. res.push(`.unpackedSize: ${chalk.blue(formatBytes(manifest.dist.unpackedSize, true))}`)
  278. }
  279. if (deps.length) {
  280. const maxDeps = 24
  281. res.push('\ndependencies:')
  282. res.push(deps.slice(0, maxDeps).join(', '))
  283. if (deps.length > maxDeps) {
  284. res.push(chalk.dim(`(...and ${deps.length - maxDeps} more.)`))
  285. }
  286. }
  287. if (packu.maintainers?.length) {
  288. res.push('\nmaintainers:')
  289. packu.maintainers.forEach(u =>
  290. res.push(`- ${unparsePerson({
  291. name: chalk.blue(u.name),
  292. email: chalk.dim(u.email) })}`)
  293. )
  294. }
  295. res.push('\ndist-tags:')
  296. const maxTags = 5
  297. res.push(distTags.slice(0, maxTags).join('\n'))
  298. if (distTags.length > maxTags) {
  299. res.push(chalk.dim(`(...and ${distTags.length - maxTags} more.)`))
  300. }
  301. const publisher = manifest._npmUser && unparsePerson({
  302. name: chalk.blue(manifest._npmUser.name),
  303. email: chalk.dim(manifest._npmUser.email),
  304. })
  305. if (publisher || packu.time) {
  306. let publishInfo = 'published'
  307. if (packu.time?.[manifest.version]) {
  308. publishInfo += ` ${chalk.cyan(relativeDate(packu.time[manifest.version]))}`
  309. }
  310. if (publisher) {
  311. publishInfo += ` by ${publisher}`
  312. }
  313. res.push('')
  314. res.push(publishInfo)
  315. }
  316. return res.join('\n')
  317. }
  318. }
  319. module.exports = View
  320. function parseArgs (args) {
  321. if (!args.length) {
  322. args = ['.']
  323. }
  324. const pkg = args.shift()
  325. return {
  326. pkg,
  327. local: /^\.@/.test(pkg) || pkg === '.',
  328. rest: args,
  329. }
  330. }
  331. function cleanData (obj, wholePackument) {
  332. // JSON formatted output (JSON or specific attributes from packument)
  333. const data = obj.reduce((acc, cur) => {
  334. if (cur) {
  335. Object.entries(cur).forEach(([k, v]) => {
  336. acc[k] ||= {}
  337. Object.keys(v).forEach((t) => {
  338. acc[k][t] = cur[k][t]
  339. })
  340. })
  341. }
  342. return acc
  343. }, {})
  344. if (wholePackument) {
  345. const cleaned = Object.entries(data).reduce((acc, [k, v]) => {
  346. acc[k] = v[Queryable.ALL]
  347. return acc
  348. }, {})
  349. log.silly('view', cleaned)
  350. return cleaned
  351. }
  352. return data
  353. }
  354. // return whatever was printed
  355. function showFields ({ data, version, fields, json }) {
  356. const o = [data, version].reduce((acc, s) => {
  357. Object.entries(s).forEach(([k, v]) => {
  358. acc[k] = v
  359. })
  360. return acc
  361. }, {})
  362. const queryable = new Queryable(o)
  363. if (!fields.length) {
  364. return { [version.version]: queryable.query(Queryable.ALL) }
  365. }
  366. return fields.map((field) => {
  367. const s = queryable.query(field, { unwrapSingleItemArrays: !json })
  368. if (s) {
  369. return { [version.version]: s }
  370. }
  371. })
  372. }
  373. function cleanup (data) {
  374. if (Array.isArray(data)) {
  375. return data.map(cleanup)
  376. }
  377. if (!data || typeof data !== 'object') {
  378. return data
  379. }
  380. const keys = Object.keys(data)
  381. if (keys.length <= 3 && data.name && (
  382. (keys.length === 1) ||
  383. (keys.length === 3 && data.email && data.url) ||
  384. (keys.length === 2 && (data.email || data.url)) ||
  385. data.trustedPublisher
  386. )) {
  387. data = unparsePerson(data)
  388. }
  389. return data
  390. }
  391. const unparsePerson = (d) =>
  392. `${d.name}${d.email ? ` <${d.email}>` : ''}${d.url ? ` (${d.url})` : ''}`
  393. function getCompletionFields (d, f = [], pref = []) {
  394. Object.entries(d).forEach(([k, v]) => {
  395. if (k.charAt(0) === '_' || k.indexOf('.') !== -1) {
  396. return
  397. }
  398. const p = pref.concat(k).join('.')
  399. f.push(p)
  400. if (Array.isArray(v)) {
  401. v.forEach((val, i) => {
  402. const pi = p + '[' + i + ']'
  403. if (val && typeof val === 'object') {
  404. getCompletionFields(val, f, [p])
  405. } else {
  406. f.push(pi)
  407. }
  408. })
  409. return
  410. }
  411. if (typeof v === 'object') {
  412. getCompletionFields(v, f, [p])
  413. }
  414. })
  415. return f
  416. }