From 32717a60eb55fcf8c7e5016223bfee78a6daba0e Mon Sep 17 00:00:00 2001 From: Gar Date: Tue, 30 Mar 2021 15:37:48 -0700 Subject: [PATCH] feat(view): add workspace support PR-URL: https://github.com/npm/cli/pull/3001 Credit: @wraithgar Close: #3001 Reviewed-by: @nlf --- docs/content/commands/npm-view.md | 3 +- lib/view.js | 186 ++++++++----- .../test-lib-utils-npm-usage.js-TAP.test.js | 3 + tap-snapshots/test-lib-view.js-TAP.test.js | 257 ++++++++++++++++++ test/lib/view.js | 126 +++++++++ 5 files changed, 511 insertions(+), 64 deletions(-) diff --git a/docs/content/commands/npm-view.md b/docs/content/commands/npm-view.md index bf09c2ba4f361..90d5218856c8e 100644 --- a/docs/content/commands/npm-view.md +++ b/docs/content/commands/npm-view.md @@ -14,8 +14,7 @@ aliases: info, show, v ### Description -This command shows data about a package and prints it to the stream -referenced by the `outfd` config, which defaults to stdout. +This command shows data about a package and prints it to stdout. As an example, to view information about the `connect` package from the registry, you would run: diff --git a/lib/view.js b/lib/view.js index e0df1e231f9d8..fb280f0d58248 100644 --- a/lib/view.js +++ b/lib/view.js @@ -7,12 +7,13 @@ const fs = require('fs') const jsonParse = require('json-parse-even-better-errors') const log = require('npmlog') const npa = require('npm-package-arg') -const path = require('path') +const { resolve } = require('path') const relativeDate = require('tiny-relative-date') const semver = require('semver') const style = require('ansistyles') const { inspect, promisify } = require('util') const { packument } = require('pacote') +const getWorkspaces = require('./workspaces/get-workspaces.js') const readFile = promisify(fs.readFile) const readJson = async file => jsonParse(await readFile(file, 'utf8')) @@ -24,6 +25,15 @@ class View extends BaseCommand { return 'View registry info' } + /* istanbul ignore next - see test/lib/load-all-commands.js */ + static get params () { + return [ + 'json', + 'workspace', + 'workspaces', + ] + } + /* istanbul ignore next - see test/lib/load-all-commands.js */ static get name () { return 'view' @@ -85,43 +95,116 @@ class View extends BaseCommand { this.view(args).then(() => cb()).catch(cb) } + execWorkspaces (args, filters, cb) { + this.viewWorkspaces(args, filters).then(() => cb()).catch(cb) + } + async view (args) { if (!args.length) args = ['.'] + let pkg = args.shift() + const local = /^\.@/.test(pkg) || pkg === '.' - const opts = { - ...this.npm.flatOptions, - preferOnline: true, - fullMetadata: true, + if (local) { + if (this.npm.config.get('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)}` } + let wholePackument = false + if (!args.length) { + args = [''] + wholePackument = true + } + const [pckmnt, data] = await this.getData(pkg, args) + + if (!this.npm.config.get('json') && wholePackument) { + // pretty view (entire packument) + data.map((v) => this.prettyView(pckmnt, v[Object.keys(v)[0]][''])) + } else { + // JSON formatted output (JSON or specific attributes from packument) + let reducedData = data.reduce(reducer, {}) + if (wholePackument) { + // No attributes + reducedData = cleanBlanks(reducedData) + log.silly('view', reducedData) + } + // disable the progress bar entirely, as we can't meaningfully update it + // if we may have partial lines printed. + log.disableProgress() + + const msg = await this.jsonData(reducedData, pckmnt._id) + if (msg !== '') + console.log(msg) + } + } + + async viewWorkspaces (args, filters) { + if (!args.length) + args = ['.'] + const pkg = args.shift() - let nv - if (/^[.]@/.test(pkg)) - nv = npa.resolve(null, pkg.slice(2)) - else - nv = npa(pkg) - const name = nv.name - const local = (name === '.' || !name) + const local = /^\.@/.test(pkg) || pkg === '.' + if (!local) { + this.npm.log.warn('Ignoring workspaces for remote package') + return this.view([pkg, ...args]) + } + let wholePackument = false + if (!args.length) { + wholePackument = true + args = [''] // getData relies on this + } + const results = {} + const workspaces = + await getWorkspaces(filters, { path: this.npm.localPrefix }) + for (const workspace of [...workspaces.entries()]) { + const wsPkg = `${workspace[0]}${pkg.slice(1)}` + const [pckmnt, data] = await this.getData(wsPkg, args) + + let reducedData = data.reduce(reducer, {}) + if (wholePackument) { + // No attributes + reducedData = cleanBlanks(reducedData) + log.silly('view', reducedData) + } - if (this.npm.config.get('global') && local) - throw new Error('Cannot use view command in global mode.') + if (!this.npm.config.get('json')) { + if (wholePackument) + data.map((v) => this.prettyView(pckmnt, v[Object.keys(v)[0]][''])) + else { + console.log(`${workspace[0]}:`) + const msg = await this.jsonData(reducedData, pckmnt._id) + if (msg !== '') + console.log(msg) + } + } else { + const msg = await this.jsonData(reducedData, pckmnt._id) + if (msg !== '') + results[workspace[0]] = JSON.parse(msg) + } + } + if (Object.keys(results).length > 0) + console.log(JSON.stringify(results, null, 2)) + } - if (local) { - const dir = this.npm.prefix - const manifest = await readJson(path.resolve(dir, 'package.json')) - if (!manifest.name) - throw new Error('Invalid package.json, no "name" field') - const p = manifest.name - nv = npa(p) - if (pkg && ~pkg.indexOf('@')) - nv.rawSpec = pkg.split('@')[pkg.indexOf('@')] + async getData (pkg, args) { + const opts = { + ...this.npm.flatOptions, + preferOnline: true, + fullMetadata: true, } + const spec = npa(pkg) + // get the data about this package - let version = nv.rawSpec || this.npm.config.get('tag') + let version = spec.rawSpec || this.npm.config.get('tag') - const pckmnt = await packument(nv, opts) + const pckmnt = await packument(spec, opts) if (pckmnt['dist-tags'] && pckmnt['dist-tags'][version]) version = pckmnt['dist-tags'][version] @@ -135,11 +218,9 @@ class View extends BaseCommand { throw er } - const results = [] + const data = [] const versions = pckmnt.versions || {} pckmnt.versions = Object.keys(versions).sort(semver.compareLoose) - if (!args.length) - args = [''] // remove readme unless we asked for it if (args.indexOf('readme') === -1) @@ -152,36 +233,22 @@ class View extends BaseCommand { if (args.indexOf('readme') !== -1) delete versions[v].readme - results.push(showFields(pckmnt, versions[v], arg)) + data.push(showFields(pckmnt, versions[v], arg)) }) } }) - let retval = results.reduce(reducer, {}) - - if (args.length === 1 && args[0] === '') { - retval = cleanBlanks(retval) - log.silly('view', retval) - } if ( !this.npm.config.get('json') && args.length === 1 && args[0] === '' - ) { - // general view + ) pckmnt.version = version - await Promise.all( - results.map((v) => this.prettyView(pckmnt, v[Object.keys(v)[0]][''])) - ) - return retval - } else { - // view by field name - await this.printData(retval, pckmnt._id) - return retval - } + + return [pckmnt, data] } - async printData (data, name) { + async jsonData (data, name) { const versions = Object.keys(data) let msg = '' let msgJson = [] @@ -233,16 +300,10 @@ class View extends BaseCommand { msg = JSON.stringify(msgJson, null, 2) + '\n' } - // disable the progress bar entirely, as we can't meaningfully update it if - // we may have partial lines printed. - log.disableProgress() - - // only log if there is something to log - if (msg !== '') - console.log(msg.trim()) + return msg.trim() } - async prettyView (packument, manifest) { + prettyView (packument, manifest) { // More modern, pretty printing of default view const unicode = this.npm.config.get('unicode') const tags = [] @@ -375,17 +436,18 @@ function cleanBlanks (obj) { return clean } -function reducer (l, r) { - if (r) { - Object.keys(r).forEach((v) => { - l[v] = l[v] || {} - Object.keys(r[v]).forEach((t) => { - l[v][t] = r[v][t] +// takes an array of objects and merges them into one object +function reducer (acc, cur) { + if (cur) { + Object.keys(cur).forEach((v) => { + acc[v] = acc[v] || {} + Object.keys(cur[v]).forEach((t) => { + acc[v][t] = cur[v][t] }) }) } - return l + return acc } // return whatever was printed diff --git a/tap-snapshots/test-lib-utils-npm-usage.js-TAP.test.js b/tap-snapshots/test-lib-utils-npm-usage.js-TAP.test.js index 4f9c1b44ebf31..66d274057ba3b 100644 --- a/tap-snapshots/test-lib-utils-npm-usage.js-TAP.test.js +++ b/tap-snapshots/test-lib-utils-npm-usage.js-TAP.test.js @@ -920,6 +920,9 @@ All commands: Usage: npm view [<@scope>/][@] [[.subfield]...] + Options: + [--json] [-w|--workspace |-w|--workspace ] [-ws|--workspaces] + aliases: v, info, show Run "npm help view" for more info diff --git a/tap-snapshots/test-lib-view.js-TAP.test.js b/tap-snapshots/test-lib-view.js-TAP.test.js index f8a9fe464df2a..02810e31a5087 100644 --- a/tap-snapshots/test-lib-view.js-TAP.test.js +++ b/tap-snapshots/test-lib-view.js-TAP.test.js @@ -270,3 +270,260 @@ dist-tags: published a year ago ` + +exports[`test/lib/view.js TAP workspaces all workspaces --json > must match snapshot 1`] = ` + +{ + "green": { + "_id": "green", + "name": "green", + "dist-tags": { + "latest": "1.0.0" + }, + "maintainers": [ + { + "name": "claudia", + "email": "c@yellow.com", + "twitter": "cyellow" + }, + { + "name": "isaacs", + "email": "i@yellow.com", + "twitter": "iyellow" + } + ], + "keywords": [ + "colors", + "green", + "crayola" + ], + "versions": [ + "1.0.0", + "1.0.1" + ], + "version": "1.0.0", + "description": "green is a very important color", + "bugs": { + "url": "http://bugs.green.com" + }, + "deprecated": true, + "repository": { + "url": "http://repository.green.com" + }, + "license": { + "type": "ACME" + }, + "bin": { + "green": "bin/green.js" + }, + "dependencies": { + "red": "1.0.0", + "yellow": "1.0.0" + }, + "dist": { + "shasum": "123", + "tarball": "http://hm.green.com/1.0.0.tgz", + "integrity": "---", + "fileCount": 1, + "unpackedSize": 1 + } + }, + "orange": { + "name": "orange", + "dist-tags": { + "latest": "1.0.0" + }, + "versions": [ + "1.0.0", + "1.0.1" + ], + "version": "1.0.0", + "homepage": "http://hm.orange.com", + "license": {}, + "dist": { + "shasum": "123", + "tarball": "http://hm.orange.com/1.0.0.tgz", + "integrity": "---", + "fileCount": 1, + "unpackedSize": 1 + } + } +} +` + +exports[`test/lib/view.js TAP workspaces all workspaces > must match snapshot 1`] = ` + + +green@1.0.0 | ACME | deps: 2 | versions: 2 +green is a very important color + +DEPRECATED!! - true + +keywords:colors, green, crayola + +bin:green + +dist +.tarball:http://hm.green.com/1.0.0.tgz +.shasum:123 +.integrity:--- +.unpackedSize:1 B + +dependencies: +red: 1.0.0 +yellow: 1.0.0 + +maintainers: +-claudia <c@yellow.com> +-isaacs <i@yellow.com> + +dist-tags: +latest: 1.0.0 + +orange@1.0.0 | Proprietary | deps: none | versions: 2 +http://hm.orange.com + +dist +.tarball:http://hm.orange.com/1.0.0.tgz +.shasum:123 +.integrity:--- +.unpackedSize:1 B + +dist-tags: +latest: 1.0.0 +` + +exports[`test/lib/view.js TAP workspaces all workspaces nonexistent field --json > must match snapshot 1`] = ` + +` + +exports[`test/lib/view.js TAP workspaces all workspaces nonexistent field > must match snapshot 1`] = ` + +green: +orange: +` + +exports[`test/lib/view.js TAP workspaces all workspaces single field --json > must match snapshot 1`] = ` + +{ + "green": "green", + "orange": "orange" +} +` + +exports[`test/lib/view.js TAP workspaces all workspaces single field > must match snapshot 1`] = ` + +green: +green +orange: +orange +` + +exports[`test/lib/view.js TAP workspaces one specific workspace > must match snapshot 1`] = ` + + +green@1.0.0 | ACME | deps: 2 | versions: 2 +green is a very important color + +DEPRECATED!! - true + +keywords:colors, green, crayola + +bin:green + +dist +.tarball:http://hm.green.com/1.0.0.tgz +.shasum:123 +.integrity:--- +.unpackedSize:1 B + +dependencies: +red: 1.0.0 +yellow: 1.0.0 + +maintainers: +-claudia <c@yellow.com> +-isaacs <i@yellow.com> + +dist-tags: +latest: 1.0.0 +` + +exports[`test/lib/view.js TAP workspaces remote package name > must match snapshot 1`] = ` +Ignoring workspaces for remote package +` + +exports[`test/lib/view.js TAP workspaces remote package name > must match snapshot 2`] = ` + + +pink@1.0.0 | Proprietary | deps: none | versions: 2 + +dist +.tarball:http://hm.pink.com/1.0.0.tgz +.shasum:123 +.integrity:--- +.unpackedSize:1 B + +dist-tags: +latest: 1.0.0 +` + +exports[`test/lib/view.js TAP workspaces single workspace --json > must match snapshot 1`] = ` + +{ + "green": { + "_id": "green", + "name": "green", + "dist-tags": { + "latest": "1.0.0" + }, + "maintainers": [ + { + "name": "claudia", + "email": "c@yellow.com", + "twitter": "cyellow" + }, + { + "name": "isaacs", + "email": "i@yellow.com", + "twitter": "iyellow" + } + ], + "keywords": [ + "colors", + "green", + "crayola" + ], + "versions": [ + "1.0.0", + "1.0.1" + ], + "version": "1.0.0", + "description": "green is a very important color", + "bugs": { + "url": "http://bugs.green.com" + }, + "deprecated": true, + "repository": { + "url": "http://repository.green.com" + }, + "license": { + "type": "ACME" + }, + "bin": { + "green": "bin/green.js" + }, + "dependencies": { + "red": "1.0.0", + "yellow": "1.0.0" + }, + "dist": { + "shasum": "123", + "tarball": "http://hm.green.com/1.0.0.tgz", + "integrity": "---", + "fileCount": 1, + "unpackedSize": 1 + } + } +} +` diff --git a/test/lib/view.js b/test/lib/view.js index d136a1f418d10..91ce18786b218 100644 --- a/test/lib/view.js +++ b/test/lib/view.js @@ -238,6 +238,7 @@ const packument = (nv, opts) => { } t.beforeEach(cleanLogs) + t.test('should log package info', t => { const View = requireInject('../../lib/view.js', { pacote: { @@ -548,6 +549,131 @@ t.test('throws when unpublished', (t) => { }) }) +t.test('workspaces', t => { + t.beforeEach((done) => { + warnMsg = undefined + config.json = false + done() + }) + const testDir = t.testdir({ + 'package.json': JSON.stringify({ + name: 'workspaces-test-package', + version: '1.2.3', + workspaces: ['test-workspace-a', 'test-workspace-b'], + }), + 'test-workspace-a': { + 'package.json': JSON.stringify({ + name: 'green', + version: '1.2.3', + }), + }, + 'test-workspace-b': { + 'package.json': JSON.stringify({ + name: 'orange', + version: '1.2.3', + }), + }, + }) + const View = requireInject('../../lib/view.js', { + pacote: { + packument, + }, + }) + const config = { + tag: 'latest', + } + let warnMsg + const npm = mockNpm({ + log: { + warn: (msg) => { + warnMsg = msg + }, + }, + config, + localPrefix: testDir, + }) + const view = new View(npm) + + t.test('all workspaces', t => { + view.execWorkspaces([], [], (err) => { + t.error(err) + t.matchSnapshot(logs) + t.end() + }) + }) + + t.test('one specific workspace', t => { + view.execWorkspaces([], ['green'], (err) => { + t.error(err) + t.matchSnapshot(logs) + t.end() + }) + }) + + t.test('all workspaces --json', t => { + config.json = true + view.execWorkspaces([], [], (err) => { + t.error(err) + t.matchSnapshot(logs) + t.end() + }) + }) + + t.test('all workspaces single field', t => { + view.execWorkspaces(['.', 'name'], [], (err) => { + t.error(err) + t.matchSnapshot(logs) + t.end() + }) + }) + + t.test('all workspaces nonexistent field', t => { + view.execWorkspaces(['.', 'foo'], [], (err) => { + t.error(err) + t.matchSnapshot(logs) + t.end() + }) + }) + + t.test('all workspaces nonexistent field --json', t => { + config.json = true + view.execWorkspaces(['.', 'foo'], [], (err) => { + t.error(err) + t.matchSnapshot(logs) + t.end() + }) + }) + + t.test('all workspaces single field --json', t => { + config.json = true + view.execWorkspaces(['.', 'name'], [], (err) => { + t.error(err) + t.matchSnapshot(logs) + t.end() + }) + }) + + t.test('single workspace --json', t => { + config.json = true + view.execWorkspaces([], ['green'], (err) => { + t.error(err) + t.matchSnapshot(logs) + t.end() + }) + }) + + t.test('remote package name', t => { + view.execWorkspaces(['pink'], [], (err) => { + t.error(err) + t.matchSnapshot(warnMsg) + t.matchSnapshot(logs) + t.end() + }) + }) + + t.end() +}) + t.test('completion', async t => { const View = requireInject('../../lib/view.js', { pacote: {