From 14fec07ee488697e0343fc45ad42d8baff67e5e0 Mon Sep 17 00:00:00 2001 From: Ruy Adorno Date: Wed, 17 Mar 2021 19:28:38 -0400 Subject: [PATCH] fixup! feat: add exec workspaces --- docs/content/commands/npm-exec.md | 87 +++++++++++++++++++++++++ lib/exec.js | 54 ++++++++++++++-- test/lib/exec.js | 103 ++++++++++++++++++++++++------ 3 files changed, 220 insertions(+), 24 deletions(-) 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 f2c2fefab46d4..48e516c2fe679 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') @@ -39,6 +40,13 @@ const getWorkspaces = require('./workspaces/get-workspaces.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 name () { @@ -72,7 +80,7 @@ class Exec extends BaseCommand { // When commands go async and we can dump the boilerplate exec methods this // can be named correctly - async _exec (_args, { path, runPath }) { + async _exec (_args, { locationMsg, path, runPath }) { const { package: p, call, shell } = this.npm.flatOptions const packages = [...p] @@ -87,6 +95,7 @@ class Exec extends BaseCommand { return await this.run({ args, call, + locationMsg, shell, path, pathArr, @@ -113,6 +122,7 @@ class Exec extends BaseCommand { return await this.run({ args, call, + locationMsg, path, pathArr, runPath, @@ -205,10 +215,18 @@ class Exec extends BaseCommand { pathArr.unshift(resolve(installDir, 'node_modules/.bin')) } - return await this.run({ args, call, path, pathArr, runPath, shell }) + return await this.run({ + args, + call, + locationMsg, + path, + pathArr, + runPath, + shell, + }) } - async run ({ args, call, path, pathArr, runPath, shell }) { + async run ({ args, call, locationMsg, path, pathArr, runPath, shell }) { // turn list of args into command string const script = call || args.shift() || shell @@ -230,7 +248,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({ @@ -305,9 +335,21 @@ class Exec extends BaseCommand { 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()) - await this._exec(args, { path: workspacePath, runPath: workspacePath }) + 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/test/lib/exec.js b/test/lib/exec.js index 0a45f64e4e803..071ac0ade061c 100644 --- a/test/lib/exec.js +++ b/test/lib/exec.js @@ -38,6 +38,8 @@ const npm = { globalBin: 'global-bin', config: { get: k => { + if (k === 'color') + return false if (k !== 'cache') throw new Error('unexpected config get') @@ -240,14 +242,35 @@ 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 + + const _config = npm.config + npm.config = { get (k) { + if (k === 'color') + return true + } } + t.teardown(() => { + npm.config = _config + }) + + 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() }) @@ -1116,22 +1139,66 @@ t.test('workspaces', t => { PROGRESS_IGNORED = true npm.localBin = resolve(npm.localPrefix, 'node_modules/.bin') - exec.execWorkspaces(['foo', 'one arg', 'two arg'], ['a', 'b'], er => { - if (er) - throw er + 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.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() + }) + }) + + const _config = npm.config + npm.config = { get (k) { + if (k === 'color') + return true + } } + t.teardown(() => { + npm.config = _config + }) + 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() })