Skip to content

Commit e1b3b31

Browse files
ruyadornowraithgar
authored andcommitted
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: npm/statusboard#288 PR-URL: #2886 Credit: @ruyadorno Close: #2886 Reviewed-by: @wraithgar
1 parent b876442 commit e1b3b31

File tree

6 files changed

+504
-41
lines changed

6 files changed

+504
-41
lines changed

docs/content/commands/npm-exec.md

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ npm exec -- <pkg>[@<version>] [args...]
1111
npm exec --package=<pkg>[@<version>] -- <cmd> [args...]
1212
npm exec -c '<cmd> [args...]'
1313
npm exec --package=foo -c '<cmd> [args...]'
14+
npm exec [-ws] [-w <workspace-name] [args...]
1415

1516
npx <pkg>[@<specifier>] [args...]
1617
npx -p <pkg>[@<specifier>] <cmd> [args...]
@@ -145,6 +146,68 @@ $ npm x -c 'eslint && say "hooray, lint passed"'
145146
$ npx -c 'eslint && say "hooray, lint passed"'
146147
```
147148

149+
### Workspaces support
150+
151+
You may use the `workspace` or `workspaces` configs in order to run an
152+
arbitrary command from an npm package (either one installed locally, or fetched
153+
remotely) in the context of the specified workspaces.
154+
If no positional argument or `--call` option is provided, it will open an
155+
interactive subshell in the context of each of these configured workspaces one
156+
at a time.
157+
158+
Given a project with configured workspaces, e.g:
159+
160+
```
161+
.
162+
+-- package.json
163+
`-- packages
164+
+-- a
165+
| `-- package.json
166+
+-- b
167+
| `-- package.json
168+
`-- c
169+
`-- package.json
170+
```
171+
172+
Assuming the workspace configuration is properly set up at the root level
173+
`package.json` file. e.g:
174+
175+
```
176+
{
177+
"workspaces": [ "./packages/*" ]
178+
}
179+
```
180+
181+
You can execute an arbitrary command from a package in the context of each of
182+
the configured workspaces when using the `workspaces` configuration options,
183+
in this example we're using **eslint** to lint any js file found within each
184+
workspace folder:
185+
186+
```
187+
npm exec -ws -- eslint ./*.js
188+
```
189+
190+
#### Filtering workspaces
191+
192+
It's also possible to execute a command in a single workspace using the
193+
`workspace` config along with a name or directory path:
194+
195+
```
196+
npm exec --workspace=a -- eslint ./*.js
197+
```
198+
199+
The `workspace` config can also be specified multiple times in order to run a
200+
specific script in the context of multiple workspaces. When defining values for
201+
the `workspace` config in the command line, it also possible to use `-w` as a
202+
shorthand, e.g:
203+
204+
```
205+
npm exec -w a -w b -- eslint ./*.js
206+
```
207+
208+
This last command will run the `eslint` command in both `./packages/a` and
209+
`./packages/b` folders.
210+
148211
### Compatibility with Older npx Versions
149212

