Mini Shell
const columns = require('cli-columns')
const { readFile } = require('node:fs/promises')
const jsonParse = require('json-parse-even-better-errors')
const { log, output, META } = require('proc-log')
const npa = require('npm-package-arg')
const { resolve } = require('node:path')
const formatBytes = require('../utils/format-bytes.js')
const relativeDate = require('tiny-relative-date')
const semver = require('semver')
const { inspect } = require('node:util')
const { packument } = require('pacote')
const Queryable = require('../utils/queryable.js')
const BaseCommand = require('../base-cmd.js')
const { getError } = require('../utils/error-message.js')
const { jsonError, outputError } = require('../utils/output-error.js')
const readJson = file => readFile(file, 'utf8').then(jsonParse)
class View extends BaseCommand {
static description = 'View registry info'
static name = 'view'
static params = [
'json',
'workspace',
'workspaces',
'include-workspace-root',
]
static workspaces = true
static ignoreImplicitWorkspace = false
static usage = ['[<package-spec>] [<field>[.subfield]...]']
static async completion (opts, npm) {
if (opts.conf.argv.remain.length <= 2) {
// There used to be registry completion here, but it stopped
// making sense somewhere around 50,000 packages on the registry
return
}
// have the package, get the fields
const config = {
...npm.flatOptions,
fullMetadata: true,
preferOnline: true,
}
const spec = npa(opts.conf.argv.remain[2])
const pckmnt = await packument(spec, config)
const defaultTag = npm.config.get('tag')
const dv = pckmnt.versions[pckmnt['dist-tags'][defaultTag]]
pckmnt.versions = Object.keys(pckmnt.versions).sort(semver.compareLoose)
return getCompletionFields(pckmnt).concat(getCompletionFields(dv))
}
async exec (args) {
let { pkg, local, rest } = parseArgs(args)
if (local) {
if (this.npm.global) {
throw new Error('Cannot use view command in global mode.')
}
const dir = this.npm.prefix
const manifest = await readJson(resolve(dir, 'package.json'))
if (!manifest.name) {
throw new Error('Invalid package.json, no "name" field')
}
// put the version back if it existed
pkg = `${manifest.name}${pkg.slice(1)}`
}
await this.#viewPackage(pkg, rest)
}
async execWorkspaces (args) {
const { pkg, local, rest } = parseArgs(args)
if (!local) {
log.warn('Ignoring workspaces for specified package(s)')
return this.exec([pkg, ...rest])
}
const json = this.npm.config.get('json')
await this.setWorkspaces()
for (const name of this.workspaceNames) {
try {
await this.#viewPackage(`${name}${pkg.slice(1)}`, rest, { workspace: true })
} catch (e) {
const err = getError(e, { npm: this.npm, command: this })
if (err.code !== 'E404') {
throw e
}
if (json) {
output.buffer({ [META]: true, jsonError: { [name]: jsonError(err, this.npm) } })
} else {
outputError(err)
}
process.exitCode = err.exitCode
}
}
}
async #viewPackage (name, args, { workspace } = {}) {
const wholePackument = !args.length
const json = this.npm.config.get('json')
// If we are viewing many packages and outputting individual fields then
// output the name before doing any async activity
if (!json && !wholePackument && workspace) {
output.standard(`${name}:`)
}
const [pckmnt, data] = await this.#getData(name, args, wholePackument)
if (!json && wholePackument) {
// pretty view (entire packument)
for (const v of data) {
output.standard(this.#prettyView(pckmnt, Object.values(v)[0][Queryable.ALL]))
}
return
}
const res = this.#packageOutput(cleanData(data, wholePackument), pckmnt._id)
if (res) {
if (json) {
output.buffer(workspace ? { [name]: res } : res)
} else {
output.standard(res)
}
}
}
async #getData (pkg, args) {
const spec = npa(pkg)
const pckmnt = await packument(spec, {
...this.npm.flatOptions,
preferOnline: true,
fullMetadata: true,
})
// get the data about this package
let version = this.npm.config.get('tag')
// rawSpec is the git url if this is from git
if (spec.type !== 'git' && spec.type !== 'directory' && spec.rawSpec !== '*') {
version = spec.rawSpec
}
if (pckmnt['dist-tags']?.[version]) {
version = pckmnt['dist-tags'][version]
}
if (pckmnt.time?.unpublished) {
const u = pckmnt.time.unpublished
throw Object.assign(new Error(`Unpublished on ${u.time}`), {
statusCode: 404,
code: 'E404',
pkgid: pckmnt._id,
})
}
const versions = pckmnt.versions || {}
pckmnt.versions = Object.keys(versions).filter(v => {
if (semver.valid(v)) {
return true
}
log.info('view', `Ignoring invalid version: ${v}`)
return false
}).sort(semver.compareLoose)
// remove readme unless we asked for it
if (args.indexOf('readme') === -1) {
delete pckmnt.readme
}
const data = Object.entries(versions)
.filter(([v]) => semver.satisfies(v, version, true))
.flatMap(([, v]) => {
// remove readme unless we asked for it
if (args.indexOf('readme') !== -1) {
delete v.readme
}
return showFields({
data: pckmnt,
version: v,
fields: args,
json: this.npm.config.get('json'),
})
})
// No data has been pushed because no data is matching the specified version
if (!data.length && version !== 'latest') {
throw Object.assign(new Error(`No match found for version ${version}`), {
statusCode: 404,
code: 'E404',
pkgid: `${pckmnt._id}@${version}`,
})
}
return [pckmnt, data]
}
#packageOutput (data, name) {
const json = this.npm.config.get('json')
const versions = Object.keys(data)
const includeVersions = versions.length > 1
let includeFields
const res = versions.flatMap((v) => {
const fields = Object.entries(data[v])
includeFields ||= (fields.length > 1)
const msg = json ? {} : []
for (let [f, d] of fields) {
d = cleanup(d)
if (json) {
msg[f] = d
continue
}
if (includeVersions || includeFields || typeof d !== 'string') {
d = inspect(d, {
showHidden: false,
depth: 5,
colors: this.npm.color,
maxArrayLength: null,
})
}
if (f && includeFields) {
f += ' = '
}
msg.push(`${includeVersions ? `${name}@${v} ` : ''}${includeFields ? f : ''}${d}`)
}
return msg
})
if (json) {
// TODO(BREAKING_CHANGE): all unwrapping should be removed. Users should know
// based on their arguments if they can expect an array or an object. And this
// unwrapping can break that assumption. Eg `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.
const first = Object.keys(res[0] || {})
const jsonRes = first.length === 1 ? res.map(m => m[first[0]]) : res
if (jsonRes.length === 0) {
return
}
if (jsonRes.length === 1) {
return jsonRes[0]
}
return jsonRes
}
return res.join('\n').trim()
}
#prettyView (packu, manifest) {
// More modern, pretty printing of default view
const unicode = this.npm.config.get('unicode')
const chalk = this.npm.chalk
const deps = Object.entries(manifest.dependencies || {}).map(([k, dep]) =>
`${chalk.blue(k)}: ${dep}`
)
const site = manifest.homepage?.url || manifest.homepage
const bins = Object.keys(manifest.bin || {})
const licenseField = manifest.license || 'Proprietary'
const license = typeof licenseField === 'string'
? licenseField
: (licenseField.type || 'Proprietary')
const res = []
res.push('')
res.push([
chalk.underline.cyan(`${manifest.name}@${manifest.version}`),
license.toLowerCase().trim() === 'proprietary'
? chalk.red(license)
: chalk.green(license),
`deps: ${deps.length ? chalk.cyan(deps.length) : chalk.cyan('none')}`,
`versions: ${chalk.cyan(packu.versions.length + '')}`,
].join(' | '))
manifest.description && res.push(manifest.description)
if (site) {
res.push(chalk.blue(site))
}
manifest.deprecated && res.push(
`\n${chalk.redBright('DEPRECATED')}${unicode ? ' ⚠️ ' : '!!'} - ${manifest.deprecated}`
)
if (packu.keywords?.length) {
res.push(`\nkeywords: ${
packu.keywords.map(k => chalk.cyan(k)).join(', ')
}`)
}
if (bins.length) {
res.push(`\nbin: ${chalk.cyan(bins.join(', '))}`)
}
res.push('\ndist')
res.push(`.tarball: ${chalk.blue(manifest.dist.tarball)}`)
res.push(`.shasum: ${chalk.green(manifest.dist.shasum)}`)
if (manifest.dist.integrity) {
res.push(`.integrity: ${chalk.green(manifest.dist.integrity)}`)
}
if (manifest.dist.unpackedSize) {
res.push(`.unpackedSize: ${chalk.blue(formatBytes(manifest.dist.unpackedSize, true))}`)
}
if (deps.length) {
const maxDeps = 24
res.push('\ndependencies:')
res.push(columns(deps.slice(0, maxDeps), { padding: 1 }))
if (deps.length > maxDeps) {
res.push(chalk.dim(`(...and ${deps.length - maxDeps} more.)`))
}
}
if (packu.maintainers?.length) {
res.push('\nmaintainers:')
packu.maintainers.forEach(u =>
res.push(`- ${unparsePerson({
name: chalk.blue(u.name),
email: chalk.dim(u.email) })}`)
)
}
res.push('\ndist-tags:')
res.push(columns(Object.entries(packu['dist-tags']).map(([k, t]) =>
`${chalk.blue(k)}: ${t}`
)))
const publisher = manifest._npmUser && unparsePerson({
name: chalk.blue(manifest._npmUser.name),
email: chalk.dim(manifest._npmUser.email),
})
if (publisher || packu.time) {
let publishInfo = 'published'
if (packu.time) {
publishInfo += ` ${chalk.cyan(relativeDate(packu.time[manifest.version]))}`
}
if (publisher) {
publishInfo += ` by ${publisher}`
}
res.push('')
res.push(publishInfo)
}
return res.join('\n')
}
}
module.exports = View
function parseArgs (args) {
if (!args.length) {
args = ['.']
}
const pkg = args.shift()
return {
pkg,
local: /^\.@/.test(pkg) || pkg === '.',
rest: args,
}
}
function cleanData (obj, wholePackument) {
// JSON formatted output (JSON or specific attributes from packument)
const data = obj.reduce((acc, cur) => {
if (cur) {
Object.entries(cur).forEach(([k, v]) => {
acc[k] ||= {}
Object.keys(v).forEach((t) => {
acc[k][t] = cur[k][t]
})
})
}
return acc
}, {})
if (wholePackument) {
const cleaned = Object.entries(data).reduce((acc, [k, v]) => {
acc[k] = v[Queryable.ALL]
return acc
}, {})
log.silly('view', cleaned)
return cleaned
}
return data
}
// return whatever was printed
function showFields ({ data, version, fields, json }) {
const o = [data, version].reduce((acc, s) => {
Object.entries(s).forEach(([k, v]) => {
acc[k] = v
})
return acc
}, {})
const queryable = new Queryable(o)
if (!fields.length) {
return { [version.version]: queryable.query(Queryable.ALL) }
}
return fields.map((field) => {
const s = queryable.query(field, { unwrapSingleItemArrays: !json })
if (s) {
return { [version.version]: s }
}
})
}
function cleanup (data) {
if (Array.isArray(data)) {
return data.map(cleanup)
}
if (!data || typeof data !== 'object') {
return data
}
const keys = Object.keys(data)
if (keys.length <= 3 && data.name && (
(keys.length === 1) ||
(keys.length === 3 && data.email && data.url) ||
(keys.length === 2 && (data.email || data.url))
)) {
data = unparsePerson(data)
}
return data
}
const unparsePerson = (d) =>
`${d.name}${d.email ? ` <${d.email}>` : ''}${d.url ? ` (${d.url})` : ''}`
function getCompletionFields (d, f = [], pref = []) {
Object.entries(d).forEach(([k, v]) => {
if (k.charAt(0) === '_' || k.indexOf('.') !== -1) {
return
}
const p = pref.concat(k).join('.')
f.push(p)
if (Array.isArray(v)) {
v.forEach((val, i) => {
const pi = p + '[' + i + ']'
if (val && typeof val === 'object') {
getCompletionFields(val, f, [p])
} else {
f.push(pi)
}
})
return
}
if (typeof v === 'object') {
getCompletionFields(v, f, [p])
}
})
return f
}
Zerion Mini Shell 1.0