├── .editorconfig ├── .github ├── FUNDING.yml └── workflows │ └── ci.yml ├── .gitignore ├── bin.js ├── index.d.ts ├── license ├── logo.jpg ├── package.json ├── readme.md ├── src └── index.js └── test └── index.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_size = 2 6 | indent_style = tab 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.{json,yml,md}] 13 | indent_style = space 14 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: lukeed 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | test: 13 | name: Node.js v${{ matrix.nodejs }} (${{ matrix.os }}) 14 | runs-on: ${{ matrix.os }} 15 | timeout-minutes: 3 16 | strategy: 17 | matrix: 18 | nodejs: [8, 10, 12, 14] 19 | os: [ubuntu-latest, windows-latest, macOS-latest] 20 | steps: 21 | - uses: actions/checkout@v2 22 | - uses: actions/setup-node@v1 23 | with: 24 | node-version: ${{ matrix.nodejs }} 25 | 26 | - name: Install 27 | run: npm install 28 | 29 | - name: Testing 30 | run: npm test 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | *-lock.* 4 | *.lock 5 | *.log 6 | 7 | /dist 8 | /assert 9 | /parse 10 | /diff 11 | -------------------------------------------------------------------------------- /bin.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const mri = require('mri'); 3 | 4 | let i=0, command=''; 5 | const argv = process.argv.slice(2); 6 | for (; i < argv.length; i++) { 7 | if (argv[i] === '--') { 8 | command = argv.splice(++i).join(' '); 9 | argv.pop(); 10 | break; 11 | } 12 | } 13 | 14 | const opts = mri(argv, { 15 | default: { 16 | cwd: '.', 17 | clear: true, 18 | e: false, 19 | }, 20 | alias: { 21 | cwd: 'C', 22 | eager: 'e', 23 | version: 'v', 24 | ignore: 'i', 25 | help: 'h', 26 | } 27 | }); 28 | 29 | if (opts.version) { 30 | const { version } = require('./package.json'); 31 | console.log(`watchlist, ${version}`); 32 | process.exit(0); 33 | } 34 | 35 | if (opts.help) { 36 | let msg = ''; 37 | msg += '\n Usage\n $ watchlist [...directory] [options] -- \n'; 38 | msg += '\n Options'; 39 | msg += `\n -C, --cwd Directory to resolve from (default .)`; 40 | msg += `\n -e, --eager Execute the command on startup`; 41 | msg += `\n -i, --ignore Any file patterns to ignore`; 42 | msg += '\n -v, --version Displays current version'; 43 | msg += '\n -h, --help Displays this message\n'; 44 | msg += '\n Examples'; 45 | msg += '\n $ watchlist src -- npm run build'; 46 | msg += '\n $ watchlist src tests -i fixtures -- uvu -r esm tests\n'; 47 | console.log(msg); 48 | process.exit(0); 49 | } 50 | 51 | if (!command.length) { 52 | console.error('Missing a command to execute!'); 53 | process.exit(1); 54 | } 55 | 56 | const { run, watch } = require('./dist'); 57 | 58 | try { 59 | const handler = run.bind(0, command); 60 | const dirs = opts._ || [opts.cwd]; 61 | watch(dirs, handler, opts); 62 | } catch (err) { 63 | console.log('Oops~!', err); 64 | } 65 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | import type { BaseEncodingOptions } from 'fs'; 2 | import type { ExecOptions } from 'child_process'; 3 | 4 | type Arrayable = T[] | T; 5 | type Promisable = Promise | T; 6 | 7 | export interface Options { 8 | cwd: string; 9 | clear: boolean; 10 | ignore: Arrayable; 11 | eager: boolean; 12 | } 13 | 14 | export type Handler = () => Promisable; 15 | 16 | export function watch(dirs: string[], handler: Handler, opts?: Partial): Promise; 17 | 18 | export function run(command: string, options: { encoding: 'buffer' | null } & ExecOptions): Promise; 19 | export function run(command: string, options: { encoding: BufferEncoding } & ExecOptions): Promise; 20 | export function run(command: string, options: { encoding: BufferEncoding } & ExecOptions): Promise; 21 | export function run(command: string, options: ExecOptions): Promise; 22 | export function run(command: string, options: (BaseEncodingOptions & ExecOptions) | undefined | null): Promise; 23 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Luke Edwards (lukeed.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lukeed/watchlist/7c3139c22c8ba890e83ff29d8ace9d82d67f0c66/logo.jpg -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "watchlist", 3 | "version": "0.3.1", 4 | "repository": "lukeed/watchlist", 5 | "description": "Recursively watch a list of directories & run a command on any file system changes", 6 | "module": "dist/index.mjs", 7 | "main": "dist/index.js", 8 | "types": "index.d.ts", 9 | "license": "MIT", 10 | "bin": { 11 | "watchlist": "bin.js" 12 | }, 13 | "exports": { 14 | "require": "./dist/index.js", 15 | "import": "./dist/index.mjs" 16 | }, 17 | "files": [ 18 | "*.js", 19 | "*.d.ts", 20 | "dist" 21 | ], 22 | "scripts": { 23 | "build": "bundt", 24 | "test": "uvu -r esm test" 25 | }, 26 | "engines": { 27 | "node": ">=8" 28 | }, 29 | "keywords": [ 30 | "watch", 31 | "watcher", 32 | "watchfile", 33 | "watchexec" 34 | ], 35 | "dependencies": { 36 | "mri": "^1.1.5" 37 | }, 38 | "devDependencies": { 39 | "bundt": "1.0.1", 40 | "esm": "3.2.25", 41 | "uvu": "0.2.2" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 |
2 | watchlist 3 |
4 | 5 | 19 | 20 |
21 | Recursively watch a list of directories & run a command on any file system changes 22 |
23 | 24 | 25 | ## Features 26 | 27 | * Extremely lightweight 28 | * Simple [`fs.watch`](https://nodejs.org/api/fs.html#fs_fs_watch_filename_options_listener) wrapper 29 | * Runs on macOS, Linux, and Windows 30 | * Recursively monitors all subdirectories 31 | * Optional ignore patterns 32 | 33 | > While `fs.watch` has its inconsistencies, efforts are made to normalize behavior across platforms. 34 | 35 | 36 | ## Install 37 | 38 | ``` 39 | $ npm install --save-dev watchlist 40 | ``` 41 | 42 | 43 | ## Usage 44 | 45 | ***CLI*** 46 | 47 | ```sh 48 | # Run `npm test` on changes within "src" and "test" contents change 49 | $ watchlist src test -- npm test 50 | 51 | # Run `npm test` on changes within "packages", ignoring /fixtures/i 52 | $ watchlist packages --ignore fixtures -- npm test 53 | 54 | # Run `lint` script on ANY change 55 | $ watchlist -- npm run lint 56 | ``` 57 | 58 | ***API*** 59 | 60 | ```js 61 | import { watch } from 'watchlist'; 62 | 63 | async function task() { 64 | console.log('~> something updated!'); 65 | await execute_example(); // linter, tests, build, etc 66 | } 67 | 68 | // Run `task()` when "{src,test}/**/*" changes 69 | // Must also ignore changes to any `/fixture/i` match 70 | await watch(['src', 'test'], task, { 71 | ignore: 'fixtures' 72 | }); 73 | ``` 74 | 75 | 76 | ## CLI 77 | 78 | The `watchlist` binary expects the following usage: 79 | 80 | ```sh 81 | $ watchlist [...directory] [options] -- 82 | ``` 83 | 84 | > **Important:** The `--` is required! It separates your `command` from your `watchlist` arguments. 85 | 86 | Please run `watchlist --help` for additional information. 87 | 88 | 89 | ## API 90 | 91 | ### watch(dirs: string[], handler: Function, options?: Options) 92 | Returns: `Promise` 93 | 94 | Watch a list of directories recursively, calling `handler` whenever their contents are modified. 95 | 96 | #### dirs 97 | Type: `Array` 98 | 99 | The list of directories to watch. 100 | 101 | May be relative or absolute paths.
All paths are resolved from the `opts.cwd` location. 102 | 103 | #### handler 104 | Type: `Function` 105 | 106 | The callback function to run. 107 | 108 | > **Note:** This may be a Promise/`async` function. Return values are ignored. 109 | 110 | #### options.cwd 111 | Type: `String`
112 | Default: `.` 113 | 114 | The current working directory. All paths are resolved from this location.
Defaults to `process.cwd()`. 115 | 116 | #### options.ignore 117 | Type: `String` or `RegExp` or `Array` 118 | 119 | A list of patterns that should **not** be watched nor should trigger a `handler` execution.
Ignore patterns are applied to file and directory paths alike. 120 | 121 | > **Note:** Any `String` values will be converted into a `RegExp` automatically. 122 | 123 | #### options.clear 124 | Type: `Boolean`
125 | Default: `false` 126 | 127 | Whether or not the `console` should be cleared before re-running your `handler` function. 128 | 129 | > **Note:** Defaults to `true` for the CLI! Pass `--no-clear` to disable. 130 | 131 | #### options.eager 132 | Type: `Boolean`
133 | Default: `false` 134 | 135 | When enabled, runs the `command` one time, after `watchlist` has initialized. When disabled, a change within the `dirs` list must be observed before the first `command` execution. 136 | 137 | 138 | ### run(command: string, ...args) 139 | Returns: `Promise` 140 | 141 | All arguments to `watchlist.run` are passed to [`child_process.exec`][child_exec] directly. 142 | 143 | > **Note:** Any `stdout` or `stderr` content will be piped/forwarded to your console. 144 | 145 | #### command 146 | Type: `String` 147 | 148 | The command string to execute.
View [`child_process.exec`][child_exec] for more information. 149 | 150 | #### args 151 | Type: `String` 152 | 153 | Additional [`child_process.exec`][child_exec] arguments. 154 | 155 | > **Important:** The `callback` argument is not available! 156 | 157 | 158 | ## License 159 | 160 | MIT © [Luke Edwards](https://lukeed.com) 161 | 162 | [child_exec]: https://nodejs.org/api/child_process.html#child_process_child_process_exec_command_options_callback 163 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { promisify } from 'util'; 2 | import { resolve, join } from 'path'; 3 | import { existsSync, readdir, stat, watch as fsw } from 'fs'; 4 | import { exec } from 'child_process'; 5 | 6 | const toExec = promisify(exec); 7 | const toStats = promisify(stat); 8 | const toRead = promisify(readdir); 9 | 10 | // modified: lukeed/totalist 11 | async function walk(dir, callback, pre='') { 12 | await toRead(dir).then(arr => { 13 | return Promise.all( 14 | arr.map(str => { 15 | let abs = join(dir, str); 16 | return toStats(abs).then(stats => { 17 | if (!stats.isDirectory()) return; 18 | callback(join(pre, str), abs, stats); 19 | return walk(abs, callback, join(pre, str)); 20 | }); 21 | }) 22 | ); 23 | }); 24 | } 25 | 26 | async function setup(dir, onChange) { 27 | let output = {}; 28 | 29 | try { 30 | output[dir] = fsw(dir, { recursive: true }, onChange.bind(0, dir)); 31 | } catch (err) { 32 | if (err.code !== 'ERR_FEATURE_UNAVAILABLE_ON_PLATFORM') throw err; 33 | output[dir] = fsw(dir, onChange.bind(0, dir)); 34 | await walk(dir, (rel, abs) => { 35 | output[abs] = fsw(abs, onChange.bind(0, abs)); 36 | }); 37 | } 38 | 39 | return output; 40 | } 41 | 42 | export async function watch(list, callback, opts={}) { 43 | const cwd = resolve('.', opts.cwd || '.'); 44 | const dirs = new Set(list.map(str => resolve(cwd, str)).filter(existsSync)); 45 | const ignores = ['node_modules'].concat(opts.ignore || []).map(x => new RegExp(x, 'i')); 46 | 47 | let wip = 0; 48 | const Triggers = new Set; 49 | const Watchers = new Map; 50 | 51 | async function handle() { 52 | await callback(); 53 | if (--wip) return handle(); 54 | } 55 | 56 | // TODO: Catch `EPERM` on Windows for removed dir 57 | async function onChange(dir, type, filename) { 58 | if (ignores.some(x => x.test(filename))) return; 59 | 60 | let tmp = join(dir, filename); 61 | if (Triggers.has(tmp)) return; 62 | if (wip++) return wip = 1; 63 | 64 | if (opts.clear) console.clear(); 65 | 66 | Triggers.add(tmp); 67 | await handle(); 68 | Triggers.delete(tmp); 69 | } 70 | 71 | let dir, output, key; 72 | for (dir of dirs) { 73 | output = await setup(dir, onChange); 74 | for (key in output) Watchers.set(key, output[key]); 75 | } 76 | 77 | if (opts.eager) { 78 | await callback(); 79 | } 80 | } 81 | 82 | export async function run() { 83 | try { 84 | let pid = await toExec.apply(0, arguments); 85 | if (pid.stdout) process.stdout.write(pid.stdout); 86 | if (pid.stderr) process.stderr.write(pid.stderr); 87 | } catch (err) { 88 | console.log(`[ERROR] ${err.message}`); // TODO: beep? 89 | if (err.stdout) process.stdout.write(err.stdout); 90 | if (err.stderr) process.stderr.write(err.stderr); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | import { test } from 'uvu'; 2 | import * as assert from 'uvu/assert'; 3 | import * as watchlist from '../src'; 4 | 5 | test('exports', () => { 6 | assert.type(watchlist, 'object'); 7 | assert.type(watchlist.run, 'function'); 8 | assert.type(watchlist.watch, 'function'); 9 | }); 10 | 11 | test.run(); 12 | --------------------------------------------------------------------------------