Repository: fgnass/node-dev (js)

Issue:
Argument parsing is still broken in 6.7.0 (-r works but --require doesn't)
Probably a continuation of #236.

In 5.2.0:

```
node-dev --no-notify --require source-map-support/register lib/server/index.js
// Correct source-mapped line numbers
```

In 6.7.0:

```
node-dev --no-notify --require source-map-support/register lib/server/index.js
// Wrong line numbers
node-dev --no-notify -r source-map-support/register lib/server/index.js
// Correct source-mapped line numbers
```

Pull request:
Improve command line argument parsing
Fixes #258

`node` has particular CLI usage patterns that are not supported by `minimist`. specifically, `--inspect` which has either no option or must include `=`.

I've added specific support for `inspect` & `r` / `require`. Unknown argument should be passed on to node.

We now throw if we cannot identify the `script` portion of the command line arguments. In the future we can instead list the command line arguments when this happens.

The way this now works is we manually handle the `inspect` options and remove them before passing the args off to `minimist`. We parse once with `minimist` to determine the location of the `script` and then parse again with the default values that we find in that script's directory. 

I'll release this with a major version bump as I'm not 100% certain I covered all the use cases out there.


Patch:
diff --git a/lib/cli.js b/lib/cli.js
index f7ac750..4ff9d15 100644
--- a/lib/cli.js
+++ b/lib/cli.js
@@ -1,84 +1,78 @@
+const assert = require('assert');
 const minimist = require('minimist');
 const { resolve } = require('path');
 
-const { defaultConfig, getConfig } = require('./cfg');
+const { getConfig } = require('./cfg');
 
-const configKeys = Object.keys(defaultConfig);
+const arrayify = v => (Array.isArray(v) ? [...v] : [v]);
+const argify = key => ({ arg: `--${key}`, key });
 
-function resolvePath(unresolvedPath) {
-  return resolve(process.cwd(), unresolvedPath);
-}
+const resolvePath = p => resolve(process.cwd(), p);
 
-const doubleDash = s => /^--/.test(s);
-const dash = s => /^-[^-]*$/.test(s);
+const nodeAlias = { require: 'r' };
+const nodeBoolean = ['expose_gc'];
+const nodeOptional = ['inspect', 'inspect-brk'];
+const nodeString = ['require'];
 
-function getFirstNonOptionArgIndex(args) {
-  for (let i = 2; i < args.length; i += 1) {
-    if (!doubleDash(args[i]) && !dash(args[i]) && !dash(args[i - 1] || '')) return i;
-  }
+const nodeDevBoolean = ['clear', 'dedupe', 'fork', 'notify', 'poll', 'respawn', 'vm'];
+const nodeDevNumber = ['debounce', 'deps', 'interval'];
+const nodeDevString = ['graceful_ipc', 'ignore', 'timestamp'];
 
-  return args.length - 1;
-}
+const alias = Object.assign({}, nodeAlias);
+const boolean = [...nodeBoolean, ...nodeDevBoolean];
+const string = [...nodeString, ...nodeDevString];
 
-function unique(k) {
-  const seen = [];
-  return o => {
-    if (!seen.includes(o[k])) {
-      seen.push(o[k]);
-      return true;
-    }
-    return false;
-  };
-}
+const nodeArgsReducer = opts => (out, { arg, key }) => {
+  const value = opts[key];
 
-module.exports = argv => {
-  const unknownArgs = [];
+  if (typeof value === 'boolean') {
+    value && out.push(arg);
+  } else if (typeof value !== 'undefined') {
+    arrayify(value).forEach(v => {
+      if (arg.includes('=')) {
+        out.push(`${arg.split('=')[0]}=${v}`);
+      } else {
+        out.push(`${arg}=${v}`);
+      }
+    });
+  }
+
+  delete opts[key];
+
+  return out;
+};
 
-  const scriptIndex = getFirstNonOptionArgIndex(argv);
+const nodeOptionalFactory = args => arg => {
+  const isNodeOptional = nodeOptional.includes(arg.substring(2));
+  if (isNodeOptional) args.push(arg);
+  return !isNodeOptional;
+};
 
-  const script = argv[scriptIndex];
-  const scriptArgs = argv.slice(scriptIndex + 1);
-  const devArgs = argv.slice(2, scriptIndex);
+const unknownFactory = args => arg => {
+  const [, key] = Object.keys(minimist([arg]));
+  key && !nodeDevNumber.includes(key) && args.push({ arg, key });
+};
 
-  const opts = minimist(devArgs, {
-    boolean: ['clear', 'dedupe', 'fork', 'notify', 'poll', 'respawn', 'vm'],
-    string: ['graceful_ipc', 'ignore', 'timestamp'],
-    default: getConfig(script),
-    unknown: arg => {
-      const key = Object.keys(minimist([arg]))[1];
+module.exports = argv => {
+  const nodeOptionalArgs = [];
+  const args = argv.slice(2).filter(nodeOptionalFactory(nodeOptionalArgs));
 
-      if (!configKeys.includes(key)) {
-        unknownArgs.push({ arg, key });
-      }
-    }
-  });
+  const unknownArgs = [];
+  const unknown = unknownFactory(unknownArgs);
 
-  const nodeArgs = unknownArgs.filter(unique('key')).reduce((out, { arg, key }) => {
-    const value = opts[key];
+  const {
+    _: [script, ...scriptArgs]
+  } = minimist(args, { alias, boolean, string, unknown });
 
-    if (typeof value !== 'boolean' && !arg.includes('=')) {
-      if (Array.isArray(value)) {
-        value.forEach(v => out.push(arg, v));
-      } else {
-        out.push(arg, value);
-      }
-    } else {
-      out.push(arg);
-    }
+  assert(script, 'Could not parse command line arguments');
 
-    return out;
-  }, []);
+  const opts = minimist(args, { alias, boolean, default: getConfig(script) });
 
-  unknownArgs.forEach(({ key }) => {
-    delete opts[key];
-  });
+  const nodeArgs = [...nodeBoolean.map(argify), ...nodeString.map(argify), ...unknownArgs]
+    .sort((a, b) => a.key - b.key)
+    .reduce(nodeArgsReducer(opts), [...nodeOptionalArgs]);
 
-  opts.ignore = [...(Array.isArray(opts.ignore) ? opts.ignore : [opts.ignore])].map(resolvePath);
+  opts.ignore = arrayify(opts.ignore).map(resolvePath);
 
-  return {
-    script,
-    scriptArgs,
-    nodeArgs,
-    opts
-  };
+  return { nodeArgs, opts, script, scriptArgs };
 };
diff --git a/package.json b/package.json
index 39fa7a8..70e363f 100644
--- a/package.json
+++ b/package.json
@@ -35,14 +35,14 @@
     "dateformat": "^3.0.3",
     "dynamic-dedupe": "^0.3.0",
     "filewatcher": "~3.0.0",
-    "minimist": "^1.1.3",
+    "minimist": "^1.2.5",
     "node-notifier": "^8.0.1",
     "resolve": "^1.0.0",
     "semver": "^7.3.5"
   },
   "devDependencies": {
     "@types/node": "^14.14.37",
-    "eslint": "^7.23.0",
+    "eslint": "^7.25.0",
     "eslint-plugin-import": "^2.22.1",
     "husky": "^6.0.0",
     "lint-staged": "^10.5.4",


Test patch:
diff --git a/test/cli.js b/test/cli.js
index 33f2a47..6ff81dd 100644
--- a/test/cli.js
+++ b/test/cli.js
@@ -68,19 +68,19 @@ tap.test('cli overrides .node-dev.json from false to true', t => {
 tap.test('-r ts-node/register --inspect test/fixture/server.js', t => {
   const argv = 'node bin/node-dev -r ts-node/register --inspect test/fixture/server.js'.split(' ');
   const { nodeArgs } = cli(argv);
-  t.same(nodeArgs, ['-r', 'ts-node/register', '--inspect']);
+  t.same(nodeArgs, ['--inspect', '--require=ts-node/register']);
   t.end();
 });
 
 tap.test('--inspect -r ts-node/register test/fixture/server.js', t => {
   const argv = 'node bin/node-dev --inspect -r ts-node/register test/fixture/server.js'.split(' ');
   const { nodeArgs } = cli(argv);
-  t.same(nodeArgs, ['--inspect', '-r', 'ts-node/register']);
+  t.same(nodeArgs, ['--inspect', '--require=ts-node/register']);
   t.end();
 });
 
 tap.test('--expose_gc gc.js foo', t => {
-  const argv = 'node bin/node-dev --expose_gc test/fixture/gc.js test/fixture/foo'.split(' ');
+  const argv = 'node bin/node-dev --expose_gc test/fixture/gc.js foo'.split(' ');
   const { nodeArgs } = cli(argv);
   t.same(nodeArgs, ['--expose_gc']);
   t.end();
@@ -139,3 +139,147 @@ tap.test('--debounce=2000', t => {
   t.equal(debounce, 2000);
   t.end();
 });
+
+tap.test('--require source-map-support/register', t => {
+  const { nodeArgs } = cli([
+    'node',
+    'bin/node-dev',
+    '--require',
+    'source-map-support/register',
+    'test'
+  ]);
+
+  t.same(nodeArgs, ['--require=source-map-support/register']);
+  t.end();
+});
+
+tap.test('--require=source-map-support/register', t => {
+  const { nodeArgs } = cli([
+    'node',
+    'bin/node-dev',
+    '--require=source-map-support/register',
+    'test'
+  ]);
+
+  t.same(nodeArgs, ['--require=source-map-support/register']);
+  t.end();
+});
+
+tap.test('-r source-map-support/register', t => {
+  const { nodeArgs } = cli(['node', 'bin/node-dev', '-r', 'source-map-support/register', 'test']);
+
+  t.same(nodeArgs, ['--require=source-map-support/register']);
+  t.end();
+});
+
+tap.test('-r=source-map-support/register', t => {
+  const { nodeArgs } = cli(['node', 'bin/node-dev', '-r=source-map-support/register', 'test']);
+
+  t.same(nodeArgs, ['--require=source-map-support/register']);
+  t.end();
+});
+
+tap.test('--inspect=127.0.0.1:12345', t => {
+  const { nodeArgs } = cli(['node', 'bin/node-dev', '--inspect=127.0.0.1:12345', 'test']);
+
+  t.same(nodeArgs, ['--inspect=127.0.0.1:12345']);
+  t.end();
+});
+
+tap.test('--inspect', t => {
+  const { nodeArgs } = cli(['node', 'bin/node-dev', '--inspect', 'test']);
+
+  t.same(nodeArgs, ['--inspect']);
+  t.end();
+});
+
+tap.test('--require source-map-support/register --require ts-node/register', t => {
+  const { nodeArgs } = cli([
+    'node',
+    'bin/node-dev',
+    '--require',
+    'source-map-support/register',
+    '--require',
+    'ts-node/register',
+    'test'
+  ]);
+
+  t.same(nodeArgs, ['--require=source-map-support/register', '--require=ts-node/register']);
+  t.end();
+});
+
+// This should display usage information at some point
+tap.test('No script or option should fail', t => {
+  t.throws(() => cli(['node', 'bin/node-dev']));
+  t.end();
+});
+
+tap.test('Just an option should fail', t => {
+  t.throws(() => cli(['node', 'bin/node-dev', '--option']));
+  t.end();
+});
+
+tap.test('Just an option with a value should fail', t => {
+  t.throws(() => cli(['node', 'bin/node-dev', '--option=value']));
+  t.end();
+});
+
+tap.test('An unknown argument with a value instead of a script should fail.', t => {
+  t.throws(() => cli(['node', 'bin/node-dev', '--unknown-arg', 'value']));
+  t.end();
+});
+
+tap.test('An unknown argument with a value', t => {
+  const { nodeArgs } = cli(['node', 'bin/node-dev', '--unknown-arg=value', 'test']);
+
+  t.same(nodeArgs, ['--unknown-arg=value']);
+  t.end();
+});
+
+tap.test('An unknown argument without a value can use -- to delimit', t => {
+  // use -- to delimit the end of options
+  const { nodeArgs } = cli(['node', 'bin/node-dev', '--unknown-arg', '--', 'test']);
+
+  t.same(nodeArgs, ['--unknown-arg']);
+  t.end();
+});
+
+tap.test('Single dash with value', t => {
+  const { nodeArgs } = cli(['node', 'bin/node-dev', '-u', 'value', 'test']);
+
+  t.same(nodeArgs, ['-u=value']);
+  t.end();
+});
+
+tap.test('Single dash with = and value', t => {
+  const { nodeArgs } = cli(['node', 'bin/node-dev', '-u=value', 'test']);
+
+  t.same(nodeArgs, ['-u=value']);
+  t.end();
+});
+
+tap.test('Single dash without value should fail', t => {
+  t.throws(() => cli(['node', 'bin/node-dev', '-u', 'test']));
+  t.end();
+});
+
+tap.test('Single dash without value can use -- to delimit', t => {
+  const { nodeArgs } = cli(['node', 'bin/node-dev', '-u', '--', 'test']);
+
+  t.same(nodeArgs, ['-u']);
+  t.end();
+});
+
+tap.test('Repeated single dash', t => {
+  const { nodeArgs } = cli(['node', 'bin/node-dev', '-u=value1', '-u=value2', 'test']);
+
+  t.same(nodeArgs, ['-u=value1', '-u=value2']);
+  t.end();
+});
+
+tap.test('Repeated single dash without =', t => {
+  const { nodeArgs } = cli(['node', 'bin/node-dev', '-u', 'value1', '-u', 'value2', 'test']);
+
+  t.same(nodeArgs, ['-u=value1', '-u=value2']);
+  t.end();
+});