From c045239b48bfdb8e462364b48bbda4c546756f4c Mon Sep 17 00:00:00 2001 From: Ruy Adorno Date: Sun, 7 Mar 2021 12:13:58 -0500 Subject: [PATCH] feat: add exec workspaces Add workspaces support to `npm exec` - Refactored logic to read and filter workspaces into `lib/workspaces/get-workspaces.js` - Added location context message when entering interactive shell using `npm exec` (with no args) - Add ability to execute a package in the context of each configured workspace Fixes: https://github.com/npm/statusboard/issues/288 --- docs/content/commands/npm-exec.md | 87 +++++++++++ lib/exec.js | 87 +++++++++-- lib/run-script.js | 29 +--- lib/workspaces/get-workspaces.js | 33 +++++ test/lib/exec.js | 110 +++++++++++++- test/lib/workspaces/get-workspaces.js | 199 ++++++++++++++++++++++++++ 6 files changed, 504 insertions(+), 41 deletions(-) create mode 100644 lib/workspaces/get-workspaces.js create mode 100644 test/lib/workspaces/get-workspaces.js diff --git a/docs/content/commands/npm-exec.md b/docs/content/commands/npm-exec.md index cb3e51c8255d4..88b98e3bce466 100644 --- a/docs/content/commands/npm-exec.md +++ b/docs/content/commands/npm-exec.md @@ -11,6 +11,7 @@ npm exec -- [@] [args...] npm exec --package=[@] -- [args...] npm exec -c ' [args...]' npm exec --package=foo -c ' [args...]' +npm exec [-ws] [-w [@] [args...] npx -p [@] [args...] @@ -145,6 +146,68 @@ $ npm x -c 'eslint && say "hooray, lint passed"' $ npx -c 'eslint && say "hooray, lint passed"' ``` +### Workspaces support + +You may use the `workspace` or `workspaces` configs in order to run an +arbitrary command from an npm package (either one installed locally, or fetched +remotely) in the context of the specified workspaces. +If no positional argument or `--call` option is provided, it will open an +interactive subshell in the context of each of these configured workspaces one +at a time. + +Given a project with configured workspaces, e.g: + +``` +. ++-- package.json +`-- packages + +-- a + | `-- package.json + +-- b + | `-- package.json + `-- c + `-- package.json +``` + +Assuming the workspace configuration is properly set up at the root level +`package.json` file. e.g: + +``` +{ + "workspaces": [ "./packages/*" ] +} +``` + +You can execute an arbitrary command from a package in the context of each of +the configured workspaces when using the `workspaces` configuration options, +in this example we're using **eslint** to lint any js file found within each +workspace folder: + +``` +npm exec -ws -- eslint ./*.js +``` + +#### Filtering workspaces + +It's also possible to execute a command in a single workspace using the +`workspace` config along with a name or directory path: + +``` +npm exec --workspace=a -- eslint ./*.js +``` + +The `workspace` config can also be specified multiple times in order to run a +specific script in the context of multiple workspaces. When defining values for +the `workspace` config in the command line, it also possible to use `-w` as a +shorthand, e.g: + +``` +npm exec -w a -w b -- eslint ./*.js +``` + +This last command will run the `eslint` command in both `./packages/a` and +`./packages/b` folders. + ### Compatibility with Older npx Versions The `npx` binary was rewritten in npm v7.0.0, and the standalone `npx` @@ -195,6 +258,30 @@ requested from the server. To force full offline mode, use `offline`. Forces full offline mode. Any packages not locally cached will result in an error. +#### workspace + +* Alias: `-w` +* Type: Array +* Default: `[]` + +Enable running scripts in the context of workspaces while also filtering by +the provided names or paths provided. + +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 +children workspaces) + +#### workspaces + +* Alias: `-ws` +* Type: Boolean +* Default: `false` + +Run scripts in the context of all configured workspaces for the current +project. + ### See Also * [npm run-script](/commands/npm-run-script) diff --git a/lib/exec.js b/lib/exec.js index 5b2e158313fab..25ddeb3bdc56c 100644 --- a/lib/exec.js +++ b/lib/exec.js @@ -1,5 +1,6 @@ const { promisify } = require('util') const read = promisify(require('read')) +const chalk = require('chalk') const mkdirp = require('mkdirp-infer-owner') const readPackageJson = require('read-package-json-fast') const Arborist = require('@npmcli/arborist') @@ -12,6 +13,7 @@ const npa = require('npm-package-arg') const fileExists = require('./utils/file-exists.js') const PATH = require('./utils/path.js') const BaseCommand = require('./base-command.js') +const getWorkspaces = require('./workspaces/get-workspaces.js') // it's like this: // @@ -38,6 +40,13 @@ const BaseCommand = require('./base-command.js') // runScript({ pkg, event: 'npx', ... }) // process.env.npm_lifecycle_event = 'npx' +const nocolor = { + reset: s => s, + bold: s => s, + dim: s => s, + green: s => s, +} + class Exec extends BaseCommand { /* istanbul ignore next - see test/lib/load-all-commands.js */ static get description () { @@ -60,20 +69,27 @@ class Exec extends BaseCommand { } exec (args, cb) { - this._exec(args).then(() => cb()).catch(cb) + const path = this.npm.localPrefix + const runPath = process.cwd() + this._exec(args, { path, runPath }).then(() => cb()).catch(cb) + } + + execWorkspaces (args, filters, cb) { + this._execWorkspaces(args, filters).then(() => cb()).catch(cb) } // When commands go async and we can dump the boilerplate exec methods this // can be named correctly - async _exec (args) { + async _exec (_args, { locationMsg, path, runPath }) { const call = this.npm.config.get('call') const shell = this.npm.config.get('shell') // dereferenced because we manipulate it later const packages = [...this.npm.config.get('package')] - if (call && args.length) + if (call && _args.length) throw this.usage + const args = [..._args] const pathArr = [...PATH] // nothing to maybe install, skip the arborist dance @@ -81,8 +97,11 @@ class Exec extends BaseCommand { return await this.run({ args, call, + locationMsg, shell, + path, pathArr, + runPath, }) } @@ -105,7 +124,10 @@ class Exec extends BaseCommand { return await this.run({ args, call, + locationMsg, + path, pathArr, + runPath, shell, }) } @@ -120,11 +142,11 @@ class Exec extends BaseCommand { // node_modules/${name}/package.json, and only pacote fetch if // that fails. const manis = await Promise.all(packages.map(async p => { - const spec = npa(p, this.npm.localPrefix) + const spec = npa(p, path) if (spec.type === 'tag' && spec.rawSpec === '') { // fall through to the pacote.manifest() approach try { - const pj = resolve(this.npm.localPrefix, 'node_modules', spec.name) + const pj = resolve(path, 'node_modules', spec.name) return await readPackageJson(pj) } catch (er) {} } @@ -143,7 +165,7 @@ class Exec extends BaseCommand { // figure out whether we need to install stuff, or if local is fine const localArb = new Arborist({ ...this.npm.flatOptions, - path: this.npm.localPrefix, + path, }) const tree = await localArb.loadActual() @@ -195,16 +217,24 @@ class Exec extends BaseCommand { pathArr.unshift(resolve(installDir, 'node_modules/.bin')) } - return await this.run({ args, call, pathArr, shell }) + return await this.run({ + args, + call, + locationMsg, + path, + pathArr, + runPath, + shell, + }) } - async run ({ args, call, pathArr, shell }) { + async run ({ args, call, locationMsg, path, pathArr, runPath, shell }) { // turn list of args into command string const script = call || args.shift() || shell // do the fakey runScript dance // still should work if no package.json in cwd - const realPkg = await readPackageJson(`${this.npm.localPrefix}/package.json`) + const realPkg = await readPackageJson(`${path}/package.json`) .catch(() => ({})) const pkg = { ...realPkg, @@ -220,7 +250,19 @@ class Exec extends BaseCommand { if (process.stdin.isTTY) { if (ciDetect()) return this.npm.log.warn('exec', 'Interactive mode disabled in CI environment') - this.npm.output(`\nEntering npm script environment\nType 'exit' or ^D when finished\n`) + + const color = this.npm.config.get('color') + const colorize = color ? chalk : nocolor + + locationMsg = locationMsg || ` at location:\n${colorize.dim(runPath)}` + + this.npm.output(`${ + colorize.reset('\nEntering npm script environment') + }${ + colorize.reset(locationMsg) + }${ + colorize.bold('\nType \'exit\' or ^D when finished\n') + }`) } } return await runScript({ @@ -228,7 +270,7 @@ class Exec extends BaseCommand { pkg, banner: false, // we always run in cwd, not --prefix - path: process.cwd(), + path: runPath, stdioString: true, event: 'npx', args, @@ -288,5 +330,28 @@ class Exec extends BaseCommand { .digest('hex') .slice(0, 16) } + + async workspaces (filters) { + return getWorkspaces(filters, { path: this.npm.localPrefix }) + } + + async _execWorkspaces (args, filters) { + const workspaces = await this.workspaces(filters) + const getLocationMsg = async path => { + const color = this.npm.config.get('color') + const colorize = color ? chalk : nocolor + const { _id } = await readPackageJson(`${path}/package.json`) + return ` in workspace ${colorize.green(_id)} at location:\n${colorize.dim(path)}` + } + + for (const workspacePath of workspaces.values()) { + const locationMsg = await getLocationMsg(workspacePath) + await this._exec(args, { + locationMsg, + path: workspacePath, + runPath: workspacePath, + }) + } + } } module.exports = Exec diff --git a/lib/run-script.js b/lib/run-script.js index 0f4c40b0d1ca9..61572561a9ec8 100644 --- a/lib/run-script.js +++ b/lib/run-script.js @@ -1,13 +1,12 @@ const { resolve } = require('path') const chalk = require('chalk') const runScript = require('@npmcli/run-script') -const mapWorkspaces = require('@npmcli/map-workspaces') const { isServerPackage } = runScript const rpj = require('read-package-json-fast') const log = require('npmlog') -const minimatch = require('minimatch') const didYouMean = require('./utils/did-you-mean.js') const isWindowsShell = require('./utils/is-windows-shell.js') +const getWorkspaces = require('./workspaces/get-workspaces.js') const cmdList = [ 'publish', @@ -184,31 +183,7 @@ class RunScript extends BaseCommand { } 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 + return getWorkspaces(filters, { path: this.npm.localPrefix }) } async runWorkspaces (args, filters) { diff --git a/lib/workspaces/get-workspaces.js b/lib/workspaces/get-workspaces.js new file mode 100644 index 0000000000000..64812d5403576 --- /dev/null +++ b/lib/workspaces/get-workspaces.js @@ -0,0 +1,33 @@ +const { resolve } = require('path') +const mapWorkspaces = require('@npmcli/map-workspaces') +const minimatch = require('minimatch') +const rpj = require('read-package-json-fast') + +const getWorkspaces = async (filters, { path }) => { + const pkg = await rpj(resolve(path, 'package.json')) + const workspaces = await mapWorkspaces({ cwd: path, pkg }) + const res = filters.length ? new Map() : workspaces + + for (const filterArg of filters) { + for (const [workspaceName, workspacePath] of workspaces.entries()) { + if (filterArg === workspaceName + || resolve(path, filterArg) === workspacePath + || minimatch(workspacePath, `${resolve(path, filterArg)}/*`)) + res.set(workspaceName, workspacePath) + } + } + + 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 +} + +module.exports = getWorkspaces diff --git a/test/lib/exec.js b/test/lib/exec.js index 5e859a57a3129..7104795f68541 100644 --- a/test/lib/exec.js +++ b/test/lib/exec.js @@ -108,6 +108,7 @@ t.afterEach(cb => { LOG_WARN.length = 0 PROGRESS_IGNORED = false flatOptions.legacyPeerDeps = false + config.color = false config.package = [] flatOptions.package = [] config.call = '' @@ -241,14 +242,27 @@ t.test('npm exec , run interactive shell', t => { cb() }) } - t.test('print message when tty and not in CI', t => { CI_NAME = null process.stdin.isTTY = true run(t, true, () => { t.strictSame(LOG_WARN, []) t.strictSame(OUTPUT, [ - ['\nEntering npm script environment\nType \'exit\' or ^D when finished\n'], + [`\nEntering npm script environment at location:\n${process.cwd()}\nType 'exit' or ^D when finished\n`], + ], 'printed message about interactive shell') + t.end() + }) + }) + + t.test('print message with color when tty and not in CI', t => { + CI_NAME = null + process.stdin.isTTY = true + config.color = true + + run(t, true, () => { + t.strictSame(LOG_WARN, []) + t.strictSame(OUTPUT, [ + [`\u001b[0m\u001b[0m\n\u001b[0mEntering npm script environment\u001b[0m\u001b[0m at location:\u001b[0m\n\u001b[0m\u001b[2m${process.cwd()}\u001b[22m\u001b[0m\u001b[1m\u001b[22m\n\u001b[1mType 'exit' or ^D when finished\u001b[22m\n\u001b[1m\u001b[22m`], ], 'printed message about interactive shell') t.end() }) @@ -419,7 +433,7 @@ t.test('npm exec --package=foo bar', t => { if (er) throw er t.strictSame(MKDIRPS, [], 'no need to make any dirs') - t.match(ARB_CTOR, [{ package: ['foo'], path }]) + t.match(ARB_CTOR, [{ path }]) t.strictSame(ARB_REIFY, [], 'no need to reify anything') t.equal(PROGRESS_ENABLED, true, 'progress re-enabled') t.match(RUN_SCRIPTS, [{ @@ -1084,3 +1098,93 @@ t.test('forward legacyPeerDeps opt', t => { t.done() }) }) + +t.test('workspaces', t => { + npm.localPrefix = t.testdir({ + node_modules: { + '.bin': { + foo: '', + }, + }, + packages: { + a: { + 'package.json': JSON.stringify({ + name: 'a', + version: '1.0.0', + bin: 'cli.js', + }), + 'cli.js': '', + }, + b: { + 'package.json': JSON.stringify({ + name: 'b', + version: '1.0.0', + }), + }, + }, + 'package.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + workspaces: ['packages/*'], + }), + }) + + PROGRESS_IGNORED = true + npm.localBin = resolve(npm.localPrefix, 'node_modules/.bin') + + t.test('with args, run scripts in the context of a workspace', t => { + exec.execWorkspaces(['foo', 'one arg', 'two arg'], ['a', 'b'], er => { + if (er) + throw er + + t.match(RUN_SCRIPTS, [{ + pkg: { scripts: { npx: 'foo' }}, + args: ['one arg', 'two arg'], + banner: false, + path: process.cwd(), + stdioString: true, + event: 'npx', + env: { + PATH: [npm.localBin, ...PATH].join(delimiter), + }, + stdio: 'inherit', + }]) + t.end() + }) + }) + + t.test('no args, spawn interactive shell', async t => { + CI_NAME = null + process.stdin.isTTY = true + + await new Promise((res, rej) => { + exec.execWorkspaces([], ['a'], er => { + if (er) + return rej(er) + + t.strictSame(LOG_WARN, []) + t.strictSame(OUTPUT, [ + [`\nEntering npm script environment in workspace a@1.0.0 at location:\n${resolve(npm.localPrefix, 'packages/a')}\nType 'exit' or ^D when finished\n`], + ], 'printed message about interactive shell') + res() + }) + }) + + config.color = true + OUTPUT.length = 0 + await new Promise((res, rej) => { + exec.execWorkspaces([], ['a'], er => { + if (er) + return rej(er) + + t.strictSame(LOG_WARN, []) + t.strictSame(OUTPUT, [ + [`\u001b[0m\u001b[0m\n\u001b[0mEntering npm script environment\u001b[0m\u001b[0m in workspace \u001b[32ma@1.0.0\u001b[39m at location:\u001b[0m\n\u001b[0m\u001b[2m${resolve(npm.localPrefix, 'packages/a')}\u001b[22m\u001b[0m\u001b[1m\u001b[22m\n\u001b[1mType 'exit' or ^D when finished\u001b[22m\n\u001b[1m\u001b[22m`], + ], 'printed message about interactive shell') + res() + }) + }) + }) + + t.end() +}) diff --git a/test/lib/workspaces/get-workspaces.js b/test/lib/workspaces/get-workspaces.js new file mode 100644 index 0000000000000..ebed9dd35c519 --- /dev/null +++ b/test/lib/workspaces/get-workspaces.js @@ -0,0 +1,199 @@ +const { resolve } = require('path') +const t = require('tap') +const getWorkspaces = require('../../../lib/workspaces/get-workspaces.js') + +const normalizePath = p => p + .replace(/\\+/g, '/') + .replace(/\r\n/g, '\n') + +const cleanOutput = (str, path) => normalizePath(str) + .replace(normalizePath(path), '{PATH}') + +const clean = (res, path) => { + const cleaned = new Map() + for (const [key, value] of res.entries()) + cleaned.set(key, cleanOutput(value, path)) + return cleaned +} + +t.test('get-workspaces', async t => { + const path = 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', + 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/*'], + }), + }) + + let workspaces + + workspaces = await getWorkspaces(['a', 'b'], { path }) + t.deepEqual( + clean(workspaces, path), + new Map(Object.entries({ + a: '{PATH}/packages/a', + b: '{PATH}/packages/b', + })), + 'should filter by package name' + ) + + workspaces = await getWorkspaces(['./packages/c'], { path }) + t.deepEqual( + clean(workspaces, path), + new Map(Object.entries({ + c: '{PATH}/packages/c', + })), + 'should filter by package directory' + ) + + workspaces = await getWorkspaces(['packages/c'], { path }) + t.deepEqual( + clean(workspaces, path), + new Map(Object.entries({ + c: '{PATH}/packages/c', + })), + 'should filter by rel package directory' + ) + + workspaces = await getWorkspaces([resolve(path, 'packages/c')], { path }) + t.deepEqual( + clean(workspaces, path), + new Map(Object.entries({ + c: '{PATH}/packages/c', + })), + 'should filter by absolute package directory' + ) + + workspaces = await getWorkspaces(['packages'], { path }) + t.deepEqual( + clean(workspaces, path), + new Map(Object.entries({ + a: '{PATH}/packages/a', + b: '{PATH}/packages/b', + c: '{PATH}/packages/c', + d: '{PATH}/packages/d', + e: '{PATH}/packages/e', + noscripts: '{PATH}/packages/noscripts', + })), + 'should filter by parent directory name' + ) + + workspaces = await getWorkspaces(['./packages/'], { path }) + t.deepEqual( + clean(workspaces, path), + new Map(Object.entries({ + a: '{PATH}/packages/a', + b: '{PATH}/packages/b', + c: '{PATH}/packages/c', + d: '{PATH}/packages/d', + e: '{PATH}/packages/e', + noscripts: '{PATH}/packages/noscripts', + })), + 'should filter by parent directory path' + ) + + workspaces = await getWorkspaces([resolve(path, './packages')], { path }) + t.deepEqual( + clean(workspaces, path), + new Map(Object.entries({ + a: '{PATH}/packages/a', + b: '{PATH}/packages/b', + c: '{PATH}/packages/c', + d: '{PATH}/packages/d', + e: '{PATH}/packages/e', + noscripts: '{PATH}/packages/noscripts', + })), + 'should filter by absolute parent directory path' + ) + + workspaces = await getWorkspaces([], { path }) + t.deepEqual( + clean(workspaces, path), + new Map(Object.entries({ + a: '{PATH}/packages/a', + b: '{PATH}/packages/b', + c: '{PATH}/packages/c', + d: '{PATH}/packages/d', + e: '{PATH}/packages/e', + noscripts: '{PATH}/packages/noscripts', + })), + 'should return all workspaces if no filter set' + ) + + try { + await getWorkspaces(['missing'], { path }) + throw new Error('missed throw') + } catch (err) { + t.match( + err, + /No workspaces found/, + 'should throw no workspaces found error' + ) + } + + const unconfiguredWorkspaces = t.testdir({ + 'package.json': JSON.stringify({ + name: 'no-configured-workspaces', + version: '1.0.0', + }), + }) + try { + await getWorkspaces([], { path: unconfiguredWorkspaces }) + throw new Error('missed throw') + } catch (err) { + t.match( + err, + /No workspaces found/, + 'should throw no workspaces found error' + ) + } +})