Skip to content

Commit

Permalink
npx: add install prompt, handle options correctly
Browse files Browse the repository at this point in the history
- handle previous npx options that are still possible to be handled, and
  print a warning if any deprecated/removed options are used.
- expand shorthands properly in npx command line.
- take existing npm options into account when determining placement of
  the -- argument.
- document changes from previous versions of npx.

PR-URL: #1596
Credit: @isaacs
Close: #1596
Reviewed-by: @ruyadorno
  • Loading branch information
isaacs committed Aug 4, 2020
1 parent 87d27d3 commit 3aba8d6
Showing 10 changed files with 470 additions and 20 deletions.
106 changes: 102 additions & 4 deletions bin/npx-cli.js
Original file line number Diff line number Diff line change
@@ -6,25 +6,123 @@ const cli = require('../lib/cli.js')
process.argv[1] = require.resolve('./npm-cli.js')
process.argv.splice(2, 0, 'exec')

// TODO: remove the affordances for removed items in npm v9
const removedSwitches = new Set([
'always-spawn',
'ignore-existing',
'shell-auto-fallback'
])

const removedOpts = new Set([
'npm',
'node-arg',
'n'
])

const removed = new Set([
...removedSwitches,
...removedOpts
])

const { types, shorthands } = require('../lib/config/defaults.js')
const npmSwitches = Object.entries(types)
.filter(([key, type]) => type === Boolean ||
(Array.isArray(type) && type.includes(Boolean)))
.map(([key, type]) => key)

// things that don't take a value
const switches = new Set([
...removedSwitches,
...npmSwitches,
'no-install',
'quiet',
'q',
'version',
'v',
'help',
'h'
])

// things that do take a value
const opts = new Set([
...removedOpts,
'package',
'p',
'cache',
'userconfig',
'call',
'c',
'shell',
'npm',
'node-arg',
'n'
])

