Skip to content

Commit

Permalink
fixup! feat: add exec workspaces
Browse files Browse the repository at this point in the history
  • Loading branch information
ruyadorno committed Mar 17, 2021
1 parent 789a01c commit 14fec07
Show file tree
Hide file tree
Showing 3 changed files with 220 additions and 24 deletions.
87 changes: 87 additions & 0 deletions docs/content/commands/npm-exec.md
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
54 changes: 48 additions & 6 deletions 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')
Expand Down Expand Up @@ -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 () {
Expand Down Expand Up @@ -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]

Expand All @@ -87,6 +95,7 @@ class Exec extends BaseCommand {
return await this.run({
args,
call,
locationMsg,
shell,
path,
pathArr,
Expand All @@ -113,6 +122,7 @@ class Exec extends BaseCommand {
return await this.run({
args,
call,
locationMsg,
path,
pathArr,
runPath,
Expand Down Expand Up @@ -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

Expand All @@ -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({
Expand Down Expand Up @@ -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
103 changes: 85 additions & 18 deletions test/lib/exec.js
Expand Up @@ -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')

Expand Down Expand Up @@ -240,14 +242,35 @@ t.test('npm exec <noargs>, 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()
})
Expand Down Expand Up @@ -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()
})

0 comments on commit 14fec07

Please sign in to comment.