From d74d5a90e148470867afb9e7c184bc330b8cb261 Mon Sep 17 00:00:00 2001 From: Ruy Adorno Date: Wed, 10 Mar 2021 20:04:08 -0500 Subject: [PATCH] feat: add run-script workspaces Add workspaces support to `npm run-script` Related to: https://github.com/npm/rfcs/pull/117 Fixes: https://github.com/npm/statusboard/issues/276 --- lib/npm.js | 9 + lib/run-script.js | 123 +++++++++++-- lib/utils/lifecycle-cmd.js | 4 + test/lib/run-script.js | 348 ++++++++++++++++++++++++++++++++++++- 4 files changed, 469 insertions(+), 15 deletions(-) diff --git a/lib/npm.js b/lib/npm.js index 0534e630606e4..3bae72bc91c3d 100644 --- a/lib/npm.js +++ b/lib/npm.js @@ -105,9 +105,18 @@ const npm = module.exports = new class extends EventEmitter { }) } + const workspacesEnabled = this.config.get('workspaces') + const workspacesFilters = this.config.get('workspace') + const filterByWorkspaces = workspacesEnabled || workspacesFilters.length > 0 + if (this.config.get('usage')) { console.log(impl.usage) cb() + } if (filterByWorkspaces) { + impl.execWorkspaces(args, this.config.get('workspace'), er => { + process.emit('timeEnd', `command:${cmd}`) + cb(er) + }) } else { impl.exec(args, er => { process.emit('timeEnd', `command:${cmd}`) diff --git a/lib/run-script.js b/lib/run-script.js index 3ea85b79ffd18..5170fb24dcdcb 100644 --- a/lib/run-script.js +++ b/lib/run-script.js @@ -1,8 +1,10 @@ const runScript = require('@npmcli/run-script') +const mapWorkspaces = require('@npmcli/map-workspaces') const { isServerPackage } = runScript -const readJson = require('read-package-json-fast') +const rpj = require('read-package-json-fast') const { resolve } = require('path') const log = require('npmlog') +const minimatch = require('minimatch') const didYouMean = require('./utils/did-you-mean.js') const isWindowsShell = require('./utils/is-windows-shell.js') @@ -34,7 +36,7 @@ class RunScript extends BaseCommand { if (argv.length === 2) { // find the script name const json = resolve(this.npm.localPrefix, 'package.json') - const { scripts = {} } = await readJson(json).catch(er => ({})) + const { scripts = {} } = await rpj(json).catch(er => ({})) return Object.keys(scripts) } } @@ -46,12 +48,18 @@ class RunScript extends BaseCommand { this.list(args).then(() => cb()).catch(cb) } - async run (args) { - const path = this.npm.localPrefix - const event = args.shift() + execWorkspaces (args, filters, cb) { + if (args.length) + this.runWorkspaces(args, filters).then(() => cb()).catch(cb) + else + this.listWorkspaces(args, filters).then(() => cb()).catch(cb) + } + + async run (_args, { path = this.npm.localPrefix, pkg } = {}) { + const [event, ...args] = _args const { scriptShell } = this.npm.flatOptions - const pkg = await readJson(`${path}/package.json`) + pkg = pkg || (await rpj(`${path}/package.json`)) const { scripts = {} } = pkg if (event === 'restart' && !scripts.restart) @@ -102,9 +110,10 @@ class RunScript extends BaseCommand { } } - async list () { - const path = this.npm.localPrefix - const { scripts, name } = await readJson(`${path}/package.json`) + async list (args, path) { + path = path || this.npm.localPrefix + const { scripts, name, _id } = await rpj(`${path}/package.json`) + const pkgid = _id || name if (!scripts) return [] @@ -135,13 +144,13 @@ class RunScript extends BaseCommand { } if (cmds.length) - this.npm.output(`Lifecycle scripts included in ${name}:`) + this.npm.output(`Lifecycle scripts included in ${pkgid}:`) for (const script of cmds) this.npm.output(prefix + script + indent + scripts[script]) if (!cmds.length && runScripts.length) - this.npm.output(`Scripts available in ${name} via \`npm run-script\`:`) + this.npm.output(`Scripts available in ${pkgid} via \`npm run-script\`:`) else if (runScripts.length) this.npm.output('\navailable via `npm run-script`:') @@ -150,5 +159,97 @@ class RunScript extends BaseCommand { return allScripts } + + async workspaces (filters) { + const cwd = this.npm.localPrefix + const pkg = await rpj(resolve(cwd, 'package.json')) + const workspaces = await mapWorkspaces({ cwd, pkg }) + const res = filters.length ? new Map() : workspaces + + for (const filterArg of filters) { + for (const [key, path] of workspaces.entries()) { + if (filterArg === key + || resolve(cwd, filterArg) === path + || minimatch(path, `${resolve(cwd, filterArg)}/*`)) + res.set(key, path) + } + } + + if (!res.size) { + let msg = '!' + if (filters.length) { + msg = `:\n ${filters.reduce( + (res, filterArg) => `${res} --workspace=${filterArg}`, '')}` + } + + throw new Error(`No workspaces found${msg}`) + } + + return res + } + + async runWorkspaces (args, filters) { + log.disableProgress() + + const res = [] + const workspaces = await this.workspaces(filters) + + for (const workspacePath of workspaces.values()) { + const pkg = await rpj(`${workspacePath}/package.json`) + const runResult = await this.run(args, { + path: workspacePath, + pkg, + }).catch(err => { + log.error(`Lifecycle script \`${args[0]}\` failed with error:`) + log.error(err) + log.error(` for workspace: ${pkg._id || pkg.name}`) + log.error(` at location: ${workspacePath}`) + + // avoids exiting with error code in case + // there's scripts missing in some workspaces + if (!err.message.startsWith('missing script')) + process.exitCode = 1 + + // keeps track of failed runs + return 1 + }) + res.push(runResult) + } + + // in case **all** tests are failing, then it should exit with an error + // code, this handles the scenario in which all scripts are missing + if (res.every(Boolean)) + process.exitCode = 1 + } + + async listWorkspaces (args, filters) { + const workspaces = await this.workspaces(filters) + + if (log.level === 'silent') + return + + if (this.npm.flatOptions.json) { + const res = {} + for (const workspacePath of workspaces.values()) { + const { scripts, name } = await rpj(`${workspacePath}/package.json`) + res[name] = { ...scripts } + } + this.npm.output(JSON.stringify(res, null, 2)) + return + } + + if (this.npm.flatOptions.parseable) { + for (const workspacePath of workspaces.values()) { + const { scripts, name } = await rpj(`${workspacePath}/package.json`) + for (const [script, cmd] of Object.entries(scripts || {})) + this.npm.output(`${name}:${script}:${cmd}`) + } + return + } + + for (const workspacePath of workspaces.values()) + await this.list(args, workspacePath) + } } + module.exports = RunScript diff --git a/lib/utils/lifecycle-cmd.js b/lib/utils/lifecycle-cmd.js index 1917bef367855..2c5b89dfcdd04 100644 --- a/lib/utils/lifecycle-cmd.js +++ b/lib/utils/lifecycle-cmd.js @@ -10,5 +10,9 @@ class LifecycleCmd extends BaseCommand { exec (args, cb) { this.npm.commands['run-script']([this.constructor.name, ...args], cb) } + + execWorkspaces (args, filters, cb) { + this.npm.commands['run-script']([this.constructor.name, ...args], cb) + } } module.exports = LifecycleCmd diff --git a/test/lib/run-script.js b/test/lib/run-script.js index 0566daf2341f4..4c699308321fd 100644 --- a/test/lib/run-script.js +++ b/test/lib/run-script.js @@ -1,3 +1,4 @@ +const { resolve } = require('path') const t = require('tap') const requireInject = require('require-inject') @@ -23,15 +24,23 @@ const npm = { const output = [] +const npmlog = { + disableProgress: () => null, + level: 'warn', + error: () => null, +} + t.afterEach(cb => { + npmlog.level = 'warn' + npmlog.error = () => null output.length = 0 RUN_SCRIPTS.length = 0 + npm.config.set('if-present', false) npm.flatOptions.json = false npm.flatOptions.parseable = false cb() }) -const npmlog = { level: 'warn' } const getRS = windows => { const RunScript = requireInject('../../lib/run-script.js', { '@npmcli/run-script': Object.assign(async opts => { @@ -443,7 +452,7 @@ t.test('list scripts', t => { if (er) throw er t.strictSame(output, [ - ['Lifecycle scripts included in x:'], + ['Lifecycle scripts included in x@1.2.3:'], [' test\n exit 2'], [' start\n node server.js'], [' stop\n node kill-server.js'], @@ -522,7 +531,7 @@ t.test('list scripts, only commands', t => { if (er) throw er t.strictSame(output, [ - ['Lifecycle scripts included in x:'], + ['Lifecycle scripts included in x@1.2.3:'], [' preversion\n echo doing the version dance'], ]) t.end() @@ -542,9 +551,340 @@ t.test('list scripts, only non-commands', t => { if (er) throw er t.strictSame(output, [ - ['Scripts available in x via `npm run-script`:'], + ['Scripts available in x@1.2.3 via `npm run-script`:'], [' glorp\n echo doing the glerp glop'], ]) t.end() }) }) + +t.test('workspaces', t => { + npm.localPrefix = t.testdir({ + packages: { + a: { + 'package.json': JSON.stringify({ + name: 'a', + version: '1.0.0', + scripts: { glorp: 'echo a doing the glerp glop' }, + }), + }, + b: { + 'package.json': JSON.stringify({ + name: 'b', + version: '2.0.0', + scripts: { glorp: 'echo b doing the glerp glop' }, + }), + }, + c: { + 'package.json': JSON.stringify({ + name: 'c', + version: '1.0.0', + scripts: { + test: 'exit 0', + posttest: 'echo posttest', + lorem: 'echo c lorem', + }, + }), + }, + d: { + 'package.json': JSON.stringify({ + name: 'd', + version: '1.0.0', + scripts: { + test: 'exit 0', + posttest: 'echo posttest', + }, + }), + }, + e: { + 'package.json': JSON.stringify({ + name: 'e', + version: '1.0.0', + scripts: { test: 'exit 0', start: 'echo start something' }, + }), + }, + noscripts: { + 'package.json': JSON.stringify({ + name: 'noscripts', + version: '1.0.0', + }), + }, + }, + 'package.json': JSON.stringify({ + name: 'x', + version: '1.2.3', + workspaces: ['packages/*'], + }), + }) + + t.test('list all scripts', t => { + runScript.execWorkspaces([], [], er => { + if (er) + throw er + t.strictSame(output, [ + ['Scripts available in a@1.0.0 via `npm run-script`:'], + [' glorp\n echo a doing the glerp glop'], + ['Scripts available in b@2.0.0 via `npm run-script`:'], + [' glorp\n echo b doing the glerp glop'], + ['Lifecycle scripts included in c@1.0.0:'], + [' test\n exit 0'], + [' posttest\n echo posttest'], + ['\navailable via `npm run-script`:'], + [' lorem\n echo c lorem'], + ['Lifecycle scripts included in d@1.0.0:'], + [' test\n exit 0'], + [' posttest\n echo posttest'], + ['Lifecycle scripts included in e@1.0.0:'], + [' test\n exit 0'], + [' start\n echo start something'], + ]) + t.end() + }) + }) + + t.test('list regular scripts, filtered by name', t => { + runScript.execWorkspaces([], ['a', 'b'], er => { + if (er) + throw er + t.strictSame(output, [ + ['Scripts available in a@1.0.0 via `npm run-script`:'], + [' glorp\n echo a doing the glerp glop'], + ['Scripts available in b@2.0.0 via `npm run-script`:'], + [' glorp\n echo b doing the glerp glop'], + ]) + t.end() + }) + }) + + t.test('list regular scripts, filtered by path', t => { + runScript.execWorkspaces([], ['./packages/a'], er => { + if (er) + throw er + t.strictSame(output, [ + ['Scripts available in a@1.0.0 via `npm run-script`:'], + [' glorp\n echo a doing the glerp glop'], + ]) + t.end() + }) + }) + + t.test('list regular scripts, filtered by parent folder', t => { + runScript.execWorkspaces([], ['./packages'], er => { + if (er) + throw er + t.strictSame(output, [ + ['Scripts available in a@1.0.0 via `npm run-script`:'], + [' glorp\n echo a doing the glerp glop'], + ['Scripts available in b@2.0.0 via `npm run-script`:'], + [' glorp\n echo b doing the glerp glop'], + ['Lifecycle scripts included in c@1.0.0:'], + [' test\n exit 0'], + [' posttest\n echo posttest'], + ['\navailable via `npm run-script`:'], + [' lorem\n echo c lorem'], + ['Lifecycle scripts included in d@1.0.0:'], + [' test\n exit 0'], + [' posttest\n echo posttest'], + ['Lifecycle scripts included in e@1.0.0:'], + [' test\n exit 0'], + [' start\n echo start something'], + ]) + t.end() + }) + }) + + t.test('list all scripts --json', t => { + npm.flatOptions.json = true + runScript.execWorkspaces([], [], er => { + if (er) + throw er + t.strictSame(output, [ + [ + '{\n' + + ' "a": {\n' + + ' "glorp": "echo a doing the glerp glop"\n' + + ' },\n' + + ' "b": {\n' + + ' "glorp": "echo b doing the glerp glop"\n' + + ' },\n' + + ' "c": {\n' + + ' "test": "exit 0",\n' + + ' "posttest": "echo posttest",\n' + + ' "lorem": "echo c lorem"\n' + + ' },\n' + + ' "d": {\n' + + ' "test": "exit 0",\n' + + ' "posttest": "echo posttest"\n' + + ' },\n' + + ' "e": {\n' + + ' "test": "exit 0",\n' + + ' "start": "echo start something"\n' + + ' },\n' + + ' "noscripts": {}\n' + + '}', + ], + ]) + t.end() + }) + }) + + t.test('list all scripts --parseable', t => { + npm.flatOptions.parseable = true + runScript.execWorkspaces([], [], er => { + if (er) + throw er + t.strictSame(output, [ + ['a:glorp:echo a doing the glerp glop'], + ['b:glorp:echo b doing the glerp glop'], + ['c:test:exit 0'], + ['c:posttest:echo posttest'], + ['c:lorem:echo c lorem'], + ['d:test:exit 0'], + ['d:posttest:echo posttest'], + ['e:test:exit 0'], + ['e:start:echo start something'], + ]) + t.end() + }) + }) + + t.test('list no scripts --loglevel=silent', t => { + npmlog.level = 'silent' + runScript.execWorkspaces([], [], er => { + if (er) + throw er + t.strictSame(output, []) + t.end() + }) + }) + + t.test('run scripts across all workspaces', t => { + runScript.execWorkspaces(['test'], [], er => { + if (er) + throw er + + t.match(RUN_SCRIPTS, [ + { + path: resolve(npm.localPrefix, '/packages/c'), + pkg: { name: 'c', version: '1.0.0' }, + event: 'test', + }, + { + path: resolve(npm.localPrefix, '/packages/c'), + pkg: { name: 'c', version: '1.0.0' }, + event: 'posttest', + }, + { + path: resolve(npm.localPrefix, 'packages/d'), + pkg: { name: 'd', version: '1.0.0' }, + event: 'test', + }, + { + path: resolve(npm.localPrefix, 'packages/d'), + pkg: { name: 'd', version: '1.0.0' }, + event: 'posttest', + }, + { + path: resolve(npm.localPrefix, 'packages/e'), + pkg: { name: 'e', version: '1.0.0' }, + event: 'test', + }, + ]) + t.end() + }) + }) + + t.test('missing scripts in all workspaces', t => { + const LOG = [] + npmlog.error = (err) => { + LOG.push(String(err)) + } + runScript.execWorkspaces(['missing-script'], [], er => { + if (er) + throw er + + process.exitCode = 0 // clean exit code + + t.match(RUN_SCRIPTS, []) + t.strictSame(LOG, [ + 'Lifecycle script `missing-script` failed with error:', + 'Error: missing script: missing-script', + ' for workspace: a@1.0.0', + ' at location: /Users/ruyadorno/Documents/workspace/cli/main/test/lib/run-script-workspaces/packages/a', + 'Lifecycle script `missing-script` failed with error:', + 'Error: missing script: missing-script', + ' for workspace: b@2.0.0', + ' at location: /Users/ruyadorno/Documents/workspace/cli/main/test/lib/run-script-workspaces/packages/b', + 'Lifecycle script `missing-script` failed with error:', + 'Error: missing script: missing-script', + ' for workspace: c@1.0.0', + ' at location: /Users/ruyadorno/Documents/workspace/cli/main/test/lib/run-script-workspaces/packages/c', + 'Lifecycle script `missing-script` failed with error:', + 'Error: missing script: missing-script', + ' for workspace: d@1.0.0', + ' at location: /Users/ruyadorno/Documents/workspace/cli/main/test/lib/run-script-workspaces/packages/d', + 'Lifecycle script `missing-script` failed with error:', + 'Error: missing script: missing-script', + ' for workspace: e@1.0.0', + ' at location: /Users/ruyadorno/Documents/workspace/cli/main/test/lib/run-script-workspaces/packages/e', + 'Lifecycle script `missing-script` failed with error:', + 'Error: missing script: missing-script', + ' for workspace: noscripts@1.0.0', + ' at location: /Users/ruyadorno/Documents/workspace/cli/main/test/lib/run-script-workspaces/packages/noscripts', + ], 'should log error msgs for each workspace script') + t.end() + }) + }) + + t.test('missing scripts in some workspaces', t => { + const LOG = [] + npmlog.error = (err) => { + LOG.push(String(err)) + } + runScript.execWorkspaces(['test'], ['a', 'b', 'c', 'd'], er => { + if (er) + throw er + + t.match(RUN_SCRIPTS, []) + t.strictSame(LOG, [ + 'Lifecycle script `test` failed with error:', + 'Error: missing script: test', + ' for workspace: a@1.0.0', + ' at location: /Users/ruyadorno/Documents/workspace/cli/main/test/lib/run-script-workspaces/packages/a', + 'Lifecycle script `test` failed with error:', + 'Error: missing script: test', + ' for workspace: b@2.0.0', + ' at location: /Users/ruyadorno/Documents/workspace/cli/main/test/lib/run-script-workspaces/packages/b', + ], 'should log error msgs for each workspace script') + t.end() + }) + }) + + t.test('no workspaces when filtering by user args', t => { + runScript.execWorkspaces([], ['foo', 'bar'], er => { + t.equal( + er.message, + 'No workspaces found:\n --workspace=foo --workspace=bar', + 'should throw error msg' + ) + t.end() + }) + }) + + t.test('no workspaces', t => { + const _prevPrefix = npm.localPrefix + npm.localPrefix = t.testdir({ + 'package.json': JSON.stringify({ + name: 'foo', + version: '1.0.0', + }), + }) + + runScript.execWorkspaces([], [], er => { + t.match(er, /No workspaces found!/, 'should throw error msg') + npm.localPrefix = _prevPrefix + t.end() + }) + }) + t.end() +})