// break out of loop when we find a positional argument or --
// If we find a positional arg, we shove -- in front of it, and
// let the normal npm cli handle the rest.
let i
let sawRemovedFlags = false
for (i = 3; i < process.argv.length; i++) {
const arg = process.argv[i]
if (arg === '--') {
break
} else if (/^-/.test(arg)) {
// `--package foo` treated the same as `--package=foo`
if (!arg.includes('=')) {
i++
const [key, ...v] = arg.replace(/^-+/, '').split('=')

switch (key) {
case 'p':
process.argv[i] = ['--package', ...v].join('=')
break

case 'shell':
process.argv[i] = ['--script-shell', ...v].join('=')
break

case 'no-install':
process.argv[i] = '--yes=false'
break

default:
// resolve shorthands and run again
if (shorthands[key] && !removed.has(key)) {
const a = [...shorthands[key]]
if (v.length) {
a.push(v.join('='))
}
process.argv.splice(i, 1, ...a)
i--
continue
}
break
}

if (removed.has(key)) {
console.error(`npx: the --${key} argument has been removed.`)
sawRemovedFlags = true
process.argv.splice(i, 1)
i--
}

if (v.length === 0 && !switches.has(key) &&
(opts.has(key) || !/^-/.test(process.argv[i + 1]))) {
// value will be next argument, skip over it.
if (removed.has(key)) {
// also remove the value for the cut key.
process.argv.splice(i + 1, 1)
} else {
i++
}
}
continue
} else {
// found a positional arg, put -- in front of it, and we're done
process.argv.splice(i, 0, '--')
break
}
}

if (sawRemovedFlags) {
console.error('See `npm help exec` for more information')
}

cli(process)
33 changes: 32 additions & 1 deletion docs/content/cli-commands/npm-exec.md
Original file line number Diff line number Diff line change
@@ -41,7 +41,10 @@ where all specified packages are available.

If any requested packages are not present in the local project
dependencies, then they are installed to a folder in the npm cache, which
is added to the `PATH` environment variable in the executed process.
is added to the `PATH` environment variable in the executed process. A
prompt is printed (which can be suppressed by providing either `--yes` or
`--no`).

Package names provided without a specifier will be matched with whatever
version exists in the local project. Package names with a specifier will
only be considered a match if they have the exact same name and version as
@@ -137,6 +140,34 @@ $ npm x -c 'eslint && say "hooray, lint passed"'
$ npx -c 'eslint && say "hooray, lint passed"'
```

### Compatibility with Older npx Versions

The `npx` binary was rewritten in npm v7.0.0, and the standalone `npx`
package deprecated at that time. `npx` uses the `npm exec`
command instead of a separate argument parser and install process, with
some affordances to maintain backwards compatibility with the arguments it
accepted in previous versions.

This resulted in some shifts in its functionality:

- Any `npm` config value may be provided.
- To prevent security and user-experience problems from mistyping package
names, `npx` prompts before installing anything. Suppress this
prompt with the `-y` or `--yes` option.
- The `--no-install` option is deprecated, and will be converted to `--no`.
- Shell fallback functionality is removed, as it is not advisable.
- The `-p` argument is a shorthand for `--parseable` in npm, but shorthand
for `--package` in npx. This is maintained, but only for the `npx`
executable.
- The `--ignore-existing` option is removed. Locally installed bins are
always present in the executed process `PATH`.
- The `--npm` option is removed. `npx` will always use the `npm` it ships
with.
- The `--node-arg` and `-n` options are removed.
- The `--always-spawn` option is redundant, and thus removed.
- The `--shell` option is replaced with `--script-shell`, but maintained
in the `npx` executable for backwards compatibility.

### See Also

* [npm run-script](/cli-commands/run-script)
33 changes: 32 additions & 1 deletion docs/content/cli-commands/npx.md
Original file line number Diff line number Diff line change
@@ -41,7 +41,10 @@ where all specified packages are available.

If any requested packages are not present in the local project
dependencies, then they are installed to a folder in the npm cache, which
is added to the `PATH` environment variable in the executed process.
is added to the `PATH` environment variable in the executed process. A
prompt is printed (which can be suppressed by providing either `--yes` or
`--no`).

Package names provided without a specifier will be matched with whatever
version exists in the local project. Package names with a specifier will
only be considered a match if they have the exact same name and version as
@@ -137,6 +140,34 @@ $ npm x -c 'eslint && say "hooray, lint passed"'
$ npx -c 'eslint && say "hooray, lint passed"'
```

### Compatibility with Older npx Versions

The `npx` binary was rewritten in npm v7.0.0, and the standalone `npx`
package deprecated at that time. `npx` uses the `npm exec`
command instead of a separate argument parser and install process, with
some affordances to maintain backwards compatibility with the arguments it
accepted in previous versions.

This resulted in some shifts in its functionality:

- Any `npm` config value may be provided.
- To prevent security and user-experience problems from mistyping package
names, `npx` prompts before installing anything. Suppress this
prompt with the `-y` or `--yes` option.
- The `--no-install` option is deprecated, and will be converted to `--no`.
- Shell fallback functionality is removed, as it is not advisable.
- The `-p` argument is a shorthand for `--parseable` in npm, but shorthand
for `--package` in npx. This is maintained, but only for the `npx`
executable.
- The `--ignore-existing` option is removed. Locally installed bins are
always present in the executed process `PATH`.
- The `--npm` option is removed. `npx` will always use the `npm` it ships
with.
- The `--node-arg` and `-n` options are removed.
- The `--always-spawn` option is redundant, and thus removed.
- The `--shell` option is replaced with `--script-shell`, but maintained
in the `npx` executable for backwards compatibility.

### See Also

* [npm run-script](/cli-commands/run-script)
6 changes: 3 additions & 3 deletions lib/config/defaults.js
Original file line number Diff line number Diff line change
@@ -311,7 +311,7 @@ exports.types = {
'ham-it-up': Boolean,
heading: String,
'if-present': Boolean,
include: [Array, 'dev', 'optional', 'peer'],
include: [Array, 'prod', 'dev', 'optional', 'peer'],
'include-staged': Boolean,
'ignore-prepublish': Boolean,
'ignore-scripts': Boolean,
@@ -365,7 +365,7 @@ exports.types = {
'save-prod': Boolean,
scope: String,
'script-shell': [null, String],
'scripts-prepend-node-path': [false, true, 'auto', 'warn-only'],
'scripts-prepend-node-path': [Boolean, 'auto', 'warn-only'],
searchopts: String,
searchexclude: [null, String],
searchlimit: Number,
@@ -412,7 +412,7 @@ function getLocalAddresses () {
}

exports.shorthands = {
before: ['--enjoy-by'],
'enjoy-by': ['--before'],
c: ['--call'],
s: ['--loglevel', 'silent'],
d: ['--loglevel', 'info'],
3 changes: 3 additions & 0 deletions lib/config/flat-options.js
Original file line number Diff line number Diff line change
@@ -198,6 +198,9 @@ const flatOptions = npm => npm.flatOptions || Object.freeze({
},
userAgent: npm.config.get('user-agent'),

// yes, it's fine, just do it, jeez, stop asking
yes: npm.config.get('yes'),

...getScopesAndAuths(npm),

// npm fund exclusive option to select an item from a funding list
26 changes: 23 additions & 3 deletions lib/exec.js
Original file line number Diff line number Diff line change
@@ -6,21 +6,25 @@ const usage = usageUtil('exec',
'Run a command from a local or remote npm package.\n\n' +

'npm exec -- <pkg>[@<version>] [args...]\n' +
'npm exec -p <pkg>[@<version>] -- <cmd> [args...]\n' +
'npm exec --package=<pkg>[@<version>] -- <cmd> [args...]\n' +
'npm exec -c \'<cmd> [args...]\'\n' +
'npm exec -p foo -c \'<cmd> [args...]\'\n' +
'npm exec --package=foo -c \'<cmd> [args...]\'\n' +
'\n' +
'npx <pkg>[@<specifier>] [args...]\n' +
'npx -p <pkg>[@<specifier>] <cmd> [args...]\n' +
'npx -c \'<cmd> [args...]\'\n' +
'npx -p <pkg>[@<specifier>] -c \'<cmd> [args...]\'',

'\n-p <pkg> --package=<pkg> (may be specified multiple times)\n' +
'\n--package=<pkg> (may be specified multiple times)\n' +
'-p is a shorthand for --package only when using npx executable\n' +
'-c <cmd> --call=<cmd> (may not be mixed with positional arguments)'
)

const completion = require('./utils/completion/installed-shallow.js')

const { promisify } = require('util')
const read = promisify(require('read'))

// it's like this:
//
// npm x pkg@version <-- runs the bin named "pkg" or the only bin if only 1
@@ -118,9 +122,25 @@ const exec = async args => {
// add installDir/node_modules/.bin to pathArr
const add = manis.filter(mani => manifestMissing(tree, mani))
.map(mani => mani._from)
.sort((a, b) => a.localeCompare(b))

// no need to install if already present
if (add.length) {
if (!npm.flatOptions.yes) {
// set -n to always say no
if (npm.flatOptions.yes === false) {
throw 'canceled'
}
const addList = add.map(a => ` ${a.replace(/@$/, '')}`)
.join('\n') + '\n'
const prompt = `Need to install the following packages:\n${
addList
}Ok to proceed? `
const confirm = await read({ prompt, default: 'y' })
if (confirm.trim().toLowerCase().charAt(0) !== 'y') {
throw 'canceled'
}
}
await arb.reify({ add })
}
pathArr.unshift(resolve(installDir, 'node_modules/.bin'))
1 change: 1 addition & 0 deletions tap-snapshots/test-lib-config-flat-options.js-TAP.test.js
Original file line number Diff line number Diff line change
@@ -120,5 +120,6 @@ Object {
"userAgent": "user-agent",
"viewer": "viewer",
"which": undefined,
"yes": undefined,
}
`
Loading

0 comments on commit 3aba8d6

Please sign in to comment.