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.
  • Loading branch information
isaacs committed Jul 31, 2020
1 parent 5473bbd commit ecfa0af
Show file tree
Hide file tree
Showing 11 changed files with 464 additions and 21 deletions.
100 changes: 96 additions & 4 deletions bin/npx-cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,58 @@ 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-feedback'
])

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.
Expand All @@ -15,11 +67,51 @@ for (i = 3; i < process.argv.length; 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.`)
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, '--')
Expand Down
33 changes: 32 additions & 1 deletion docs/content/cli-commands/npm-exec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
33 changes: 32 additions & 1 deletion docs/content/cli-commands/npx.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions lib/config/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -412,7 +412,7 @@ function getLocalAddresses () {
}

exports.shorthands = {
before: ['--enjoy-by'],
'enjoy-by': ['--before'],
c: ['--call'],
s: ['--loglevel', 'silent'],
d: ['--loglevel', 'info'],
Expand Down
3 changes: 3 additions & 0 deletions lib/config/flat-options.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 23 additions & 3 deletions lib/exec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'))
Expand Down
2 changes: 1 addition & 1 deletion lib/repo.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const getRepo = async pkg => {
}

const info = hostedFromMani(mani)
const url = info ? info.browse() : unknownHostedUrl(rurl)
const url = info ? info.browse(mani.repository.directory) : unknownHostedUrl(rurl)

if (!url) {
throw Object.assign(new Error('no repository: could not get url'), {
Expand Down
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
Expand Up @@ -120,5 +120,6 @@ Object {
"userAgent": "user-agent",
"viewer": "viewer",
"which": undefined,
"yes": undefined,
}
`
67 changes: 64 additions & 3 deletions test/bin/npx-cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ const npx = require.resolve('../../bin/npx-cli.js')
const cli = require.resolve('../../lib/cli.js')
const npm = require.resolve('../../bin/npm-cli.js')

const logs = []
console.error = (...msg) => logs.push(msg)

t.afterEach(cb => {
logs.length = 0
cb()
})

t.test('npx foo -> npm exec -- foo', t => {
process.argv = ['node', npx, 'foo']
requireInject(npx, { [cli]: () => {} })
Expand All @@ -25,9 +33,62 @@ t.test('npx -x y foo -z -> npm exec -x y -- foo -z', t => {
t.end()
})

t.test('npx --x=y foo -z -> npm exec --x=y -- foo -z', t => {
process.argv = ['node', npx, '--x=y', 'foo', '-z']
t.test('npx --x=y --no-install foo -z -> npm exec --x=y -- foo -z', t => {
process.argv = ['node', npx, '--x=y', '--no-install', 'foo', '-z']
requireInject(npx, { [cli]: () => {} })
t.strictSame(process.argv, ['node', npm, 'exec', '--x=y', '--yes=false', '--', 'foo', '-z'])
t.end()
})

t.test('transform renamed options into proper values', t => {
process.argv = ['node', npx, '-y', '--shell=bash', '-p', 'foo', '-c', 'asdf']
requireInject(npx, { [cli]: () => {} })
t.strictSame(process.argv, ['node', npm, 'exec', '--yes', '--script-shell=bash', '--package', 'foo', '--call', 'asdf'])
t.end()
})

// warn if deprecated switches/options are used
t.test('use a bunch of deprecated switches and options', t => {
process.argv = [
'node',
npx,
'--npm',
'/some/npm/bin',
'--node-arg=--harmony',
'-n',
'--require=foobar',
'--reg=http://localhost:12345/',
'-p',
'foo',
'--always-spawn',
'--shell-auto-feedback',
'--ignore-existing',
'-q',
'foobar'
]

const expect = [
'node',
npm,
'exec',
'--registry',
'http://localhost:12345/',
'--package',
'foo',
'--loglevel',
'warn',
'--',
'foobar'
]
requireInject(npx, { [cli]: () => {} })
t.strictSame(process.argv, ['node', npm, 'exec', '--x=y', '--', 'foo', '-z'])
t.strictSame(process.argv, expect)
t.strictSame(logs, [
[ 'npx: the --npm argument has been removed.' ],
[ 'npx: the --node-arg argument has been removed.' ],
[ 'npx: the --n argument has been removed.' ],
[ 'npx: the --always-spawn argument has been removed.' ],
[ 'npx: the --shell-auto-feedback argument has been removed.' ],
[ 'npx: the --ignore-existing argument has been removed.' ]
])
t.end()
})
Loading

0 comments on commit ecfa0af

Please sign in to comment.