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`
  - Add ability to execute a package in the context of each
  configured workspace

Fixes: npm/statusboard#288
  • Loading branch information
ruyadorno committed Mar 17, 2021
1 parent 96552a0 commit 789a01c
Show file tree
Hide file tree
Showing 5 changed files with 330 additions and 46 deletions.
46 changes: 35 additions & 11 deletions lib/exec.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,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 Down Expand Up @@ -60,17 +61,25 @@ 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) {
const { package: packages, call, shell } = this.npm.flatOptions
async _exec (_args, { path, runPath }) {
const { package: p, call, shell } = this.npm.flatOptions
const packages = [...p]

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
Expand All @@ -79,7 +88,9 @@ class Exec extends BaseCommand {
args,
call,
shell,
path,
pathArr,
runPath,
})
}

Expand All @@ -102,7 +113,9 @@ class Exec extends BaseCommand {
return await this.run({
args,
call,
path,
pathArr,
runPath,
shell,
})
}
Expand All @@ -117,11 +130,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 @@ -140,7 +153,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 @@ -192,16 +205,16 @@ 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, path, pathArr, runPath, shell })
}

async run ({ args, call, pathArr, shell }) {
async run ({ args, call, 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 @@ -225,7 +238,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,
Expand Down Expand Up @@ -285,5 +298,16 @@ 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)

for (const workspacePath of workspaces.values())
await this._exec(args, { 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 @@ -178,31 +177,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
69 changes: 61 additions & 8 deletions test/lib/exec.js
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ t.test('npm exec foo, already present locally', 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, [{
Expand Down Expand Up @@ -300,7 +300,7 @@ t.test('npm exec foo, not present locally or in central loc', t => {
if (er)
throw er
t.strictSame(MKDIRPS, [installDir], 'need to make install dir')
t.match(ARB_CTOR, [{ package: ['foo'], path }])
t.match(ARB_CTOR, [{ path }])
t.match(ARB_REIFY, [{add: ['foo@'], legacyPeerDeps: false}], 'need to install foo@')
t.equal(PROGRESS_ENABLED, true, 'progress re-enabled')
const PATH = `${resolve(installDir, 'node_modules', '.bin')}${delimiter}${process.env.PATH}`
Expand Down Expand Up @@ -340,7 +340,7 @@ t.test('npm exec foo, not present locally but in central loc', t => {
if (er)
throw er
t.strictSame(MKDIRPS, [installDir], 'need to make install dir')
t.match(ARB_CTOR, [{ package: ['foo'], path }])
t.match(ARB_CTOR, [{ path }])
t.match(ARB_REIFY, [], 'no need to install again, already there')
t.equal(PROGRESS_ENABLED, true, 'progress re-enabled')
const PATH = `${resolve(installDir, 'node_modules', '.bin')}${delimiter}${process.env.PATH}`
Expand Down Expand Up @@ -380,7 +380,7 @@ t.test('npm exec foo, present locally but wrong version', t => {
if (er)
throw er
t.strictSame(MKDIRPS, [installDir], 'need to make install dir')
t.match(ARB_CTOR, [{ package: ['foo'], path }])
t.match(ARB_CTOR, [{ path }])
t.match(ARB_REIFY, [{ add: ['[email protected]'], legacyPeerDeps: false }], 'need to add [email protected]')
t.equal(PROGRESS_ENABLED, true, 'progress re-enabled')
const PATH = `${resolve(installDir, 'node_modules', '.bin')}${delimiter}${process.env.PATH}`
Expand Down Expand Up @@ -417,7 +417,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, [{
Expand Down Expand Up @@ -459,7 +459,7 @@ t.test('npm exec @foo/bar -- --some=arg, locally installed', t => {
if (er)
throw er
t.strictSame(MKDIRPS, [], 'no need to make any dirs')
t.match(ARB_CTOR, [{ package: ['@foo/bar'], 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, [{
Expand Down Expand Up @@ -502,7 +502,7 @@ t.test('npm exec @foo/bar, with same bin alias and no unscoped named bin, locall
if (er)
throw er
t.strictSame(MKDIRPS, [], 'no need to make any dirs')
t.match(ARB_CTOR, [{ package: ['@foo/bar'], 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, [{
Expand Down Expand Up @@ -666,7 +666,7 @@ t.test('npm exec -p foo -c "ls -laF"', 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, [{
Expand Down Expand Up @@ -1082,3 +1082,56 @@ 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')

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()
})
})
Loading

0 comments on commit 789a01c

Please sign in to comment.