diff --git a/docs/content/commands/npm-outdated.md b/docs/content/commands/npm-outdated.md index cf43de7bbd553..bc9263d7aeda7 100644 --- a/docs/content/commands/npm-outdated.md +++ b/docs/content/commands/npm-outdated.md @@ -15,7 +15,8 @@ npm outdated [[<@scope>/] ...] This command will check the registry to see if any (or, specific) installed packages are currently outdated. -By default, only the direct dependencies of the root project are shown. +By default, only the direct dependencies of the root project and direct +dependencies of your configured *workspaces* are shown. Use `--all` to find all outdated meta-dependencies as well. In the output: @@ -134,6 +135,28 @@ folder instead of the current working directory. See * bin files are linked to `{prefix}/bin` * man pages are linked to `{prefix}/share/man` +#### `workspace` + +* Default: +* Type: String (can be set multiple times) + +Enable running a command in the context of the configured workspaces of the +current project while filtering by running only the workspaces defined by +this configuration option. + +Valid values for the `workspace` config are either: + +* Workspace names +* Path to a workspace directory +* Path to a parent workspace directory (will result to selecting all of the + nested workspaces) + +When set for the `npm init` command, this may be set to the folder of a +workspace which does not yet exist, to create the folder and set it up as a +brand new workspace within the project. + +This value is not exported to the environment for child processes. + ### See Also @@ -142,3 +165,4 @@ folder instead of the current working directory. See * [npm dist-tag](/commands/npm-dist-tag) * [npm registry](/using-npm/registry) * [npm folders](/configuring-npm/folders) +* [npm workspaces](/using-npm/workspaces) diff --git a/lib/outdated.js b/lib/outdated.js index 7516bafe02279..1be92b9349fe7 100644 --- a/lib/outdated.js +++ b/lib/outdated.js @@ -2,7 +2,7 @@ const os = require('os') const path = require('path') const pacote = require('pacote') const table = require('text-table') -const color = require('ansicolors') +const color = require('chalk') const styles = require('ansistyles') const npa = require('npm-package-arg') const pickManifest = require('npm-pick-manifest') @@ -10,9 +10,9 @@ const pickManifest = require('npm-pick-manifest') const Arborist = require('@npmcli/arborist') const ansiTrim = require('./utils/ansi-trim.js') -const BaseCommand = require('./base-command.js') +const ArboristWorkspaceCmd = require('./workspaces/arborist-cmd.js') -class Outdated extends BaseCommand { +class Outdated extends ArboristWorkspaceCmd { /* istanbul ignore next - see test/lib/load-all-commands.js */ static get description () { return 'Check for outdated packages' @@ -36,6 +36,7 @@ class Outdated extends BaseCommand { 'long', 'parseable', 'global', + 'workspace', ] } @@ -58,6 +59,9 @@ class Outdated extends BaseCommand { this.list = [] this.tree = await arb.loadActual() + if (this.workspaces && this.workspaces.length) + this.filterSet = arb.workspaceDependencySet(this.tree, this.workspaces) + if (args.length !== 0) { // specific deps for (let i = 0; i < args.length; i++) { @@ -116,8 +120,14 @@ class Outdated extends BaseCommand { } getEdges (nodes, type) { - if (!nodes) - return this.getEdgesOut(this.tree) + // when no nodes are provided then it should only read direct deps + // from the root node and its workspaces direct dependencies + if (!nodes) { + this.getEdgesOut(this.tree) + this.getWorkspacesEdges() + return + } + for (const node of nodes) { type === 'edgesOut' ? this.getEdgesOut(node) @@ -127,16 +137,45 @@ class Outdated extends BaseCommand { getEdgesIn (node) { for (const edge of node.edgesIn) - this.edges.add(edge) + this.trackEdge(edge) } getEdgesOut (node) { + // TODO: normalize usage of edges and avoid looping through nodes here if (this.npm.config.get('global')) { for (const child of node.children.values()) - this.edges.add(child) + this.trackEdge(child) } else { for (const edge of node.edgesOut.values()) - this.edges.add(edge) + this.trackEdge(edge) + } + } + + trackEdge (edge) { + const filteredOut = + edge.from + && this.filterSet + && this.filterSet.size > 0 + && !this.filterSet.has(edge.from.target || edge.from) + + if (filteredOut) + return + + this.edges.add(edge) + } + + getWorkspacesEdges (node) { + if (this.npm.config.get('global')) + return + + for (const edge of this.tree.edgesOut.values()) { + const workspace = edge + && edge.to + && edge.to.target + && edge.to.target.isWorkspace + + if (workspace) + this.getEdgesOut(edge.to.target) } } @@ -188,6 +227,10 @@ class Outdated extends BaseCommand { current !== wanted.version || wanted.version !== latest.version ) { + const dependent = edge.from ? + this.maybeWorkspaceName(edge.from) + : 'global' + this.list.push({ name: edge.name, path, @@ -196,7 +239,7 @@ class Outdated extends BaseCommand { location, wanted: wanted.version, latest: latest.version, - dependent: edge.from ? edge.from.name : 'global', + dependent, homepage: packument.homepage, }) } @@ -212,6 +255,23 @@ class Outdated extends BaseCommand { } } + maybeWorkspaceName (node) { + if (!node.isWorkspace) + return node.name + + const humanOutput = + !this.npm.config.get('json') && !this.npm.config.get('parseable') + + const workspaceName = + humanOutput + ? node.pkgid + : node.name + + return this.npm.color && humanOutput + ? color.green(workspaceName) + : workspaceName + } + // formatting functions makePretty (dep) { const { diff --git a/tap-snapshots/test/lib/load-all-commands.js.test.cjs b/tap-snapshots/test/lib/load-all-commands.js.test.cjs index e45239089da7e..c48745d67ec69 100644 --- a/tap-snapshots/test/lib/load-all-commands.js.test.cjs +++ b/tap-snapshots/test/lib/load-all-commands.js.test.cjs @@ -621,6 +621,7 @@ npm outdated [[<@scope>/] ...] Options: [-a|--all] [--json] [-l|--long] [-p|--parseable] [-g|--global] +[-w|--workspace [-w|--workspace ...]] Run "npm help outdated" for more info ` diff --git a/tap-snapshots/test/lib/outdated.js.test.cjs b/tap-snapshots/test/lib/outdated.js.test.cjs index fdb25e90eb8c3..9f589d0134c03 100644 --- a/tap-snapshots/test/lib/outdated.js.test.cjs +++ b/tap-snapshots/test/lib/outdated.js.test.cjs @@ -152,3 +152,101 @@ exports[`test/lib/outdated.js TAP should display outdated deps outdated specific Package Current Wanted Latest Location Depended by cat 1.0.0 1.0.1 1.0.1 node_modules/cat tap-testdir-outdated-should-display-outdated-deps ` + +exports[`test/lib/outdated.js TAP workspaces > should display all dependencies 1`] = ` + +Package Current Wanted Latest Location Depended by +cat 1.0.0 1.0.1 1.0.1 node_modules/cat a@1.0.0 +chai 1.0.0 1.0.1 1.0.1 node_modules/chai foo +dog 1.0.1 1.0.1 2.0.0 node_modules/dog tap-testdir-outdated-workspaces +theta MISSING 1.0.1 1.0.1 - c@1.0.0 +` + +exports[`test/lib/outdated.js TAP workspaces > should display json results filtered by ws 1`] = ` + +{ + "cat": { + "current": "1.0.0", + "wanted": "1.0.1", + "latest": "1.0.1", + "dependent": "a", + "location": "{CWD}/test/lib/tap-testdir-outdated-workspaces/node_modules/cat" + } +} +` + +exports[`test/lib/outdated.js TAP workspaces > should display missing deps when filtering by ws 1`] = ` + +Package Current Wanted Latest Location Depended by +theta MISSING 1.0.1 1.0.1 - c@1.0.0 +` + +exports[`test/lib/outdated.js TAP workspaces > should display nested deps when filtering by ws and using --all 1`] = ` + +Package Current Wanted Latest Location Depended by +cat 1.0.0 1.0.1 1.0.1 node_modules/cat a@1.0.0 +chai 1.0.0 1.0.1 1.0.1 node_modules/chai foo +` + +exports[`test/lib/outdated.js TAP workspaces > should display no results if ws has no deps to display 1`] = ` + +` + +exports[`test/lib/outdated.js TAP workspaces > should display parseable results filtered by ws 1`] = ` + +{CWD}/test/lib/tap-testdir-outdated-workspaces/node_modules/cat:cat@1.0.1:cat@1.0.0:cat@1.0.1:a +` + +exports[`test/lib/outdated.js TAP workspaces > should display results filtered by ws 1`] = ` + +Package Current Wanted Latest Location Depended by +cat 1.0.0 1.0.1 1.0.1 node_modules/cat a@1.0.0 +` + +exports[`test/lib/outdated.js TAP workspaces > should display ws outdated deps human output 1`] = ` + +Package Current Wanted Latest Location Depended by +cat 1.0.0 1.0.1 1.0.1 node_modules/cat a@1.0.0 +dog 1.0.1 1.0.1 2.0.0 node_modules/dog tap-testdir-outdated-workspaces +theta MISSING 1.0.1 1.0.1 - c@1.0.0 +` + +exports[`test/lib/outdated.js TAP workspaces > should display ws outdated deps json output 1`] = ` + +{ + "cat": { + "current": "1.0.0", + "wanted": "1.0.1", + "latest": "1.0.1", + "dependent": "a", + "location": "{CWD}/test/lib/tap-testdir-outdated-workspaces/node_modules/cat" + }, + "dog": { + "current": "1.0.1", + "wanted": "1.0.1", + "latest": "2.0.0", + "dependent": "tap-testdir-outdated-workspaces", + "location": "{CWD}/test/lib/tap-testdir-outdated-workspaces/node_modules/dog" + }, + "theta": { + "wanted": "1.0.1", + "latest": "1.0.1", + "dependent": "c" + } +} +` + +exports[`test/lib/outdated.js TAP workspaces > should display ws outdated deps parseable output 1`] = ` + +{CWD}/test/lib/tap-testdir-outdated-workspaces/node_modules/cat:cat@1.0.1:cat@1.0.0:cat@1.0.1:a +{CWD}/test/lib/tap-testdir-outdated-workspaces/node_modules/dog:dog@1.0.1:dog@1.0.1:dog@2.0.0:tap-testdir-outdated-workspaces +:theta@1.0.1:MISSING:theta@1.0.1:c +` + +exports[`test/lib/outdated.js TAP workspaces > should highlight ws in dependend by section 1`] = ` + +Package Current Wanted Latest Location Depended by +cat 1.0.0 1.0.1 1.0.1 node_modules/cat a@1.0.0 +dog 1.0.1 1.0.1 2.0.0 node_modules/dog tap-testdir-outdated-workspaces +theta MISSING 1.0.1 1.0.1 - c@1.0.0 +` diff --git a/tap-snapshots/test/lib/utils/npm-usage.js.test.cjs b/tap-snapshots/test/lib/utils/npm-usage.js.test.cjs index 91ad8954d2296..5fb7a871525e8 100644 --- a/tap-snapshots/test/lib/utils/npm-usage.js.test.cjs +++ b/tap-snapshots/test/lib/utils/npm-usage.js.test.cjs @@ -712,6 +712,7 @@ All commands: Options: [-a|--all] [--json] [-l|--long] [-p|--parseable] [-g|--global] + [-w|--workspace [-w|--workspace ...]] Run "npm help outdated" for more info diff --git a/test/lib/outdated.js b/test/lib/outdated.js index f7d572821275f..462ec0fc62b6c 100644 --- a/test/lib/outdated.js +++ b/test/lib/outdated.js @@ -84,6 +84,7 @@ const globalDir = t.testdir({ }) const outdated = (dir, opts) => { + logs = '' const Outdated = t.mock('../../lib/outdated.js', { pacote: { packument, @@ -91,6 +92,7 @@ const outdated = (dir, opts) => { }) const npm = mockNpm({ ...opts, + localPrefix: dir, prefix: dir, globalDir: `${globalDir}/node_modules`, output, @@ -439,3 +441,235 @@ t.test('should skip git specs', t => { t.end() }) }) + +t.test('workspaces', async t => { + const testDir = t.testdir({ + 'package.json': JSON.stringify({ + name: 'workspaces-project', + version: '1.0.0', + workspaces: ['packages/*'], + dependencies: { + dog: '^1.0.0', + }, + }), + node_modules: { + a: t.fixture('symlink', '../packages/a'), + b: t.fixture('symlink', '../packages/b'), + c: t.fixture('symlink', '../packages/c'), + cat: { + 'package.json': JSON.stringify({ + name: 'cat', + version: '1.0.0', + dependencies: { + dog: '2.0.0', + }, + }), + node_modules: { + dog: { + 'package.json': JSON.stringify({ + name: 'dog', + version: '2.0.0', + }), + }, + }, + }, + chai: { + 'package.json': JSON.stringify({ + name: 'chai', + version: '1.0.0', + }), + }, + dog: { + 'package.json': JSON.stringify({ + name: 'dog', + version: '1.0.1', + }), + }, + foo: { + 'package.json': JSON.stringify({ + name: 'foo', + version: '1.0.0', + dependencies: { + chai: '^1.0.0', + }, + }), + }, + zeta: { + 'package.json': JSON.stringify({ + name: 'zeta', + version: '1.0.0', + }), + }, + }, + packages: { + a: { + 'package.json': JSON.stringify({ + name: 'a', + version: '1.0.0', + dependencies: { + b: '^1.0.0', + cat: '^1.0.0', + foo: '^1.0.0', + }, + }), + }, + b: { + 'package.json': JSON.stringify({ + name: 'b', + version: '1.0.0', + dependencies: { + zeta: '^1.0.0', + }, + }), + }, + c: { + 'package.json': JSON.stringify({ + name: 'c', + version: '1.0.0', + dependencies: { + theta: '^1.0.0', + }, + }), + }, + }, + }) + + await new Promise((res, rej) => { + outdated(testDir, {}).exec([], err => { + if (err) + rej(err) + + t.matchSnapshot(logs, 'should display ws outdated deps human output') + res() + }) + }) + + await new Promise((res, rej) => { + outdated(testDir, { + config: { + json: true, + }, + }).exec([], err => { + if (err) + rej(err) + + t.matchSnapshot(logs, 'should display ws outdated deps json output') + res() + }) + }) + + await new Promise((res, rej) => { + outdated(testDir, { + config: { + parseable: true, + }, + }).exec([], err => { + if (err) + rej(err) + + t.matchSnapshot(logs, 'should display ws outdated deps parseable output') + res() + }) + }) + + await new Promise((res, rej) => { + outdated(testDir, { + config: { + all: true, + }, + }).exec([], err => { + if (err) + rej(err) + + t.matchSnapshot(logs, 'should display all dependencies') + res() + }) + }) + + await new Promise((res, rej) => { + outdated(testDir, { + color: true, + }).exec([], err => { + if (err) + rej(err) + + t.matchSnapshot(logs, 'should highlight ws in dependend by section') + res() + }) + }) + + await new Promise((res, rej) => { + outdated(testDir, {}).execWorkspaces([], ['a'], err => { + if (err) + rej(err) + + t.matchSnapshot(logs, 'should display results filtered by ws') + res() + }) + }) + + await new Promise((res, rej) => { + outdated(testDir, { + config: { + json: true, + }, + }).execWorkspaces([], ['a'], err => { + if (err) + rej(err) + + t.matchSnapshot(logs, 'should display json results filtered by ws') + res() + }) + }) + + await new Promise((res, rej) => { + outdated(testDir, { + config: { + parseable: true, + }, + }).execWorkspaces([], ['a'], err => { + if (err) + rej(err) + + t.matchSnapshot(logs, 'should display parseable results filtered by ws') + res() + }) + }) + + await new Promise((res, rej) => { + outdated(testDir, { + config: { + all: true, + }, + }).execWorkspaces([], ['a'], err => { + if (err) + rej(err) + + t.matchSnapshot(logs, + 'should display nested deps when filtering by ws and using --all') + res() + }) + }) + + await new Promise((res, rej) => { + outdated(testDir, {}).execWorkspaces([], ['b'], err => { + if (err) + rej(err) + + t.matchSnapshot(logs, + 'should display no results if ws has no deps to display') + res() + }) + }) + + await new Promise((res, rej) => { + outdated(testDir, {}).execWorkspaces([], ['c'], err => { + if (err) + rej(err) + + t.matchSnapshot(logs, + 'should display missing deps when filtering by ws') + res() + }) + }) +})