150213
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`.
195258
Forces full offline mode. Any packages not locally cached will result in
196259
an error.
197260

261+
#### workspace
262+
263+
* Alias: `-w`
264+
* Type: Array
265+
* Default: `[]`
266+
267+
Enable running scripts in the context of workspaces while also filtering by
268+
the provided names or paths provided.
269+
270+
Valid values for the `workspace` config are either:
271+
- Workspace names
272+
- Path to a workspace directory
273+
- Path to a parent workspace directory (will result to selecting all of the
274+
children workspaces)
275+
276+
#### workspaces
277+
278+
* Alias: `-ws`
279+
* Type: Boolean
280+
* Default: `false`
281+
282+
Run scripts in the context of all configured workspaces for the current
283+
project.
284+
198285
### See Also
199286

200287
* [npm run-script](/commands/npm-run-script)

lib/exec.js

Lines changed: 76 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
const { promisify } = require('util')
22
const read = promisify(require('read'))
3+
const chalk = require('chalk')
34
const mkdirp = require('mkdirp-infer-owner')
45
const readPackageJson = require('read-package-json-fast')
56
const Arborist = require('@npmcli/arborist')
@@ -12,6 +13,7 @@ const npa = require('npm-package-arg')
1213
const fileExists = require('./utils/file-exists.js')
1314
const PATH = require('./utils/path.js')
1415
const BaseCommand = require('./base-command.js')
16+
const getWorkspaces = require('./workspaces/get-workspaces.js')
1517

1618
// it's like this:
1719
//
@@ -38,6 +40,13 @@ const BaseCommand = require('./base-command.js')
3840
// runScript({ pkg, event: 'npx', ... })
3941
// process.env.npm_lifecycle_event = 'npx'
4042

43+
const nocolor = {
44+
reset: s => s,
45+
bold: s => s,
46+
dim: s => s,
47+
green: s => s,
48+
}
49+
4150
class Exec extends BaseCommand {
4251
/* istanbul ignore next - see test/lib/load-all-commands.js */
4352
static get description () {
@@ -60,29 +69,39 @@ class Exec extends BaseCommand {
6069
}
6170

6271
exec (args, cb) {
63-
this._exec(args).then(() => cb()).catch(cb)
72+
const path = this.npm.localPrefix
73+
const runPath = process.cwd()
74+
this._exec(args, { path, runPath }).then(() => cb()).catch(cb)
75+
}
76+
77+
execWorkspaces (args, filters, cb) {
78+
this._execWorkspaces(args, filters).then(() => cb()).catch(cb)
6479
}
6580

6681
// When commands go async and we can dump the boilerplate exec methods this
6782
// can be named correctly
68-
async _exec (args) {
83+
async _exec (_args, { locationMsg, path, runPath }) {
6984
const call = this.npm.config.get('call')
7085
const shell = this.npm.config.get('shell')
7186
// dereferenced because we manipulate it later
7287
const packages = [...this.npm.config.get('package')]
7388

74-
if (call && args.length)
89+
if (call && _args.length)
7590
throw this.usage
7691

92+
const args = [..._args]
7793
const pathArr = [...PATH]
7894

7995
// nothing to maybe install, skip the arborist dance
8096
if (!call && !args.length && !packages.length) {
8197
return await this.run({
8298
args,
8399
call,
100+
locationMsg,
84101
shell,
102+
path,
85103
pathArr,
104+
runPath,
86105
})
87106
}
88107

@@ -105,7 +124,10 @@ class Exec extends BaseCommand {
105124
return await this.run({
106125
args,
107126
call,
127+
locationMsg,
128+
path,
108129
pathArr,
130+
runPath,
109131
shell,
110132
})
111133
}
@@ -120,11 +142,11 @@ class Exec extends BaseCommand {
120142
// node_modules/${name}/package.json, and only pacote fetch if
121143
// that fails.
122144
const manis = await Promise.all(packages.map(async p => {
123-
const spec = npa(p, this.npm.localPrefix)
145+
const spec = npa(p, path)
124146
if (spec.type === 'tag' && spec.rawSpec === '') {
125147
// fall through to the pacote.manifest() approach
126148
try {
127-
const pj = resolve(this.npm.localPrefix, 'node_modules', spec.name)
149+
const pj = resolve(path, 'node_modules', spec.name)
128150
return await readPackageJson(pj)
129151
} catch (er) {}
130152
}
@@ -143,7 +165,7 @@ class Exec extends BaseCommand {
143165
// figure out whether we need to install stuff, or if local is fine
144166
const localArb = new Arborist({
145167
...this.npm.flatOptions,
146-
path: this.npm.localPrefix,
168+
path,
147169
})
148170
const tree = await localArb.loadActual()
149171

@@ -195,16 +217,24 @@ class Exec extends BaseCommand {
195217
pathArr.unshift(resolve(installDir, 'node_modules/.bin'))
196218
}
197219

198-
return await this.run({ args, call, pathArr, shell })
220+
return await this.run({
221+
args,
222+
call,
223+
locationMsg,
224+
path,
225+
pathArr,
226+
runPath,
227+
shell,
228+
})
199229
}
200230

201-
async run ({ args, call, pathArr, shell }) {
231+
async run ({ args, call, locationMsg, path, pathArr, runPath, shell }) {
202232
// turn list of args into command string
203233
const script = call || args.shift() || shell
204234

205235
// do the fakey runScript dance
206236
// still should work if no package.json in cwd
207-
const realPkg = await readPackageJson(`${this.npm.localPrefix}/package.json`)
237+
const realPkg = await readPackageJson(`${path}/package.json`)
208238
.catch(() => ({}))
209239
const pkg = {
210240
...realPkg,
@@ -220,15 +250,27 @@ class Exec extends BaseCommand {
220250
if (process.stdin.isTTY) {
221251
if (ciDetect())
222252
return this.npm.log.warn('exec', 'Interactive mode disabled in CI environment')
223-
this.npm.output(`\nEntering npm script environment\nType 'exit' or ^D when finished\n`)
253+
254+
const color = this.npm.config.get('color')
255+
const colorize = color ? chalk : nocolor
256+
257+
locationMsg = locationMsg || ` at location:\n${colorize.dim(runPath)}`
258+
259+
this.npm.output(`${
260+
colorize.reset('\nEntering npm script environment')
261+
}${
262+
colorize.reset(locationMsg)
263+
}${
264+
colorize.bold('\nType \'exit\' or ^D when finished\n')
265+
}`)
224266
}
225267
}
226268
return await runScript({
227269
...this.npm.flatOptions,
228270
pkg,
229271
banner: false,
230272
// we always run in cwd, not --prefix
231-
path: process.cwd(),
273+
path: runPath,
232274
stdioString: true,
233275
event: 'npx',
234276
args,
@@ -288,5 +330,28 @@ class Exec extends BaseCommand {
288330
.digest('hex')
289331
.slice(0, 16)
290332
}
333+
334+
async workspaces (filters) {
335+
return getWorkspaces(filters, { path: this.npm.localPrefix })
336+
}
337+
338+
async _execWorkspaces (args, filters) {
339+
const workspaces = await this.workspaces(filters)
340+
const getLocationMsg = async path => {
341+
const color = this.npm.config.get('color')
342+
const colorize = color ? chalk : nocolor
343+
const { _id } = await readPackageJson(`${path}/package.json`)
344+
return ` in workspace ${colorize.green(_id)} at location:\n${colorize.dim(path)}`
345+
}
346+
347+
for (const workspacePath of workspaces.values()) {
348+
const locationMsg = await getLocationMsg(workspacePath)
349+
await this._exec(args, {
350+
locationMsg,
351+
path: workspacePath,
352+
runPath: workspacePath,
353+
})
354+
}
355+
}
291356
}
292357
module.exports = Exec

lib/run-script.js

Lines changed: 2 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
const { resolve } = require('path')
22
const chalk = require('chalk')
33
const runScript = require('@npmcli/run-script')
4-
const mapWorkspaces = require('@npmcli/map-workspaces')
54
const { isServerPackage } = runScript
65
const rpj = require('read-package-json-fast')
76
const log = require('npmlog')
8-
const minimatch = require('minimatch')
97
const didYouMean = require('./utils/did-you-mean.js')
108
const isWindowsShell = require('./utils/is-windows-shell.js')
9+
const getWorkspaces = require('./workspaces/get-workspaces.js')
1110

1211
const cmdList = [
1312
'publish',
@@ -184,31 +183,7 @@ class RunScript extends BaseCommand {
184183
}
185184

186185
async workspaces (filters) {
187-
const cwd = this.npm.localPrefix
188-
const pkg = await rpj(resolve(cwd, 'package.json'))
189-
const workspaces = await mapWorkspaces({ cwd, pkg })
190-
const res = filters.length ? new Map() : workspaces
191-
192-
for (const filterArg of filters) {
193-
for (const [key, path] of workspaces.entries()) {
194-
if (filterArg === key
195-
|| resolve(cwd, filterArg) === path
196-
|| minimatch(path, `${resolve(cwd, filterArg)}/*`))
197-
res.set(key, path)
198-
}
199-
}
200-
201-
if (!res.size) {
202-
let msg = '!'
203-
if (filters.length) {
204-
msg = `:\n ${filters.reduce(
205-
(res, filterArg) => `${res} --workspace=${filterArg}`, '')}`
206-
}
207-
208-
throw new Error(`No workspaces found${msg}`)
209-
}
210-
211-
return res
186+
return getWorkspaces(filters, { path: this.npm.localPrefix })
212187
}
213188

214189
async runWorkspaces (args, filters) {

lib/workspaces/get-workspaces.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
const { resolve } = require('path')
2+
const mapWorkspaces = require('@npmcli/map-workspaces')
3+
const minimatch = require('minimatch')
4+
const rpj = require('read-package-json-fast')
5+
6+
const getWorkspaces = async (filters, { path }) => {
7+
const pkg = await rpj(resolve(path, 'package.json'))
8+
const workspaces = await mapWorkspaces({ cwd: path, pkg })
9+
const res = filters.length ? new Map() : workspaces
10+
11+
for (const filterArg of filters) {
12+
for (const [workspaceName, workspacePath] of workspaces.entries()) {
13+
if (filterArg === workspaceName
14+
|| resolve(path, filterArg) === workspacePath
15+
|| minimatch(workspacePath, `${resolve(path, filterArg)}/*`))
16+
res.set(workspaceName, workspacePath)
17+
}
18+
}
19+
20+
if (!res.size) {
21+
let msg = '!'
22+
if (filters.length) {
23+
msg = `:\n ${filters.reduce(
24+
(res, filterArg) => `${res} --workspace=${filterArg}`, '')}`
25+
}
26+
27+
throw new Error(`No workspaces found${msg}`)
28+
}
29+
30+
return res
31+
}
32+
33+
module.exports = getWorkspaces

0 commit comments

Comments
 (0)