diff --git a/docs/content/commands/npm-adduser.md b/docs/content/commands/npm-adduser.md index 7960869ad33cc..d0ddd68c2529a 100644 --- a/docs/content/commands/npm-adduser.md +++ b/docs/content/commands/npm-adduser.md @@ -12,6 +12,8 @@ npm adduser [--registry=url] [--scope=@orgname] [--always-auth] [--auth-type=leg aliases: login, add-user ``` +Note: This command is unaware of workspaces. + ### Description Create or verify a user named `` in the specified registry, and diff --git a/docs/content/commands/npm-bin.md b/docs/content/commands/npm-bin.md index 4303040e78dac..c835784f675a0 100644 --- a/docs/content/commands/npm-bin.md +++ b/docs/content/commands/npm-bin.md @@ -10,6 +10,8 @@ description: Display npm bin folder npm bin [-g|--global] ``` +Note: This command is unaware of workspaces. + ### Description Print the folder where npm will install executables. diff --git a/docs/content/commands/npm-cache.md b/docs/content/commands/npm-cache.md index 13386f2c4a439..bcc2989b7d3c3 100644 --- a/docs/content/commands/npm-cache.md +++ b/docs/content/commands/npm-cache.md @@ -18,6 +18,8 @@ aliases: npm cache clear, npm cache rm npm cache verify ``` +Note: This command is unaware of workspaces. + ### Description Used to add, list, or clean the npm cache folder. diff --git a/docs/content/commands/npm-completion.md b/docs/content/commands/npm-completion.md index 53737c8033194..9dbd960913f27 100644 --- a/docs/content/commands/npm-completion.md +++ b/docs/content/commands/npm-completion.md @@ -10,6 +10,8 @@ description: Tab Completion for npm source <(npm completion) ``` +Note: This command is unaware of workspaces. + ### Description Enables tab-completion in all npm commands. diff --git a/docs/content/commands/npm-config.md b/docs/content/commands/npm-config.md index 51caa5a61b607..31629a6b7d7a2 100644 --- a/docs/content/commands/npm-config.md +++ b/docs/content/commands/npm-config.md @@ -18,6 +18,8 @@ npm get [ [ ...]] alias: c ``` +Note: This command is unaware of workspaces. + ### Description npm gets its config settings from the command line, environment diff --git a/docs/content/commands/npm-deprecate.md b/docs/content/commands/npm-deprecate.md index 139441856bb06..0603797661055 100644 --- a/docs/content/commands/npm-deprecate.md +++ b/docs/content/commands/npm-deprecate.md @@ -10,6 +10,8 @@ description: Deprecate a version of a package npm deprecate [@] ``` +Note: This command is unaware of workspaces. + ### Description This command will update the npm registry entry for a package, providing a diff --git a/docs/content/commands/npm-doctor.md b/docs/content/commands/npm-doctor.md index 2aceee2390331..9416818a40aaf 100644 --- a/docs/content/commands/npm-doctor.md +++ b/docs/content/commands/npm-doctor.md @@ -10,6 +10,8 @@ description: Check your npm environment npm doctor ``` +Note: This command is unaware of workspaces. + ### Description `npm doctor` runs a set of checks to ensure that your npm installation has diff --git a/docs/content/commands/npm-edit.md b/docs/content/commands/npm-edit.md index 40fac0408529a..20788aafb6d6a 100644 --- a/docs/content/commands/npm-edit.md +++ b/docs/content/commands/npm-edit.md @@ -10,6 +10,8 @@ description: Edit an installed package npm edit ``` +Note: This command is unaware of workspaces. + ### Description Selects a dependency in the current project and opens the package folder in diff --git a/docs/content/commands/npm-explore.md b/docs/content/commands/npm-explore.md index e467a755753b4..7e2004b84c041 100644 --- a/docs/content/commands/npm-explore.md +++ b/docs/content/commands/npm-explore.md @@ -10,6 +10,8 @@ description: Browse an installed package npm explore [ -- ] ``` +Note: This command is unaware of workspaces. + ### Description Spawn a subshell in the directory of the installed package specified. diff --git a/docs/content/commands/npm-help-search.md b/docs/content/commands/npm-help-search.md index e10638efa07d9..51c7b43fb54f1 100644 --- a/docs/content/commands/npm-help-search.md +++ b/docs/content/commands/npm-help-search.md @@ -10,6 +10,8 @@ description: Search npm help documentation npm help-search ``` +Note: This command is unaware of workspaces. + ### Description This command will search the npm markdown documentation files for the terms diff --git a/docs/content/commands/npm-help.md b/docs/content/commands/npm-help.md index 56e46645522ba..57c5efc8ed5eb 100644 --- a/docs/content/commands/npm-help.md +++ b/docs/content/commands/npm-help.md @@ -10,6 +10,8 @@ description: Get help on npm npm help [] ``` +Note: This command is unaware of workspaces. + ### Description If supplied a topic, then show the appropriate documentation page. diff --git a/docs/content/commands/npm-hook.md b/docs/content/commands/npm-hook.md index 2ac548ada0c21..6effc9b7d223b 100644 --- a/docs/content/commands/npm-hook.md +++ b/docs/content/commands/npm-hook.md @@ -13,6 +13,8 @@ npm hook update [secret] npm hook rm ``` +Note: This command is unaware of workspaces. + ### Description Allows you to manage [npm diff --git a/docs/content/commands/npm-logout.md b/docs/content/commands/npm-logout.md index 7fa858a99993d..1172a3f0f560a 100644 --- a/docs/content/commands/npm-logout.md +++ b/docs/content/commands/npm-logout.md @@ -10,6 +10,8 @@ description: Log out of the registry npm logout [--registry=] [--scope=<@scope>] ``` +Note: This command is unaware of workspaces. + ### Description When logged into a registry that supports token-based authentication, tell diff --git a/docs/content/commands/npm-org.md b/docs/content/commands/npm-org.md index 18047d109cc0b..384f5b99fd42e 100644 --- a/docs/content/commands/npm-org.md +++ b/docs/content/commands/npm-org.md @@ -12,6 +12,8 @@ npm org rm npm org ls [] ``` +Note: This command is unaware of workspaces. + ### Example Add a new developer to an org: diff --git a/docs/content/commands/npm-owner.md b/docs/content/commands/npm-owner.md index 69eba56afd97d..b30bbc8dc68ef 100644 --- a/docs/content/commands/npm-owner.md +++ b/docs/content/commands/npm-owner.md @@ -14,6 +14,8 @@ npm owner ls [<@scope>/] aliases: author ``` +Note: This command is unaware of workspaces. + ### Description Manage ownership of published packages. diff --git a/docs/content/commands/npm-ping.md b/docs/content/commands/npm-ping.md index 8de06aa184836..f640bf060c750 100644 --- a/docs/content/commands/npm-ping.md +++ b/docs/content/commands/npm-ping.md @@ -10,6 +10,8 @@ description: Ping npm registry npm ping [--registry ] ``` +Note: This command is unaware of workspaces. + ### Description Ping the configured or given npm registry and verify authentication. diff --git a/docs/content/commands/npm-prefix.md b/docs/content/commands/npm-prefix.md index 9c33bb18901ef..4e3edf1902301 100644 --- a/docs/content/commands/npm-prefix.md +++ b/docs/content/commands/npm-prefix.md @@ -10,6 +10,8 @@ description: Display prefix npm prefix [-g] ``` +Note: This command is unaware of workspaces. + ### Description Print the local prefix to standard output. This is the closest parent directory diff --git a/docs/content/commands/npm-profile.md b/docs/content/commands/npm-profile.md index 88edf26d87c41..b4e2fdaee6cb1 100644 --- a/docs/content/commands/npm-profile.md +++ b/docs/content/commands/npm-profile.md @@ -14,6 +14,8 @@ npm profile enable-2fa [auth-and-writes|auth-only] npm profile disable-2fa ``` +Note: This command is unaware of workspaces. + ### Description Change your profile information on the registry. Note that this command diff --git a/docs/content/commands/npm-run-script.md b/docs/content/commands/npm-run-script.md index 8b89435e1a97b..076dfd7addcc3 100644 --- a/docs/content/commands/npm-run-script.md +++ b/docs/content/commands/npm-run-script.md @@ -8,6 +8,8 @@ description: Run arbitrary package scripts ```bash npm run-script [--if-present] [--silent] [-- ] +npm run-script [--workspace=] +npm run-script [--workspaces] aliases: run, rum, urn ``` @@ -78,6 +80,65 @@ If you try to run a script without having a `node_modules` directory and it fails, you will be given a warning to run `npm install`, just in case you've forgotten. +### Workspaces support + +You may use the `workspace` or `workspaces` configs in order to run an +arbitrary command from a package's `"scripts"` object in the context of the +specified workspaces. If no `"command"` is provided, it will list the available +scripts for each of these configured workspaces. + +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/*" ] +} +``` + +And that each of the configured workspaces has a configured `test` script, +we can run tests in all of them using the `workspaces` config: + +``` +npm test --workspaces +``` + +#### Filtering workspaces + +It's also possible to run a script in a single workspace using the `workspace` +config along with a name or directory path: + +``` +npm test --workspace=a +``` + +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 test -w a -w b +``` + +This last command will run `test` in both `./packages/a` and `./packages/b` +packages. + + ### Configuration #### if-present @@ -111,6 +172,30 @@ to `/bin/sh` on Unix, defaults to `env.comspec` or `cmd.exe` on Windows. You can use the `--silent` flag to prevent showing `npm ERR!` output on 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 scripts](/using-npm/scripts) diff --git a/docs/content/commands/npm-search.md b/docs/content/commands/npm-search.md index 35178bcb0a580..046c9334ff062 100644 --- a/docs/content/commands/npm-search.md +++ b/docs/content/commands/npm-search.md @@ -12,6 +12,8 @@ npm search [-l|--long] [--json] [--parseable] [--no-description] [search terms . aliases: s, se, find ``` +Note: This command is unaware of workspaces. + ### Description Search the registry for packages matching the search terms. `npm search` diff --git a/docs/content/commands/npm-shrinkwrap.md b/docs/content/commands/npm-shrinkwrap.md index dce50b7843bc3..6786229469d2c 100644 --- a/docs/content/commands/npm-shrinkwrap.md +++ b/docs/content/commands/npm-shrinkwrap.md @@ -10,6 +10,8 @@ description: Lock down dependency versions for publication npm shrinkwrap ``` +Note: This command is unaware of workspaces. + ### Description This command repurposes `package-lock.json` into a publishable diff --git a/docs/content/commands/npm-star.md b/docs/content/commands/npm-star.md index aab6e107747fd..e624b92480f91 100644 --- a/docs/content/commands/npm-star.md +++ b/docs/content/commands/npm-star.md @@ -10,6 +10,8 @@ description: Mark your favorite packages npm star [...] ``` +Note: This command is unaware of workspaces. + ### Description "Starring" a package means that you have some interest in it. It's diff --git a/docs/content/commands/npm-stars.md b/docs/content/commands/npm-stars.md index dab11bc669d1a..80217ee044aa8 100644 --- a/docs/content/commands/npm-stars.md +++ b/docs/content/commands/npm-stars.md @@ -9,6 +9,8 @@ description: View packages marked as favorites npm stars [] ``` +Note: This command is unaware of workspaces. + ### Description If you have starred a lot of neat things and want to find them again diff --git a/docs/content/commands/npm-team.md b/docs/content/commands/npm-team.md index 96aacd8ae95f2..04e1d7f9eb1a5 100644 --- a/docs/content/commands/npm-team.md +++ b/docs/content/commands/npm-team.md @@ -16,6 +16,8 @@ npm team rm npm team ls | ``` +Note: This command is unaware of workspaces. + ### Description Used to manage teams in organizations, and change team memberships. Does not diff --git a/docs/content/commands/npm-token.md b/docs/content/commands/npm-token.md index 652079453702e..bafc7fc45c677 100644 --- a/docs/content/commands/npm-token.md +++ b/docs/content/commands/npm-token.md @@ -9,7 +9,9 @@ description: Manage your authentication tokens npm token list [--json|--parseable] npm token create [--read-only] [--cidr=1.1.1.1/24,2.2.2.2/16] npm token revoke - ``` +``` + +Note: This command is unaware of workspaces. ### Description diff --git a/docs/content/commands/npm-unstar.md b/docs/content/commands/npm-unstar.md index 5471d908004e1..bad1917593841 100644 --- a/docs/content/commands/npm-unstar.md +++ b/docs/content/commands/npm-unstar.md @@ -10,6 +10,8 @@ description: Remove an item from your favorite packages npm unstar [...] ``` +Note: This command is unaware of workspaces. + ### Description "Unstarring" a package is the opposite of [`npm star`](/commands/npm-star), diff --git a/docs/content/commands/npm-whoami.md b/docs/content/commands/npm-whoami.md index 43b301c51707a..892adeea3db7c 100644 --- a/docs/content/commands/npm-whoami.md +++ b/docs/content/commands/npm-whoami.md @@ -10,6 +10,8 @@ description: Display npm username npm whoami [--registry ] ``` +Note: This command is unaware of workspaces. + ### Description Print the `username` config to standard output. diff --git a/docs/content/using-npm/workspaces.md b/docs/content/using-npm/workspaces.md index 2024627c75867..28fccd2200c32 100644 --- a/docs/content/using-npm/workspaces.md +++ b/docs/content/using-npm/workspaces.md @@ -88,8 +88,54 @@ This demonstrates how the nature of `node_modules` resolution allows for in such a way that is also easy to [publish](/commands/npm-publish) these nested workspaces to be consumed elsewhere. +### Running commands in the context of workspaces + +You man use the `workspace` configuration option to run commands in the context +of a configured workspace. + +Following is a quick example on how to use the `npm run` command in the context +of nested workspaces. For a project containing multiple workspaces, e.g: + +``` +. ++-- package.json +`-- packages + +-- a + | `-- package.json + `-- b + `-- package.json +``` + +By running a command using the `workspace` option, it's possible to run the +given command in the context of that specific workspace. e.g: + +``` +npm run test --workspace=a +``` + +This will run the `test` script defined within the +`./packages/a/package.json` file. + +Please note that you can also specify this argument multiple times in the +command-line in order to target multiple workspaces, e.g: + +``` +npm run test --workspace=a --workspace=b +``` + +It's also possible to use the `workspaces` (plural) configuration option to +enable the same behavior but running that command in the context of **all** +configured workspaces. e.g: + +``` +npm run test --workspaces +``` + +Will run the `test` script in both `./packages/a` and `./packages/b`. + ### See also * [npm install](/commands/npm-install) * [npm publish](/commands/npm-publish) +* [npm run-script](/commands/npm-run-script) diff --git a/lib/base-command.js b/lib/base-command.js index b8497fd448780..a142b3336d10b 100644 --- a/lib/base-command.js +++ b/lib/base-command.js @@ -42,5 +42,12 @@ class BaseCommand { code: 'EUSAGE', }) } + + execWorkspaces (args, filters, cb) { + throw Object.assign( + new Error('This command does not support workspaces.'), + { code: 'ENOWORKSPACES' } + ) + } } module.exports = BaseCommand diff --git a/lib/cli.js b/lib/cli.js index 837d0876ceb4a..086335725c740 100644 --- a/lib/cli.js +++ b/lib/cli.js @@ -66,12 +66,14 @@ module.exports = (process) => { npm.log.level = 'silent' if (cmd) { const didYouMean = require('./utils/did-you-mean.js') - const suggestions = await didYouMean(npm, cmd) + console.error(npm.localPrefix) + const suggestions = await didYouMean(npm, npm.localPrefix, cmd) npm.output(suggestions) } else npm.output(npm.usage) process.exitCode = 1 } catch (err) { + console.error(err) errorHandler(err) } } diff --git a/lib/npm.js b/lib/npm.js index 78b6ba034d14f..bc65cc79b9717 100644 --- a/lib/npm.js +++ b/lib/npm.js @@ -100,9 +100,18 @@ const npm = module.exports = new class extends EventEmitter { }) } + const workspacesEnabled = this.config.get('workspaces') + const workspacesFilters = this.config.get('workspace') + const filterByWorkspaces = workspacesEnabled || workspacesFilters.length > 0 + if (this.config.get('usage')) { this.output(impl.usage) cb() + } else if (filterByWorkspaces) { + impl.execWorkspaces(args, this.config.get('workspace'), er => { + process.emit('timeEnd', `command:${cmd}`) + cb(er) + }) } else { impl.exec(args, er => { process.emit('timeEnd', `command:${cmd}`) diff --git a/lib/run-script.js b/lib/run-script.js index a7202548cf0a4..e59340225e2e4 100644 --- a/lib/run-script.js +++ b/lib/run-script.js @@ -1,8 +1,11 @@ +const { resolve } = require('path') +const chalk = require('chalk') const runScript = require('@npmcli/run-script') +const mapWorkspaces = require('@npmcli/map-workspaces') const { isServerPackage } = runScript -const readJson = require('read-package-json-fast') -const { resolve } = require('path') +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') @@ -17,6 +20,14 @@ const cmdList = [ 'version', ].reduce((l, p) => l.concat(['pre' + p, p, 'post' + p]), []) +const nocolor = { + reset: s => s, + bold: s => s, + dim: s => s, + blue: s => s, + green: s => s, +} + const BaseCommand = require('./base-command.js') class RunScript extends BaseCommand { /* istanbul ignore next - see test/lib/load-all-commands.js */ @@ -39,7 +50,7 @@ class RunScript extends BaseCommand { if (argv.length === 2) { // find the script name const json = resolve(this.npm.localPrefix, 'package.json') - const { scripts = {} } = await readJson(json).catch(er => ({})) + const { scripts = {} } = await rpj(json).catch(er => ({})) return Object.keys(scripts) } } @@ -51,14 +62,19 @@ class RunScript extends BaseCommand { this.list(args).then(() => cb()).catch(cb) } - async run (args) { - const path = this.npm.localPrefix - const event = args.shift() + execWorkspaces (args, filters, cb) { + if (args.length) + this.runWorkspaces(args, filters).then(() => cb()).catch(cb) + else + this.listWorkspaces(args, filters).then(() => cb()).catch(cb) + } + + async run ([event, ...args], { path = this.npm.localPrefix, pkg } = {}) { // this || undefined is because runScript will be unhappy with the default // null value const scriptShell = this.npm.config.get('script-shell') || undefined - const pkg = await readJson(`${path}/package.json`) + pkg = pkg || (await rpj(`${path}/package.json`)) const { scripts = {} } = pkg if (event === 'restart' && !scripts.restart) @@ -75,7 +91,7 @@ class RunScript extends BaseCommand { if (this.npm.config.get('if-present')) return - const suggestions = await didYouMean(this.npm, event) + const suggestions = await didYouMean(this.npm, path, event) throw new Error(suggestions) } @@ -108,9 +124,11 @@ class RunScript extends BaseCommand { } } - async list () { - const path = this.npm.localPrefix - const { scripts, name } = await readJson(`${path}/package.json`) + async list (args, path) { + path = path || this.npm.localPrefix + const { scripts, name, _id } = await rpj(`${path}/package.json`) + const pkgid = _id || name + const color = !!this.npm.color if (!scripts) return [] @@ -139,22 +157,122 @@ class RunScript extends BaseCommand { const list = cmdList.includes(script) ? cmds : runScripts list.push(script) } + const colorize = color ? chalk : nocolor - if (cmds.length) - this.npm.output(`Lifecycle scripts included in ${name}:`) + if (cmds.length) { + this.npm.output(`${ + colorize.reset(colorize.bold('Lifecycle scripts'))} included in ${ + colorize.green(pkgid)}:`) + } for (const script of cmds) - this.npm.output(prefix + script + indent + scripts[script]) + this.npm.output(prefix + script + indent + colorize.dim(scripts[script])) - if (!cmds.length && runScripts.length) - this.npm.output(`Scripts available in ${name} via \`npm run-script\`:`) - else if (runScripts.length) - this.npm.output('\navailable via `npm run-script`:') + if (!cmds.length && runScripts.length) { + this.npm.output(`${ + colorize.bold('Scripts') + } available in ${colorize.green(pkgid)} via \`${ + colorize.blue('npm run-script')}\`:`) + } else if (runScripts.length) + this.npm.output(`\navailable via \`${colorize.blue('npm run-script')}\`:`) for (const script of runScripts) - this.npm.output(prefix + script + indent + scripts[script]) + this.npm.output(prefix + script + indent + colorize.dim(scripts[script])) + this.npm.output('') return allScripts } + + 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 + } + + async runWorkspaces (args, filters) { + log.disableProgress() + + const res = [] + const workspaces = await this.workspaces(filters) + + for (const workspacePath of workspaces.values()) { + const pkg = await rpj(`${workspacePath}/package.json`) + const runResult = await this.run(args, { + path: workspacePath, + pkg, + }).catch(err => { + log.error(`Lifecycle script \`${args[0]}\` failed with error:`) + log.error(err) + log.error(` in workspace: ${pkg._id || pkg.name}`) + log.error(` at location: ${workspacePath}`) + + const scriptMissing = err.message.startsWith('Unknown command') + + // avoids exiting with error code in case there's scripts missing + // in some workspaces since other scripts might have succeeded + if (!scriptMissing) + process.exitCode = 1 + + return scriptMissing + }) + res.push(runResult) + } + + // in case **all** tests are missing, then it should exit with error code + if (res.every(Boolean)) + throw new Error(`Missing script: ${args[0]}`) + } + + async listWorkspaces (args, filters) { + const workspaces = await this.workspaces(filters) + + if (log.level === 'silent') + return + + if (this.npm.config.get('json')) { + const res = {} + for (const workspacePath of workspaces.values()) { + const { scripts, name } = await rpj(`${workspacePath}/package.json`) + res[name] = { ...scripts } + } + this.npm.output(JSON.stringify(res, null, 2)) + return + } + + if (this.npm.config.get('parseable')) { + for (const workspacePath of workspaces.values()) { + const { scripts, name } = await rpj(`${workspacePath}/package.json`) + for (const [script, cmd] of Object.entries(scripts || {})) + this.npm.output(`${name}:${script}:${cmd}`) + } + return + } + + for (const workspacePath of workspaces.values()) + await this.list(args, workspacePath) + } } + module.exports = RunScript diff --git a/lib/utils/config/definitions.js b/lib/utils/config/definitions.js index 1ad3a166bdf81..4dd1abe09ed37 100644 --- a/lib/utils/config/definitions.js +++ b/lib/utils/config/definitions.js @@ -2003,6 +2003,33 @@ define('viewer', { `, }) +define('workspace', { + default: [], + type: [String, Array], + short: 'w', + description: ` + Enable running a command in the context of the configured workspaces of the + current project while filtering by running only the workspaces defined by + this configuration option. + + 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 + nested workspaces) + `, +}) + +define('workspaces', { + default: false, + type: Boolean, + short: 'ws', + description: ` + Enable running a command in the context of **all** the configured + workspaces. + `, +}) + define('yes', { default: false, type: Boolean, diff --git a/lib/utils/did-you-mean.js b/lib/utils/did-you-mean.js index 3d8c6677cc002..5e41af67a65b2 100644 --- a/lib/utils/did-you-mean.js +++ b/lib/utils/did-you-mean.js @@ -2,23 +2,22 @@ const leven = require('leven') const readJson = require('read-package-json-fast') const { cmdList } = require('./cmd-list.js') -const didYouMean = async (npm, scmd) => { +const didYouMean = async (npm, path, scmd) => { const bestCmd = cmdList - .filter(cmd => leven(scmd, cmd) < scmd.length * 0.4) + .filter(cmd => leven(scmd, cmd) < scmd.length * 0.4 && scmd !== cmd) .map(str => ` npm ${str} # ${npm.commands[str].description}`) - const path = npm.localPrefix const pkg = await readJson(`${path}/package.json`) const { scripts } = pkg // We would already be suggesting this in `npm x` so omit them here const runScripts = ['stop', 'start', 'test', 'restart'] - const bestRun = Object.keys(scripts) + const bestRun = Object.keys(scripts || {}) .filter(cmd => leven(scmd, cmd) < scmd.length * 0.4 && !runScripts.includes(cmd)) .map(str => ` npm run ${str} # run the "${str}" package script`) const { bin } = pkg - const bestBin = Object.keys(bin) + const bestBin = Object.keys(bin || {}) .filter(cmd => leven(scmd, cmd) < scmd.length * 0.4) .map(str => ` npm exec ${str} # run the "${str}" command from either this or a remote npm package`) diff --git a/lib/utils/lifecycle-cmd.js b/lib/utils/lifecycle-cmd.js index 1917bef367855..2c5b89dfcdd04 100644 --- a/lib/utils/lifecycle-cmd.js +++ b/lib/utils/lifecycle-cmd.js @@ -10,5 +10,9 @@ class LifecycleCmd extends BaseCommand { exec (args, cb) { this.npm.commands['run-script']([this.constructor.name, ...args], cb) } + + execWorkspaces (args, filters, cb) { + this.npm.commands['run-script']([this.constructor.name, ...args], cb) + } } module.exports = LifecycleCmd diff --git a/tap-snapshots/test-lib-utils-config-definitions.js-TAP.test.js b/tap-snapshots/test-lib-utils-config-definitions.js-TAP.test.js index 3e82da69913c9..d6a72b99b24fc 100644 --- a/tap-snapshots/test-lib-utils-config-definitions.js-TAP.test.js +++ b/tap-snapshots/test-lib-utils-config-definitions.js-TAP.test.js @@ -144,6 +144,8 @@ Array [ "version", "versions", "viewer", + "workspace", + "workspaces", "yes", ] ` diff --git a/tap-snapshots/test-lib-utils-config-describe-all.js-TAP.test.js b/tap-snapshots/test-lib-utils-config-describe-all.js-TAP.test.js index ca758c701d174..909b31f83cc28 100644 --- a/tap-snapshots/test-lib-utils-config-describe-all.js-TAP.test.js +++ b/tap-snapshots/test-lib-utils-config-describe-all.js-TAP.test.js @@ -1192,6 +1192,27 @@ The program to use to view help content. Set to \`"browser"\` to view html help content in the default web browser. +#### \`workspace\` + +* Default: +* Type: String (can be set multiple times) + +Enable running a command in the context of the configured workspaces of the +current project while filtering by running only the workspaces defined by +this configuration option. + +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 nested workspaces) + +#### \`workspaces\` + +* Default: false +* Type: Boolean + +Enable running a command in the context of **all** the configured +workspaces. + #### \`yes\` * Default: false diff --git a/tap-snapshots/test-lib-utils-config-index.js-TAP.test.js b/tap-snapshots/test-lib-utils-config-index.js-TAP.test.js index 1044b3063c4bf..1e5ca232452e0 100644 --- a/tap-snapshots/test-lib-utils-config-index.js-TAP.test.js +++ b/tap-snapshots/test-lib-utils-config-index.js-TAP.test.js @@ -120,6 +120,12 @@ Object { "--loglevel", "verbose", ], + "w": Array [ + "--workspace", + ], + "ws": Array [ + "--workspaces", + ], "y": Array [ "--yes", ], diff --git a/test/lib/npm.js b/test/lib/npm.js index eb0f8ab27c267..de0dcaa1c198e 100644 --- a/test/lib/npm.js +++ b/test/lib/npm.js @@ -348,6 +348,84 @@ t.test('npm.load', t => { await new Promise((res) => setTimeout(res)) }) + t.test('workpaces-aware configs and commands', async t => { + const dir = t.testdir({ + packages: { + a: { + 'package.json': JSON.stringify({ + name: 'a', + version: '1.0.0', + scripts: { test: 'echo test a' }, + }), + }, + b: { + 'package.json': JSON.stringify({ + name: 'b', + version: '1.0.0', + scripts: { test: 'echo test b' }, + }), + }, + }, + 'package.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + workspaces: ['./packages/*'], + }), + '.npmrc': '', + }) + + const { log } = console + const consoleLogs = [] + console.log = (...msg) => consoleLogs.push(msg) + + const { execPath } = process + t.teardown(() => { + console.log = log + }) + + freshConfig({ + argv: [ + execPath, + process.argv[1], + '--userconfig', + resolve(dir, '.npmrc'), + '--color', + 'false', + '--workspaces', + 'true', + ], + }) + + await npm.load(er => { + if (er) + throw er + }) + + npm.localPrefix = dir + + await new Promise((res, rej) => { + npm.commands['run-script']([], er => { + if (er) + rej(er) + + t.match( + consoleLogs, + [ + ['Lifecycle scripts included in a@1.0.0:'], + [' test\n echo test a'], + [''], + ['Lifecycle scripts included in b@1.0.0:'], + [' test\n echo test b'], + [''], + ], + 'should exec workspaces version of commands' + ) + + res() + }) + }) + }) + t.end() }) diff --git a/test/lib/run-script.js b/test/lib/run-script.js index d2cac2f4206d8..db1fc4b5c560a 100644 --- a/test/lib/run-script.js +++ b/test/lib/run-script.js @@ -1,7 +1,15 @@ +const { resolve } = require('path') const t = require('tap') const requireInject = require('require-inject') const mockNpm = require('../fixtures/mock-npm') +const normalizePath = p => p + .replace(/\\+/g, '/') + .replace(/\r\n/g, '\n') + +const cleanOutput = (str) => normalizePath(str) + .replace(normalizePath(process.cwd()), '{CWD}') + const RUN_SCRIPTS = [] const flatOptions = { scriptShell: undefined, @@ -20,21 +28,33 @@ const npm = mockNpm({ help: { description: 'test help description', }, + test: { + description: 'test test description', + }, }, output: (...msg) => output.push(msg), }) const output = [] +const npmlog = { + disableProgress: () => null, + level: 'warn', + error: () => null, +} + t.afterEach(cb => { + npm.color = false + npmlog.level = 'warn' + npmlog.error = () => null output.length = 0 RUN_SCRIPTS.length = 0 + config['if-present'] = false config.json = false config.parseable = false cb() }) -const npmlog = { level: 'warn' } const getRS = windows => { const RunScript = requireInject('../../lib/run-script.js', { '@npmcli/run-script': Object.assign(async opts => { @@ -298,7 +318,7 @@ t.test('try to run missing script', t => { }) }) t.test('with --if-present', t => { - npm.config.set('if-present', true) + config['if-present'] = true runScript.exec(['goodbye'], er => { if (er) throw er @@ -461,13 +481,14 @@ t.test('list scripts', t => { if (er) throw er t.strictSame(output, [ - ['Lifecycle scripts included in x:'], + ['Lifecycle scripts included in x@1.2.3:'], [' test\n exit 2'], [' start\n node server.js'], [' stop\n node kill-server.js'], ['\navailable via `npm run-script`:'], [' preenv\n echo before the env'], [' postenv\n echo after the env'], + [''], ], 'basic report') t.end() }) @@ -540,8 +561,9 @@ t.test('list scripts, only commands', t => { if (er) throw er t.strictSame(output, [ - ['Lifecycle scripts included in x:'], + ['Lifecycle scripts included in x@1.2.3:'], [' preversion\n echo doing the version dance'], + [''], ]) t.end() }) @@ -560,9 +582,443 @@ t.test('list scripts, only non-commands', t => { if (er) throw er t.strictSame(output, [ - ['Scripts available in x via `npm run-script`:'], + ['Scripts available in x@1.2.3 via `npm run-script`:'], [' glorp\n echo doing the glerp glop'], + [''], ]) t.end() }) }) + +t.test('workspaces', t => { + npm.localPrefix = t.testdir({ + packages: { + a: { + 'package.json': JSON.stringify({ + name: 'a', + version: '1.0.0', + scripts: { glorp: 'echo a doing the glerp glop' }, + }), + }, + b: { + 'package.json': JSON.stringify({ + name: 'b', + version: '2.0.0', + scripts: { glorp: 'echo b doing the glerp glop' }, + }), + }, + c: { + 'package.json': JSON.stringify({ + name: 'c', + version: '1.0.0', + scripts: { + test: 'exit 0', + posttest: 'echo posttest', + lorem: 'echo c lorem', + }, + }), + }, + d: { + 'package.json': JSON.stringify({ + name: 'd', + version: '1.0.0', + scripts: { + test: 'exit 0', + posttest: 'echo posttest', + }, + }), + }, + e: { + 'package.json': JSON.stringify({ + name: 'e', + scripts: { test: 'exit 0', start: 'echo start something' }, + }), + }, + noscripts: { + 'package.json': JSON.stringify({ + name: 'noscripts', + version: '1.0.0', + }), + }, + }, + 'package.json': JSON.stringify({ + name: 'x', + version: '1.2.3', + workspaces: ['packages/*'], + }), + }) + + t.test('list all scripts', t => { + runScript.execWorkspaces([], [], er => { + if (er) + throw er + t.strictSame(output, [ + ['Scripts available in a@1.0.0 via `npm run-script`:'], + [' glorp\n echo a doing the glerp glop'], + [''], + ['Scripts available in b@2.0.0 via `npm run-script`:'], + [' glorp\n echo b doing the glerp glop'], + [''], + ['Lifecycle scripts included in c@1.0.0:'], + [' test\n exit 0'], + [' posttest\n echo posttest'], + ['\navailable via `npm run-script`:'], + [' lorem\n echo c lorem'], + [''], + ['Lifecycle scripts included in d@1.0.0:'], + [' test\n exit 0'], + [' posttest\n echo posttest'], + [''], + ['Lifecycle scripts included in e:'], + [' test\n exit 0'], + [' start\n echo start something'], + [''], + ]) + t.end() + }) + }) + + t.test('list regular scripts, filtered by name', t => { + runScript.execWorkspaces([], ['a', 'b'], er => { + if (er) + throw er + t.strictSame(output, [ + ['Scripts available in a@1.0.0 via `npm run-script`:'], + [' glorp\n echo a doing the glerp glop'], + [''], + ['Scripts available in b@2.0.0 via `npm run-script`:'], + [' glorp\n echo b doing the glerp glop'], + [''], + ]) + t.end() + }) + }) + + t.test('list regular scripts, filtered by path', t => { + runScript.execWorkspaces([], ['./packages/a'], er => { + if (er) + throw er + t.strictSame(output, [ + ['Scripts available in a@1.0.0 via `npm run-script`:'], + [' glorp\n echo a doing the glerp glop'], + [''], + ]) + t.end() + }) + }) + + t.test('list regular scripts, filtered by parent folder', t => { + runScript.execWorkspaces([], ['./packages'], er => { + if (er) + throw er + t.strictSame(output, [ + ['Scripts available in a@1.0.0 via `npm run-script`:'], + [' glorp\n echo a doing the glerp glop'], + [''], + ['Scripts available in b@2.0.0 via `npm run-script`:'], + [' glorp\n echo b doing the glerp glop'], + [''], + ['Lifecycle scripts included in c@1.0.0:'], + [' test\n exit 0'], + [' posttest\n echo posttest'], + ['\navailable via `npm run-script`:'], + [' lorem\n echo c lorem'], + [''], + ['Lifecycle scripts included in d@1.0.0:'], + [' test\n exit 0'], + [' posttest\n echo posttest'], + [''], + ['Lifecycle scripts included in e:'], + [' test\n exit 0'], + [' start\n echo start something'], + [''], + ]) + t.end() + }) + }) + + t.test('list all scripts with colors', t => { + npm.color = true + runScript.execWorkspaces([], [], er => { + if (er) + throw er + t.strictSame(output, [ + [ + '\u001b[1mScripts\u001b[22m available in \x1B[32ma@1.0.0\x1B[39m via `\x1B[34mnpm run-script\x1B[39m`:', + ], + [' glorp\n \x1B[2mecho a doing the glerp glop\x1B[22m'], + [''], + [ + '\u001b[1mScripts\u001b[22m available in \x1B[32mb@2.0.0\x1B[39m via `\x1B[34mnpm run-script\x1B[39m`:', + ], + [' glorp\n \x1B[2mecho b doing the glerp glop\x1B[22m'], + [''], + [ + '\x1B[0m\x1B[1mLifecycle scripts\x1B[22m\x1B[0m included in \x1B[32mc@1.0.0\x1B[39m:', + ], + [' test\n \x1B[2mexit 0\x1B[22m'], + [' posttest\n \x1B[2mecho posttest\x1B[22m'], + ['\navailable via `\x1B[34mnpm run-script\x1B[39m`:'], + [' lorem\n \x1B[2mecho c lorem\x1B[22m'], + [''], + [ + '\x1B[0m\x1B[1mLifecycle scripts\x1B[22m\x1B[0m included in \x1B[32md@1.0.0\x1B[39m:', + ], + [' test\n \x1B[2mexit 0\x1B[22m'], + [' posttest\n \x1B[2mecho posttest\x1B[22m'], + [''], + [ + '\x1B[0m\x1B[1mLifecycle scripts\x1B[22m\x1B[0m included in \x1B[32me\x1B[39m:', + ], + [' test\n \x1B[2mexit 0\x1B[22m'], + [' start\n \x1B[2mecho start something\x1B[22m'], + [''], + ]) + t.end() + }) + }) + + t.test('list all scripts --json', t => { + config.json = true + runScript.execWorkspaces([], [], er => { + if (er) + throw er + t.strictSame(output, [ + [ + '{\n' + + ' "a": {\n' + + ' "glorp": "echo a doing the glerp glop"\n' + + ' },\n' + + ' "b": {\n' + + ' "glorp": "echo b doing the glerp glop"\n' + + ' },\n' + + ' "c": {\n' + + ' "test": "exit 0",\n' + + ' "posttest": "echo posttest",\n' + + ' "lorem": "echo c lorem"\n' + + ' },\n' + + ' "d": {\n' + + ' "test": "exit 0",\n' + + ' "posttest": "echo posttest"\n' + + ' },\n' + + ' "e": {\n' + + ' "test": "exit 0",\n' + + ' "start": "echo start something"\n' + + ' },\n' + + ' "noscripts": {}\n' + + '}', + ], + ]) + t.end() + }) + }) + + t.test('list all scripts --parseable', t => { + config.parseable = true + runScript.execWorkspaces([], [], er => { + if (er) + throw er + t.strictSame(output, [ + ['a:glorp:echo a doing the glerp glop'], + ['b:glorp:echo b doing the glerp glop'], + ['c:test:exit 0'], + ['c:posttest:echo posttest'], + ['c:lorem:echo c lorem'], + ['d:test:exit 0'], + ['d:posttest:echo posttest'], + ['e:test:exit 0'], + ['e:start:echo start something'], + ]) + t.end() + }) + }) + + t.test('list no scripts --loglevel=silent', t => { + npmlog.level = 'silent' + runScript.execWorkspaces([], [], er => { + if (er) + throw er + t.strictSame(output, []) + t.end() + }) + }) + + t.test('run scripts across all workspaces', t => { + runScript.execWorkspaces(['test'], [], er => { + if (er) + throw er + + t.match(RUN_SCRIPTS, [ + { + path: resolve(npm.localPrefix, 'packages/c'), + pkg: { name: 'c', version: '1.0.0' }, + event: 'test', + }, + { + path: resolve(npm.localPrefix, 'packages/c'), + pkg: { name: 'c', version: '1.0.0' }, + event: 'posttest', + }, + { + path: resolve(npm.localPrefix, 'packages/d'), + pkg: { name: 'd', version: '1.0.0' }, + event: 'test', + }, + { + path: resolve(npm.localPrefix, 'packages/d'), + pkg: { name: 'd', version: '1.0.0' }, + event: 'posttest', + }, + { + path: resolve(npm.localPrefix, 'packages/e'), + pkg: { name: 'e' }, + event: 'test', + }, + ]) + t.end() + }) + }) + + t.test('missing scripts in all workspaces', t => { + const LOG = [] + npmlog.error = (err) => { + LOG.push(String(err)) + } + runScript.execWorkspaces(['missing-script'], [], er => { + t.match( + er, + /Missing script: missing-script/, + 'should throw missing script error' + ) + + process.exitCode = 0 // clean exit code + + t.match(RUN_SCRIPTS, []) + t.strictSame(LOG.map(cleanOutput), [ + 'Lifecycle script `missing-script` failed with error:', + 'Error: Unknown command: "missing-script"', + ' in workspace: a@1.0.0', + ' at location: {CWD}/test/lib/run-script-workspaces/packages/a', + 'Lifecycle script `missing-script` failed with error:', + 'Error: Unknown command: "missing-script"', + ' in workspace: b@2.0.0', + ' at location: {CWD}/test/lib/run-script-workspaces/packages/b', + 'Lifecycle script `missing-script` failed with error:', + 'Error: Unknown command: "missing-script"', + ' in workspace: c@1.0.0', + ' at location: {CWD}/test/lib/run-script-workspaces/packages/c', + 'Lifecycle script `missing-script` failed with error:', + 'Error: Unknown command: "missing-script"', + ' in workspace: d@1.0.0', + ' at location: {CWD}/test/lib/run-script-workspaces/packages/d', + 'Lifecycle script `missing-script` failed with error:', + 'Error: Unknown command: "missing-script"', + ' in workspace: e', + ' at location: {CWD}/test/lib/run-script-workspaces/packages/e', + 'Lifecycle script `missing-script` failed with error:', + 'Error: Unknown command: "missing-script"', + ' in workspace: noscripts@1.0.0', + ' at location: {CWD}/test/lib/run-script-workspaces/packages/noscripts', + ], 'should log error msgs for each workspace script') + + t.end() + }) + }) + + t.test('missing scripts in some workspaces', t => { + const LOG = [] + npmlog.error = (err) => { + LOG.push(String(err)) + } + runScript.execWorkspaces(['test'], ['a', 'b', 'c', 'd'], er => { + if (er) + throw er + + t.match(RUN_SCRIPTS, []) + t.strictSame(LOG.map(cleanOutput), [ + 'Lifecycle script `test` failed with error:', + 'Error: Unknown command: "test"', + ' in workspace: a@1.0.0', + ' at location: {CWD}/test/lib/run-script-workspaces/packages/a', + 'Lifecycle script `test` failed with error:', + 'Error: Unknown command: "test"', + ' in workspace: b@2.0.0', + ' at location: {CWD}/test/lib/run-script-workspaces/packages/b', + ], 'should log error msgs for each workspace script') + t.end() + }) + }) + + t.test('no workspaces when filtering by user args', t => { + runScript.execWorkspaces([], ['foo', 'bar'], er => { + t.equal( + er.message, + 'No workspaces found:\n --workspace=foo --workspace=bar', + 'should throw error msg' + ) + t.end() + }) + }) + + t.test('no workspaces', t => { + const _prevPrefix = npm.localPrefix + npm.localPrefix = t.testdir({ + 'package.json': JSON.stringify({ + name: 'foo', + version: '1.0.0', + }), + }) + + runScript.execWorkspaces([], [], er => { + t.match(er, /No workspaces found!/, 'should throw error msg') + npm.localPrefix = _prevPrefix + t.end() + }) + }) + + t.test('single failed workspace run', t => { + const RunScript = requireInject('../../lib/run-script.js', { + '@npmcli/run-script': () => { + throw new Error('err') + }, + npmlog, + '../../lib/utils/is-windows-shell.js': false, + }) + const runScript = new RunScript(npm) + + runScript.execWorkspaces(['test'], ['c'], er => { + t.ok('should complete running all targets') + process.exitCode = 0 // clean up exit code + t.end() + }) + }) + + t.test('failed workspace run with succeeded runs', t => { + const RunScript = requireInject('../../lib/run-script.js', { + '@npmcli/run-script': async opts => { + if (opts.pkg.name === 'a') + throw new Error('ERR') + + RUN_SCRIPTS.push(opts) + }, + npmlog, + '../../lib/utils/is-windows-shell.js': false, + }) + const runScript = new RunScript(npm) + + runScript.execWorkspaces(['glorp'], ['a', 'b'], er => { + t.match(RUN_SCRIPTS, [ + { + path: resolve(npm.localPrefix, 'packages/b'), + pkg: { name: 'b', version: '2.0.0' }, + event: 'glorp', + }, + ]) + + process.exitCode = 0 // clean up exit code + t.end() + }) + }) + + t.end() +}) diff --git a/test/lib/utils/did-you-mean.js b/test/lib/utils/did-you-mean.js index 48b6d4027cdba..898806aa1c26b 100644 --- a/test/lib/utils/did-you-mean.js +++ b/test/lib/utils/did-you-mean.js @@ -7,25 +7,37 @@ t.test('did-you-mean', t => { npm.load(err => { t.notOk(err) t.test('nistall', async t => { - const result = await dym(npm, 'nistall') + const result = await dym(npm, npm.localPrefix, 'nistall') t.match(result, 'Unknown command') t.match(result, 'npm install') }) t.test('sttest', async t => { - const result = await dym(npm, 'sttest') + const result = await dym(npm, npm.localPrefix, 'sttest') t.match(result, 'Unknown command') t.match(result, 'npm test') t.match(result, 'npm run posttest') }) t.test('npz', async t => { - const result = await dym(npm, 'npxx') + const result = await dym(npm, npm.localPrefix, 'npxx') t.match(result, 'Unknown command') t.match(result, 'npm exec npx') }) t.test('qwuijbo', async t => { - const result = await dym(npm, 'qwuijbo') + const result = await dym(npm, npm.localPrefix, 'qwuijbo') t.match(result, 'Unknown command') }) t.end() }) }) + +t.test('missing bin and script properties', async t => { + const path = t.testdir({ + 'package.json': JSON.stringify({ + name: 'missing-bin', + }), + }) + + const result = await dym(npm, path, 'nistall') + t.match(result, 'Unknown command') + t.match(result, 'npm install') +}) diff --git a/test/lib/utils/lifecycle-cmd.js b/test/lib/utils/lifecycle-cmd.js index 3e3a7da43443e..862c87a8e032c 100644 --- a/test/lib/utils/lifecycle-cmd.js +++ b/test/lib/utils/lifecycle-cmd.js @@ -10,6 +10,7 @@ const npm = { }, } t.test('create a lifecycle command', t => { + t.plan(5) class TestStage extends LifecycleCmd { static get name () { return 'test-stage' @@ -20,6 +21,9 @@ t.test('create a lifecycle command', t => { cmd.exec(['some', 'args'], (er, result) => { t.same(runArgs, ['test-stage', 'some', 'args']) t.strictSame(result, 'called npm.commands.run') - t.end() + }) + cmd.execWorkspaces(['some', 'args'], [], (er, result) => { + t.same(runArgs, ['test-stage', 'some', 'args']) + t.strictSame(result, 'called npm.commands.run') }) })