diff --git a/.eslintrc b/.eslintrc index fff6274..dbf2b66 100644 --- a/.eslintrc +++ b/.eslintrc @@ -5,6 +5,9 @@ "extends": [ "airbnb-base/legacy" ], + "parserOptions": { + "ecmaVersion": 9 + }, "rules": { "comma-dangle": [2, "never"], "consistent-return": 0, diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f35f02..51fdd72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # node-dev +## v5.0.0 / 2020-07-08 + +- Remove `--all-deps` and `--no-deps` CLI options, use `--deps=-1` or `--deps=0` respectively +- Unify `cli` and `cfg` logic to ensure CLI always overrides config files +- Load order for config files now matches what is in the `README` +- Add tests for notify, CLI should override config files +- All config now have clear default values +- Use more ES6 code +- Rename `resolveMain.js` to `resolve-main.js` + ## v4.3.0 / 2020-07-03 - Enable `--notify` by default and add tests diff --git a/README.md b/README.md index ea399fe..e0193e0 100644 --- a/README.md +++ b/README.md @@ -35,12 +35,20 @@ node-dev foo.js There are a couple of command line options that can be used to control which files are watched and what happens when they change: -* `--no-deps` Watch only the project's own files and linked modules (via `npm link`) -* `--all-deps` Watch the whole dependency tree -* `--respawn` Keep watching for changes after the script has exited -* `--dedupe` [Dedupe dynamically](https://www.npmjs.org/package/dynamic-dedupe) -* `--graceful_ipc ` Send 'msg' as an IPC message instead of SIGTERM for restart/shutdown -* `--poll` Force polling for file changes (Caution! CPU-heavy!) +* `--clear` - Clear the screen on restart +* `--dedupe` - [Dedupe dynamically](https://www.npmjs.org/package/dynamic-dedupe) +* `--deps`: + * -1 - Watch the whole dependency tree + * 0 - Watch only the project's own files and linked modules (via `npm link`) + * 1 (_Default_) - Watch all first level dependencies +* `--fork` - Hook into child_process.fork +* `--graceful_ipc ` - Send 'msg' as an IPC message instead of SIGTERM for restart/shutdown +* `--ignore` - A file whose changes should not cause a restart +* `--notify` - Display desktop notifications +* `--poll` - Force polling for file changes (Caution! CPU-heavy!) +* `--respawn` - Keep watching for changes after the script has exited +* `--timestamp` - The timestamp format to use for logging restarts +* `--vm` - Load files using Node's VM By default node-dev will watch all first-level dependencies, i.e. the ones in the project's `node_modules`folder. @@ -76,13 +84,16 @@ Usually node-dev doesn't require any configuration at all, but there are some options you can set to tweak its behaviour: * `clear` – Whether to clear the screen upon restarts. _Default:_ `false` +* `dedupe` – Whether modules should by [dynamically deduped](https://www.npmjs.org/package/dynamic-dedupe). _Default:_ `false` +* `deps` – How many levels of dependencies should be watched. _Default:_ `1` +* `fork` – Whether to hook into [child_process.fork](http://nodejs.org/docs/latest/api/child_process.html#child_process_child_process_fork_modulepath_args_options) (required for [clustered](http://nodejs.org/docs/latest/api/cluster.html) programs). _Default:_ `true` +* `graceful_ipc` - Send the argument provided as an IPC message instead of SIGTERM during restart events. _Default:_ `""` (off) +* `ignore` - A single file or an array of files to ignore. _Default:_ `[]` * `notify` – Whether to display desktop notifications. _Default:_ `true` +* `poll` - Force polling for file changes, this can be CPU-heavy. _Default:_ `false` +* `respawn` - Keep watching for changes after the script has exited. _Default:_ `false` * `timestamp` – The timestamp format to use for logging restarts. _Default:_ `"HH:MM:ss"` * `vm` – Whether to watch files loaded via Node's [VM](http://nodejs.org/docs/latest/api/vm.html) module. _Default:_ `true` -* `fork` – Whether to hook into [child_process.fork](http://nodejs.org/docs/latest/api/child_process.html#child_process_child_process_fork_modulepath_args_options) (required for [clustered](http://nodejs.org/docs/latest/api/cluster.html) programs). _Default:_ `true` -* `deps` – How many levels of dependencies should be watched. _Default:_ `1` -* `dedupe` – Whether modules should by [dynamically deduped](https://www.npmjs.org/package/dynamic-dedupe). _Default:_ `false` -* `graceful_ipc` - Send the argument provided as an IPC message instead of SIGTERM during restart events. _Default:_ `""` (off) Upon startup node-dev looks for a `.node-dev.json` file in the following directories: * user's HOME directory diff --git a/lib/cfg.js b/lib/cfg.js index ecedc3b..1f5e158 100644 --- a/lib/cfg.js +++ b/lib/cfg.js @@ -1,51 +1,44 @@ -var fs = require('fs'); -var path = require('path'); +const fs = require('fs'); +const path = require('path'); + +const resolveMain = require('./resolve-main'); + +const defaultConfig = { + clear: false, + dedupe: false, + deps: 1, + extensions: { + coffee: 'coffeescript/register', + ls: 'LiveScript' + }, + fork: true, + graceful_ipc: '', + ignore: [], + notify: true, + poll: false, + respawn: false, + timestamp: 'HH:MM:ss', + vm: true +}; function read(dir) { - var f = path.resolve(dir, '.node-dev.json'); + const f = path.resolve(dir, '.node-dev.json'); return fs.existsSync(f) ? JSON.parse(fs.readFileSync(f)) : {}; } -function resolvePath(unresolvedPath) { - return path.resolve(process.cwd(), unresolvedPath); -} - -module.exports = function (main, opts) { - var dir = main ? path.dirname(main) : '.'; - var c = Object.assign(read(process.cwd()), read(dir)); - - /* eslint-disable no-proto */ - c.__proto__ = read(process.env.HOME || process.env.USERPROFILE); +function getConfig(script) { + const main = resolveMain(script); + const dir = main ? path.dirname(main) : '.'; - // Truthy == --all-deps, false: one level of deps - if (typeof c.deps !== 'number') c.deps = c.deps ? -1 : 1; - - if (opts) { - // Overwrite with CLI opts ... - if (opts.allDeps) c.deps = -1; - if (!opts.deps) c.deps = 0; - if (opts.dedupe) c.dedupe = true; - if (opts.graceful_ipc) c.graceful_ipc = opts.graceful_ipc; - if (opts.respawn) c.respawn = true; - c.notify = opts.notify; - } - - var ignore = (c.ignore || []).map(resolvePath); + return Object.assign( + defaultConfig, + read(process.env.HOME || process.env.USERPROFILE), + read(process.cwd()), + read(dir) + ); +} - return { - vm: c.vm !== false, - fork: c.fork !== false, - notify: c.notify, - deps: c.deps, - timestamp: c.timestamp || (c.timestamp !== false && 'HH:MM:ss'), - clear: !!c.clear, - dedupe: !!c.dedupe, - graceful_ipc: c.graceful_ipc, - ignore: ignore, - respawn: c.respawn || false, - extensions: c.extensions || { - coffee: 'coffeescript/register', - ls: 'LiveScript' - } - }; +module.exports = { + defaultConfig, + getConfig }; diff --git a/lib/cli.js b/lib/cli.js index 907fabe..83d021c 100644 --- a/lib/cli.js +++ b/lib/cli.js @@ -1,15 +1,26 @@ const minimist = require('minimist'); +const path = require('path'); + +const { defaultConfig, getConfig } = require('./cfg'); + +const configKeys = Object.keys(defaultConfig); + +function resolvePath(unresolvedPath) { + return path.resolve(process.cwd(), unresolvedPath); +} function getFirstNonOptionArgIndex(args) { for (let i = 2; i < args.length; i += 1) { if (args[i][0] != '-') return i; } + return args.length; } function removeValueArgs(args, names) { let i = 0; let removed = []; + while (i < args.length) { if (names.includes(args[i])) { removed = removed.concat(args.splice(i, 2)); @@ -17,6 +28,7 @@ function removeValueArgs(args, names) { i += 1; } } + return removed; } @@ -29,14 +41,22 @@ module.exports = function (argv) { const devArgs = argv.slice(2, scriptIndex); const opts = minimist(devArgs, { - boolean: ['all-deps', 'deps', 'dedupe', 'poll', 'respawn', 'notify'], - string: ['graceful_ipc'], - default: { deps: true, notify: true, graceful_ipc: '' }, + boolean: ['clear', 'dedupe', 'fork', 'notify', 'poll', 'respawn', 'vm'], + string: ['graceful_ipc', 'ignore', 'timestamp'], + default: getConfig(script), unknown: function (arg) { + const argKeys = Object.keys(minimist([arg])); + + if (configKeys.some(k => argKeys.includes(k))) { + return true; + } + nodeArgs.push(arg); } }); + opts.ignore = [...Array.isArray(opts.ignore) ? opts.ignore : [opts.ignore]].map(resolvePath); + return { script, scriptArgs, diff --git a/lib/hook.js b/lib/hook.js index f84d9c3..86c1799 100644 --- a/lib/hook.js +++ b/lib/hook.js @@ -1,12 +1,11 @@ -var vm = require('vm'); - -module.exports = function (cfg, wrapper, callback) { +const vm = require('vm'); +module.exports = function (patchVM, wrapper, callback) { // Hook into Node's `require(...)` updateHooks(); // Patch the vm module to watch files executed via one of these methods: - if (cfg.vm) { + if (patchVM) { patch(vm, 'createScript', 1); patch(vm, 'runInThisContext', 1); patch(vm, 'runInNewContext', 2); @@ -18,11 +17,11 @@ module.exports = function (cfg, wrapper, callback) { * index. */ function patch(obj, method, optionsArgIndex) { - var orig = obj[method]; + const orig = obj[method]; if (!orig) return; obj[method] = function () { - var opts = arguments[optionsArgIndex]; - var file = null; + const opts = arguments[optionsArgIndex]; + let file = null; if (opts) { file = typeof opts == 'string' ? opts : opts.filename; } @@ -36,7 +35,7 @@ module.exports = function (cfg, wrapper, callback) { */ function updateHooks() { Object.keys(require.extensions).forEach(function (ext) { - var fn = require.extensions[ext]; + const fn = require.extensions[ext]; if (typeof fn === 'function' && fn.name !== 'nodeDevHook') { require.extensions[ext] = createHook(fn); } diff --git a/lib/index.js b/lib/index.js index 9a4c852..067668e 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,10 +1,23 @@ -var fork = require('child_process').fork; -var filewatcher = require('filewatcher'); -var path = require('path'); -var ipc = require('./ipc'); -var resolveMain = require('./resolveMain'); - -module.exports = function (script, scriptArgs, nodeArgs, opts) { +const fork = require('child_process').fork; +const filewatcher = require('filewatcher'); +const path = require('path'); + +const ipc = require('./ipc'); +const logFactory = require('./log'); +const notifyFactory = require('./notify'); +const resolveMain = require('./resolve-main'); + +module.exports = function (script, scriptArgs, nodeArgs, { + clear, + dedupe, + deps, + graceful_ipc: gracefulIPC, + ignore, + notify: notifyEnabled, + poll: forcePolling, + respawn, + timestamp +}) { if (!script) { console.log('Usage: node-dev [options] script [arguments]\n'); process.exit(1); @@ -22,23 +35,22 @@ module.exports = function (script, scriptArgs, nodeArgs, opts) { throw new TypeError('`nodeArgs` must be an array'); } + const log = logFactory({ timestamp }); + const notify = notifyFactory(notifyEnabled, log); + // The child_process - var child; + let child; - var wrapper = resolveMain(path.join(__dirname, 'wrap.js')); - var main = resolveMain(script); - var cfg = require('./cfg')(main, opts); - var log = require('./log')(cfg); - var notify = require('./notify')(cfg, log); + const wrapper = resolveMain(path.join(__dirname, 'wrap.js')); // Run ./dedupe.js as preload script - if (cfg.dedupe) process.env.NODE_DEV_PRELOAD = path.join(__dirname, 'dedupe'); + if (dedupe) process.env.NODE_DEV_PRELOAD = path.join(__dirname, 'dedupe'); - var watcher = filewatcher({ forcePolling: opts.poll }); + const watcher = filewatcher({ forcePolling }); watcher.on('change', function (file) { /* eslint-disable no-octal-escape */ - if (cfg.clear) process.stdout.write('\033[2J\033[H'); + if (clear) process.stdout.write('\033[2J\033[H'); notify('Restarting', file + ' has been modified'); watcher.removeAll(); if (child) { @@ -55,20 +67,20 @@ module.exports = function (script, scriptArgs, nodeArgs, opts) { log.warn('node-dev ran out of file handles after watching %s files.', limit); log.warn('Falling back to polling which uses more CPU.'); log.info('Run ulimit -n 10000 to increase the file descriptor limit.'); - if (cfg.deps) log.info('... or add `--no-deps` to use less file handles.'); + if (deps) log.info('... or add `--deps=0` to use fewer file handles.'); }); /** * Run the wrapped script. */ function start() { - var cmd = nodeArgs.concat(wrapper, script, scriptArgs); + const cmd = nodeArgs.concat(wrapper, script, scriptArgs); child = fork(cmd[0], cmd.slice(1), { cwd: process.cwd(), env: process.env }); - if (cfg.respawn) { + if (respawn) { child.respawn = true; } child.on('exit', function (code) { @@ -78,9 +90,9 @@ module.exports = function (script, scriptArgs, nodeArgs, opts) { // Listen for `required` messages and watch the required file. ipc.on(child, 'required', function (m) { - var isIgnored = cfg.ignore.some(isPrefixOf(m.required)); + const isIgnored = ignore.some(isPrefixOf(m.required)); - if (!isIgnored && (cfg.deps === -1 || getLevel(m.required) <= cfg.deps)) { + if (!isIgnored && (deps === -1 || getLevel(m.required) <= deps)) { watcher.add(m.required); } }); @@ -95,9 +107,9 @@ module.exports = function (script, scriptArgs, nodeArgs, opts) { function stop(willTerminate) { child.respawn = true; if (!willTerminate) { - if (cfg.graceful_ipc) { - log.info('Sending IPC: ' + JSON.stringify(cfg.graceful_ipc)); - child.send(cfg.graceful_ipc); + if (gracefulIPC) { + log.info('Sending IPC: ' + JSON.stringify(gracefulIPC)); + child.send(gracefulIPC); } else { child.kill('SIGTERM'); } @@ -108,9 +120,9 @@ module.exports = function (script, scriptArgs, nodeArgs, opts) { // Relay SIGTERM process.on('SIGTERM', function () { if (child && child.connected) { - if (cfg.graceful_ipc) { - log.info('Sending IPC: ' + JSON.stringify(cfg.graceful_ipc)); - child.send(cfg.graceful_ipc); + if (gracefulIPC) { + log.info('Sending IPC: ' + JSON.stringify(gracefulIPC)); + child.send(gracefulIPC); } else { child.kill('SIGTERM'); } @@ -128,7 +140,7 @@ module.exports = function (script, scriptArgs, nodeArgs, opts) { * a positive integer otherwise. */ function getLevel(mod) { - var p = getPrefix(mod); + const p = getPrefix(mod); return p.split('node_modules').length - 1; } @@ -137,8 +149,8 @@ function getLevel(mod) { * empty string if the path does not contain a node_modules dir. */ function getPrefix(mod) { - var n = 'node_modules'; - var i = mod.lastIndexOf(n); + const n = 'node_modules'; + const i = mod.lastIndexOf(n); return i !== -1 ? mod.slice(0, i + n.length) : ''; } diff --git a/lib/log.js b/lib/log.js index 8245c36..6a5a02c 100644 --- a/lib/log.js +++ b/lib/log.js @@ -19,14 +19,14 @@ function colorFactory(enableColor) { * Logs a message to the console. The level is displayed in ANSI colors, * either bright red in case of an error or green otherwise. */ -module.exports = function (cfg) { - var enableColor = !(cfg.noColor || !process.stdout.isTTY); +module.exports = function ({ noColor, timestamp }) { + var enableColor = !(noColor || !process.stdout.isTTY); var color = colorFactory(enableColor); function log(msg, level) { - var timestamp = cfg.timestamp ? color(fmt(new Date(), cfg.timestamp), '39') + ' ' : ''; + var ts = timestamp ? color(fmt(new Date(), timestamp), '39') + ' ' : ''; var c = colors[level.toLowerCase()] || '32'; - var output = '[' + color(level.toUpperCase(), c) + '] ' + timestamp + msg; + var output = '[' + color(level.toUpperCase(), c) + '] ' + ts + msg; console.log(output); return output; } diff --git a/lib/notify.js b/lib/notify.js index ef25a64..40fd164 100644 --- a/lib/notify.js +++ b/lib/notify.js @@ -8,11 +8,11 @@ function icon(level) { /** * Displays a desktop notification and writes a message to the console. */ -module.exports = function (cfg, log) { +module.exports = function (notifyEnabled, log) { return function (title, msg, level) { level = level || 'info'; log(title || msg, level); - if (cfg.notify) { + if (notifyEnabled) { notifier.notify({ title: title || 'node.js', icon: icon(level), diff --git a/lib/resolveMain.js b/lib/resolve-main.js similarity index 100% rename from lib/resolveMain.js rename to lib/resolve-main.js diff --git a/lib/wrap.js b/lib/wrap.js index e54c7a0..274eeca 100755 --- a/lib/wrap.js +++ b/lib/wrap.js @@ -1,18 +1,17 @@ -var path = require('path'); -var childProcess = require('child_process'); -var fork = childProcess.fork; -var resolve = require('resolve').sync; -var hook = require('./hook'); -var ipc = require('./ipc'); -var resolveMain = require('./resolveMain'); +const path = require('path'); +const childProcess = require('child_process'); +const resolve = require('resolve').sync; + +const { getConfig } = require('./cfg'); +const hook = require('./hook'); +const ipc = require('./ipc'); +const resolveMain = require('./resolve-main'); // Remove wrap.js from the argv array process.argv.splice(1, 1); -// Resolve the location of the main script relative to cwd -var main = resolveMain(process.argv[1]); - -var cfg = require('./cfg')(main); +const script = process.argv[1]; +const { extensions, fork, vm } = getConfig(script); if (process.env.NODE_DEV_PRELOAD) { require(process.env.NODE_DEV_PRELOAD); @@ -23,11 +22,12 @@ process.on('SIGTERM', function () { if (process.listeners('SIGTERM').length === 1) process.exit(0); }); -if (cfg.fork) { +if (fork) { // Overwrite child_process.fork() so that we can hook into forked processes // too. We also need to relay messages about required files to the parent. + const originalFork = childProcess.fork; childProcess.fork = function (modulePath, args, options) { - var child = fork(__filename, [modulePath].concat(args), options); + const child = originalFork(__filename, [modulePath].concat(args), options); ipc.relay(child); return child; }; @@ -38,7 +38,7 @@ process.on('uncaughtException', function (err) { console.error(err.stack || err); // If there's a custom uncaughtException handler expect it to terminate // the process. - var hasCustomHandler = process.listeners('uncaughtException').length > 1; + const hasCustomHandler = process.listeners('uncaughtException').length > 1; ipc.send({ error: err.name || 'Error', message: err.message, @@ -47,23 +47,25 @@ process.on('uncaughtException', function (err) { }); // Hook into require() and notify the parent process about required files -hook(cfg, module, function (file) { - ipc.send({ required: file }); +hook(vm, module, function (required) { + ipc.send({ required }); }); // Check if a module is registered for this extension -var ext = path.extname(main).slice(1); -var mod = cfg.extensions[ext]; +const main = resolveMain(script); +const ext = path.extname(main).slice(1); +const mod = extensions[ext]; +const basedir = path.dirname(main); // Support extensions where 'require' returns a function that accepts options if (typeof mod == 'object' && mod.name) { - var fn = require(resolve(mod.name, { basedir: path.dirname(main) })); + const fn = require(resolve(mod.name, { basedir })); if (typeof fn == 'function' && mod.options) { // require returned a function, call it with options fn(mod.options); } } else if (typeof mod == 'string') { - require(resolve(mod, { basedir: path.dirname(main) })); + require(resolve(mod, { basedir })); } // Execute the wrapped script diff --git a/package.json b/package.json index fac5854..7cd47d0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "node-dev", - "version": "4.3.0", + "version": "5.0.0", "description": "Restarts your app when files are modified", "keywords": [ "restart", diff --git a/test/cli.js b/test/cli.js index 4160ec1..fbe319c 100644 --- a/test/cli.js +++ b/test/cli.js @@ -3,15 +3,50 @@ const tap = require('tap'); const cli = require('../lib/cli.js'); tap.test('notify is enabled by default', t => { - const { opts: { notify } } = cli([]); + const { opts: { notify } } = cli(['node', 'bin/node-dev', 'test']); t.is(notify, true); t.done(); }); -tap.test('notify can be disabled', t => { - const { opts: { notify } } = cli(['node', 'bin/node-dev', '--notify=false', 'foo.js']); +tap.test('--no-notify', t => { + const { opts: { notify } } = cli(['node', 'bin/node-dev', '--no-notify', 'test']); t.is(notify, false); t.done(); }); + +tap.test('--notify=false', t => { + const { opts: { notify } } = cli(['node', 'bin/node-dev', '--notify=false', 'test']); + + t.is(notify, false); + t.done(); +}); + +tap.test('--notify', t => { + const { opts: { notify } } = cli(['node', 'bin/node-dev', '--notify', 'test']); + + t.is(notify, true); + t.done(); +}); + +tap.test('--notify=true', t => { + const { opts: { notify } } = cli(['node', 'bin/node-dev', '--notify=true', 'test']); + + t.is(notify, true); + t.done(); +}); + +tap.test('notify can be disabled by .node-dev.json', t => { + const { opts: { notify } } = cli(['node', 'bin/node-dev', 'test/fixture/server.js']); + + t.is(notify, false); + t.done(); +}); + +tap.test('cli overrides .node-dev.json from false to true', t => { + const { opts: { notify } } = cli(['node', 'bin/node-dev', '--notify=true', 'test/fixture/server.js']); + + t.is(notify, true); + t.done(); +}); diff --git a/test/log.js b/test/log.js index f96c324..3492a14 100644 --- a/test/log.js +++ b/test/log.js @@ -1,34 +1,36 @@ -var tap = require('tap'); +const tap = require('tap'); -var cfg = require('../lib/cfg.js')(); +const { defaultConfig } = require('../lib/cfg'); +const logFactory = require('../lib/log'); -cfg.noColor = true; - -var logFactory = require('../lib/log.js'); -var log = logFactory(cfg); +const noColorCfg = { ...defaultConfig, noColor: true }; tap.test('log.info', function (t) { - var out = log.info('hello'); - t.like(out, /\[INFO\] \d{2}:\d{2}:\d{2} hello/); + const log = logFactory(noColorCfg); + t.like(log.info('hello'), /\[INFO\] \d{2}:\d{2}:\d{2} hello/); t.done(); }); tap.test('log.warn', function (t) { - var out = log.warn('a warning'); - t.like(out, /\[WARN\] \d{2}:\d{2}:\d{2} a warning/); + const log = logFactory(noColorCfg); + t.like(log.warn('a warning'), /\[WARN\] \d{2}:\d{2}:\d{2} a warning/); t.done(); }); tap.test('log.error', function (t) { - var out = log.error('an error'); - t.like(out, /\[ERROR\] \d{2}:\d{2}:\d{2} an error/); + const log = logFactory(noColorCfg); + t.like(log.error('an error'), /\[ERROR\] \d{2}:\d{2}:\d{2} an error/); + t.done(); +}); + +tap.test('Disable the timestamp', function (t) { + const log = logFactory({ ...noColorCfg, timestamp: false }); + t.like(log.info('no timestamp'), /\[INFO\] no timestamp/); t.done(); }); -tap.test('Disable the timestmap', function (t) { - var noTsCfg = Object.assign({}, cfg, { timestamp: false }); - var noTsLog = logFactory(noTsCfg); - var out = noTsLog.info('no timestamp'); - t.like(out, /\[INFO\] no timestamp/); +tap.test('Custom timestamp', function (t) { + const log = logFactory({ ...noColorCfg, timestamp: 'yyyy-mm-dd HH:MM:ss' }); + t.like(log.error('an error'), /\[ERROR\] \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} an error/); t.done(); });