Skip to content

Commit

Permalink
feat: add exec workspaces
Browse files Browse the repository at this point in the history
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: npm/statusboard#288

PR-URL: #2886
Credit: @ruyadorno
Close: #2886
Reviewed-by: @wraithgar
  • Loading branch information
ruyadorno authored and wraithgar committed Mar 22, 2021
1 parent b876442 commit e1b3b31
Show file tree
Hide file tree
Showing 6 changed files with 504 additions and 41 deletions.
87 changes: 87 additions & 0 deletions docs/content/commands/npm-exec.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ npm exec -- <pkg>[@<version>] [args...]
npm exec --package=<pkg>[@<version>] -- <cmd> [args...]
npm exec -c '<cmd> [args...]'
npm exec --package=foo -c '<cmd> [args...]'
npm exec [-ws] [-w <workspace-name] [args...]

npx <pkg>[@<specifier>] [args...]
npx -p <pkg>[@<specifier>] <cmd> [args...]
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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)
Expand Down
87 changes: 76 additions & 11 deletions lib/exec.js
Original file line number Diff line number Diff line change
@@ -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')
Expand All @@ -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:
//
Expand All @@ -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 () {
Expand All @@ -60,29 +69,39 @@ 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
if (!call && !args.length && !packages.length) {
return await this.run({
args,
call,
locationMsg,
shell,
path,
pathArr,
runPath,
})
}

Expand All @@ -105,7 +124,10 @@ class Exec extends BaseCommand {
return await this.run({
args,
call,
locationMsg,
path,
pathArr,
runPath,
shell,
})
}
Expand All @@ -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) {}
}
Expand All @@ -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()

Expand Down Expand Up @@ -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,
Expand All @@ -220,15 +250,27 @@ 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({
...this.npm.flatOptions,
pkg,
banner: false,
// we always run in cwd, not --prefix
path: process.cwd(),
path: runPath,
stdioString: true,
event: 'npx',
args,
Expand Down Expand Up @@ -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
29 changes: 2 additions & 27 deletions lib/run-script.js
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -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) {
Expand Down
33 changes: 33 additions & 0 deletions lib/workspaces/get-workspaces.js
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit e1b3b31

Please sign in to comment.