├── .babelrc ├── .editorconfig ├── .eslintrc.json ├── .github └── dependabot.yml ├── .gitignore ├── .jestrc.json ├── .npmignore ├── .travis.yml ├── API.md ├── LICENSE ├── README.md ├── appveyor.yml ├── package.json ├── src ├── bin │ └── sync-glob.js ├── index.js └── lib │ ├── fs.js │ ├── is-glob.js │ ├── resolve-target.js │ ├── sources-bases.js │ └── trim-quotes.js └── test ├── .eslintrc.json ├── copy.spec.js ├── helpers.js ├── lib ├── is-glob.spec.js ├── resolve-target.spec.js ├── sources-bases.spec.js └── trim-quotes.spec.js ├── mock ├── @org │ ├── a.txt │ ├── b.txt │ └── d.txt ├── a.txt ├── b.txt ├── bar │ └── c.txt ├── foo space │ ├── a.txt │ └── b.txt ├── foo │ ├── b.txt │ └── d.txt └── transform.js ├── sync.spec.js └── transform.spec.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "transform-object-assign", 4 | "transform-class-properties", 5 | "transform-es2015-destructuring", 6 | "transform-object-rest-spread" 7 | ], 8 | "presets": [ 9 | "es2015" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [**] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | insert_final_newline = true -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb-base", 3 | "parser": "babel-eslint", 4 | "parserOptions": { 5 | "ecmaVersion": 6, 6 | "ecmaFeatures": { 7 | "experimentalObjectRestSpread": true, 8 | "classes": true 9 | } 10 | }, 11 | "rules": { 12 | "dot-notation": "off", 13 | "max-len": ["warn", 140, 2], 14 | "no-cond-assign": ["error", "except-parens"], 15 | "no-else-return": "warn", 16 | "no-plusplus": ["error", { "allowForLoopAfterthoughts": true }], 17 | "no-use-before-define": ["error", { "functions": false, "classes": false }], 18 | "semi": ["error", "never"], 19 | "quote-props": ["error", "consistent"] 20 | }, 21 | "globals": {} 22 | } 23 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | ignore: 9 | - dependency-name: documentation 10 | versions: 11 | - 13.1.1 12 | - 13.2.0 13 | - 13.2.1 14 | - 13.2.4 15 | - dependency-name: dir-compare 16 | versions: 17 | - 3.0.0 18 | - dependency-name: chalk 19 | versions: 20 | - 4.1.0 21 | - dependency-name: babel-jest 22 | versions: 23 | - 23.6.0 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | node_modules/ 3 | tmp/ 4 | /bin/ 5 | /lib/ 6 | index.js 7 | npm-debug.log 8 | -------------------------------------------------------------------------------- /.jestrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "testEnvironment": "node", 3 | "cacheDirectory": "/tmp/jest/" 4 | } 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src/ 2 | tmp/ 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | git: 2 | depth: 3 3 | 4 | language: node_js 5 | 6 | os: 7 | - linux 8 | - osx 9 | 10 | node_js: 11 | - "node" 12 | - "6" 13 | - "5" 14 | - "4" 15 | 16 | install: 17 | - travis_wait npm install 18 | 19 | before_script: 20 | - node --version 21 | - npm --version 22 | - npm run lint 23 | - npm run test 24 | 25 | script: 26 | - npm run build 27 | -------------------------------------------------------------------------------- /API.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ### Table of Contents 4 | 5 | - [syncGlob](#syncglob) 6 | - [NotifyCallback](#notifycallback) 7 | - [CloseFunc](#closefunc) 8 | - [copyFile](#copyfile) 9 | - [copyDir](#copydir) 10 | - [remove](#remove) 11 | - [TransformFunc](#transformfunc) 12 | - [isGlob](#isglob) 13 | - [resolveTarget](#resolvetarget) 14 | - [ResolveTargetFunc](#resolvetargetfunc) 15 | - [sourcesBases](#sourcesbases) 16 | - [trimQuotes](#trimquotes) 17 | 18 | ## syncGlob 19 | 20 | Synchronise files, directories and/or glob patterns, optionally watching for changes. 21 | 22 | **Parameters** 23 | 24 | - `sources` **([string](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String) \| [Array](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array)<[string](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String)>)** A list of files, directories and/or glob patterns. 25 | - `target` **[string](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String)** The destination directory. 26 | - `options` **[Object](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object)?** An optional configuration object. 27 | - `options.watch` **bool?** Enable or disable watch mode. (optional, default `false`) 28 | - `options.delete` **bool?** Whether to delete the `target`'s content initially. (optional, default `true`) 29 | - `options.depth` **bool?** Chokidars `depth` (If set, limits how many levels of subdirectories will be traversed). (optional, default `Infinity`) 30 | - `options.transform` **[string](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String)?** A module path resolved by node's `require`. (optional, default `false`) 31 | - `notify` **[NotifyCallback](#notifycallback)?** An optional notification callback. 32 | 33 | Returns **[CloseFunc](#closefunc)** Returns a close function which cancels active promises and watch mode. 34 | 35 | ## NotifyCallback 36 | 37 | This callback notifies you about various steps, like: 38 | 39 | - **copy:** File or directory has been copied to `target`. 40 | - **remove:** File or directory has been removed from `target`. 41 | - **no-delete:** No initial deletion of `target`s contents. 42 | - **mirror:** Initial copy of all `sources` to `target` done. 43 | - **watch:** Watch mode has started. 44 | - **error:** Any error which may occurred during program execution. 45 | 46 | Type: [Function](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function) 47 | 48 | **Parameters** 49 | 50 | - `type` **[string](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String)** The type of notification. 51 | - `args` **...any** Event specific variadic arguments. 52 | 53 | ## CloseFunc 54 | 55 | A cleanup function which cancels all active promises and closes watch mode if enabled. 56 | 57 | Type: [function](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function) 58 | 59 | ## copyFile 60 | 61 | Copy file from `source` to `target`. 62 | 63 | **Parameters** 64 | 65 | - `source` **[string](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String)** A file to be copied. 66 | - `target` **[string](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String)** A destination path where to copy. 67 | - `transform` **[TransformFunc](#transformfunc)?** Optional transformation function. 68 | 69 | Returns **[Promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise)** 70 | 71 | ## copyDir 72 | 73 | Copy a directory from `source` to `target` (w/o contents). 74 | 75 | **Parameters** 76 | 77 | - `source` **[string](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String)** A directory to be copied. 78 | - `target` **[string](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String)** A destination path where to copy. 79 | 80 | Returns **[Promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise)** 81 | 82 | ## remove 83 | 84 | Remove a file or directory. 85 | 86 | **Parameters** 87 | 88 | - `fileordir` **[string](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String)** The file or directory to remove. 89 | 90 | Returns **[Promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise)** 91 | 92 | ## TransformFunc 93 | 94 | A custom function which transforms a given `file` contents and/or `target`. 95 | 96 | Type: [function](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function) 97 | 98 | **Parameters** 99 | 100 | - `file` **File** A file object obtained by `fs.readFile`. 101 | - `target` **[string](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String)** The destination where to copy this `file`. 102 | 103 | Returns **(File | {data: File, target: [string](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String)})** Returns the transformed `file` and/or renamed `target`. 104 | 105 | ## isGlob 106 | 107 | Determines whether a provided string contains a glob pattern. 108 | 109 | **Parameters** 110 | 111 | - `str` **[string](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String)** The string to test for glob patterns. 112 | 113 | Returns **[number](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number)** Returns the index of the first glob pattern or `-1` if it is not a glob. 114 | 115 | ## resolveTarget 116 | 117 | Determines the target structure by resolving a given `source` against a list of base paths. 118 | 119 | **Parameters** 120 | 121 | - `bases` **[Array](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array)<[string](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String)>** An array of base paths. 122 | 123 | Returns **[ResolveTargetFunc](#resolvetargetfunc)** Returns an `source` to `target` resolving function. 124 | 125 | ## ResolveTargetFunc 126 | 127 | A function which resolves a given `source` to a given `target` based on list of base paths. 128 | 129 | Type: [function](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function) 130 | 131 | **Parameters** 132 | 133 | - `source` **[string](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String)** A file or dir to be resolved against a list of base paths. 134 | - `target` **[string](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String)** A destination folder where to append the diff of `source` and `bases`. 135 | 136 | Returns **[string](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String)** Returns an expanded `target`. 137 | 138 | ## sourcesBases 139 | 140 | Determine the base paths of `sources` like: 141 | 142 | - **files:** `foo/bar.txt` -> `foo` 143 | - **directories:** `foo/bar/` -> `foo/bar` 144 | - **globs:** `foo/*` -> `foo` 145 | 146 | **Parameters** 147 | 148 | - `sources` **([string](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String) \| [Array](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array)<[string](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String)>)** One or more files, directors or glob patterns. 149 | 150 | Returns **[Array](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array)<[string](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String)>** Returns the base paths of `sources`. 151 | 152 | ## trimQuotes 153 | 154 | Trim quotes of a given string. 155 | 156 | **Parameters** 157 | 158 | - `str` **[string](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String)** A string. 159 | 160 | Returns **[string](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String)** Returns `str`, but trimmed from quotes like `'`, `"`. 161 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Andreas Deuschlinger 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # node-sync-glob 2 | 3 | [![Build Status](https://travis-ci.org/AndyOGo/node-sync-glob.svg?branch=master)](https://travis-ci.org/AndyOGo/node-sync-glob) 4 | 5 | [![Build status](https://ci.appveyor.com/api/projects/status/9i48hbtrfsy5sk1m/branch/master?svg=true)](https://ci.appveyor.com/project/AndyOGo/node-sync-glob/branch/master) 6 | 7 | Synchronize files and folders locally by glob patterns, watch option included. 8 | 9 | ## Install 10 | 11 | ```sh 12 | npm i sync-glob 13 | ``` 14 | 15 | ## Usage 16 | 17 | ```sh 18 | Usage: bin/sync-glob.js 19 | 20 | Commands: 21 | sources One or more globs, files or directories to be mirrored (glob 22 | exclusions are supported as well - ! prefix) 23 | target Destination folder for mirrored files 24 | 25 | Options: 26 | --version Show version number [boolean] 27 | --help Show help [boolean] 28 | -d, --delete Delete extraneous files from target 29 | [boolean] [default: true] 30 | -w, --watch Watch changes in sources and keep target in sync 31 | [boolean] [default: false] 32 | -i, --depth Maximum depth if you have performance issues (not 33 | everywhere yet: only on existing mirrors and watch 34 | scenario) [number] [default: Infinity] 35 | -t, --transform A module name to transform each file. sync-glob lookups 36 | the specified name via "require()". [string] 37 | -e, --exit-on-error Exit if an error occurred [default: true] 38 | -v, --verbose Moar output [boolean] [default: false] 39 | -s, --silent No output (except errors) [default: false] 40 | 41 | copyright 2016 42 | ``` 43 | 44 | ### In your `package.json` 45 | 46 | You may have some build script in your package.json involving mirroring folders (let's say, static assets), that's a good use-case for `sync-glob`: 47 | 48 | ```js 49 | // Before 50 | { 51 | "scripts": { 52 | "build": "cp -rf src/images dist/", 53 | "watch": "???" 54 | } 55 | } 56 | 57 | // After 58 | { 59 | "devDependencies": { 60 | "sync-glob": "^1.0.0" 61 | }, 62 | "scripts": { 63 | "build": "sync-glob 'src/images/*' dist/images", 64 | "watch": "sync-glob --watch 'src/images/*' dist/images" 65 | } 66 | } 67 | ``` 68 | 69 | ### Important 70 | 71 | Make sure that your globs are not being parsed by your shell by properly escaping them, e.g.: by quoting `'**/*'`. 72 | 73 | ### Exclude stuff 74 | 75 | To exclude stuff from source just use glob exclusion - `!` prefix, like: 76 | ```js 77 | { 78 | "scripts": { 79 | "sync": "sync-glob 'src/images/*' '!src/images/excluded.jpg' dist/images" 80 | } 81 | } 82 | ``` 83 | 84 | ### Windows 85 | 86 | As [`node-glob`](https://www.npmjs.com/package/glob#windows) and [`node-glob-all`](https://www.npmjs.com/package/glob-all) respectively only support unix style path separators `/`, don't use windows style `\`. 87 | 88 | > **Please only use forward-slashes in glob expressions.** 89 | > 90 | > Though windows uses either `/` or `\` as its path separator, only `/` 91 | > characters are used by this glob implementation. You must use 92 | >forward-slashes **only** in glob expressions. Back-slashes will always 93 | > be interpreted as escape characters, not path separators. 94 | > 95 | > Results from absolute patterns such as `/foo/*` are mounted onto the 96 | > root setting using `path.join`. On windows, this will by default result 97 | > in `/foo/*` matching `C:\foo\bar.txt`. 98 | 99 | ## API 100 | 101 | Check our [API documentation](./API.md) 102 | 103 | ## Credit/Inspiration 104 | 105 | This package was mainly inspired by [`node-sync-files`](https://github.com/byteclubfr/node-sync-files). 106 | I mainly kept the API as is, but enhanced the file matching by utilizing powerful globs. 107 | Additionally it is a complete rewrite in ES6 and it does not suffer from outdated dependencies. 108 | Some fancy features like `--transform` is inspired by [`cpx`](https://www.npmjs.com/package/cpx) 109 | 110 | Proudly brought to you by [``](http://www.scale-unlimited.com) 111 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | # set clone depth 2 | clone_depth: 3 3 | 4 | # Test against the latest version of this Node.js version 5 | environment: 6 | matrix: 7 | # node.js 8 | - nodejs_version: "stable" 9 | - nodejs_version: "6" 10 | - nodejs_version: "5" 11 | - nodejs_version: "4" 12 | 13 | platform: 14 | - x86 15 | - x64 16 | 17 | # Maximum number of concurrent jobs for the project 18 | max_jobs: 4 19 | 20 | # fix newlines 21 | init: 22 | - git config --global core.autocrlf true 23 | 24 | # Install scripts. (runs after repo cloning) 25 | install: 26 | # Get the latest stable version of Node.js or io.js 27 | - ps: Install-Product node $env:nodejs_version 28 | # install modules 29 | - npm install 30 | 31 | # Post-install test scripts. 32 | test_script: 33 | # Output useful info for debugging. 34 | - node --version 35 | - npm --version 36 | # run tests 37 | - npm run lint 38 | - npm run test 39 | 40 | # Don't actually build. 41 | build: off 42 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sync-glob", 3 | "version": "1.4.0", 4 | "description": "Synchronize files and folders locally by glob patterns, watch option included.", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "babel src --out-dir ./", 8 | "predev": "npm run build", 9 | "dev": "node bin/sync-glob.js", 10 | "docs": "documentation build src/ --output API.md --format md", 11 | "prehelp": "npm run build", 12 | "help": "node bin/sync-glob.js --help", 13 | "preversion": "npm run build", 14 | "version": "node bin/sync-glob.js --version", 15 | "test": "jest --runInBand --verbose --no-cache --config ./.jestrc.json", 16 | "lint": "eslint 'src/**/*.js' 'test/**/*.js'", 17 | "prepublish": "npm run build" 18 | }, 19 | "bin": { 20 | "sync-glob": "bin/sync-glob.js" 21 | }, 22 | "repository": { 23 | "type": "git", 24 | "url": "https://github.com/AndyOGo/node-sync-glob.git" 25 | }, 26 | "keywords": [ 27 | "rsync", 28 | "copy", 29 | "cp", 30 | "cpw", 31 | "copyw", 32 | "catw", 33 | "folder", 34 | "directory", 35 | "file", 36 | "glob" 37 | ], 38 | "author": "Andreas Deuschlinger", 39 | "license": "MIT", 40 | "bugs": { 41 | "url": "https://github.com/AndyOGo/node-sync-glob/issues" 42 | }, 43 | "homepage": "https://github.com/AndyOGo/node-sync-glob", 44 | "dependencies": { 45 | "bluebird": "^3.4.7", 46 | "chalk": "^1.1.3", 47 | "chokidar": "^1.6.1", 48 | "fs-extra": "^1.0.0", 49 | "glob-all": "^3.1.0", 50 | "yargs": "^6.3.0" 51 | }, 52 | "devDependencies": { 53 | "babel-cli": "^6.18.0", 54 | "babel-core": "^6.18.2", 55 | "babel-eslint": "^7.1.0", 56 | "babel-jest": "^18.0.0", 57 | "babel-plugin-transform-class-properties": "^6.18.0", 58 | "babel-plugin-transform-es2015-destructuring": "^6.18.0", 59 | "babel-plugin-transform-object-assign": "^6.8.0", 60 | "babel-plugin-transform-object-rest-spread": "^6.16.0", 61 | "babel-preset-es2015": "^6.18.0", 62 | "dir-compare": "^1.4.0", 63 | "documentation": "^4.0.0-beta.18", 64 | "eslint": "^3.9.1", 65 | "eslint-config-airbnb-base": "^9.0.0", 66 | "eslint-plugin-import": "^2.0.1", 67 | "jest": "^19.0.2" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/bin/sync-glob.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /* globals process */ 4 | 5 | import path from 'path' 6 | import chalk from 'chalk' 7 | import yargs from 'yargs' 8 | 9 | import syncGlob from '../index' 10 | 11 | const argv = yargs.usage('Usage: $0 ') 12 | .boolean('delete') 13 | .alias('d', 'delete') 14 | .default('delete', true) 15 | .describe('delete', 'Delete extraneous files from target') 16 | .boolean('watch') 17 | .alias('w', 'watch') 18 | .default('watch', false) 19 | .describe('watch', 'Watch changes in sources and keep target in sync') 20 | .number('depth') 21 | .alias('i', 'depth') 22 | .default('depth', Infinity) 23 | .describe('depth', 'Maximum depth if you have performance issues (not everywhere yet: only on existing mirrors and watch scenario)') 24 | .string('transform') 25 | .alias('t', 'transform') 26 | .describe('transform', 'A module name to transform each file. sync-glob lookups the specified name via "require()".') 27 | .alias('e', 'exit-on-error') 28 | .default('exit-on-error', true) 29 | .describe('exit-on-error', 'Exit if an error occurred') 30 | .boolean('verbose') 31 | .alias('v', 'verbose') 32 | .default('verbose', false) 33 | .describe('verbose', 'Moar output') 34 | .alias('s', 'silent') 35 | .default('silent', false) 36 | .describe('silent', 'No output (except errors)') 37 | .version() 38 | .help('help') 39 | .showHelpOnFail(false, 'Specify --help for available options') 40 | .epilog('copyright 2016') 41 | .command( 42 | 'sources', 'One or more globs, files or directories to be mirrored (glob exclusions are supported as well - ! prefix)', 43 | { alias: 'sources' } 44 | ) 45 | .command('target', 'Destination folder for mirrored files', { alias: 'target' }) 46 | .demand(2) 47 | .argv 48 | const _ = argv._ 49 | const length = _.length 50 | 51 | if (length < 2) { 52 | // eslint-disable-next-line no-console 53 | console.error(chalk.bold.red(`Expects exactly two arguments, received ${length}`)) 54 | process.exit(1) 55 | } 56 | 57 | if (argv.silent) { 58 | // eslint-disable-next-line no-console 59 | console.log = () => {} 60 | } 61 | 62 | const root = process.cwd() 63 | const target = _.pop() 64 | const sources = _ 65 | const notifyPriority = { 66 | 'error': 'high', 67 | 'copy': 'normal', 68 | 'remove': 'normal', 69 | 'watch': 'normal', 70 | 'max-depth': 'low', 71 | 'no-delete': 'low', 72 | } 73 | 74 | const close = syncGlob(sources, target, { 75 | watch: argv.watch, 76 | delete: argv.delete, 77 | depth: argv.depth || Infinity, 78 | transform: argv.transform, 79 | }, (event, data) => { 80 | const priority = notifyPriority[event] || 'low' 81 | 82 | if (!argv.verbose && priority === 'low') { 83 | return 84 | } 85 | 86 | switch (event) { 87 | 88 | case 'error': 89 | // eslint-disable-next-line no-console 90 | console.error('%s %s', chalk.bold('ERROR'), chalk.bold.red(data.message || data)) 91 | 92 | if (argv.exitOnError) { 93 | if (typeof close === 'function') { 94 | close() 95 | } 96 | 97 | process.exit(1) 98 | } 99 | break 100 | 101 | case 'copy': 102 | // eslint-disable-next-line no-console 103 | console.log('%s %s to %s', chalk.bold('COPY'), chalk.yellow(path.relative(root, data[0])), chalk.yellow(path.relative(root, data[1]))) 104 | break 105 | 106 | case 'mirror': 107 | // eslint-disable-next-line no-console 108 | console.log('%s %s to %s', chalk.bold('MIRROR'), chalk.green(data[0]), chalk.green(data[1])) 109 | break 110 | 111 | case 'remove': 112 | // eslint-disable-next-line no-console 113 | console.log('%s %s', chalk.bold('DELETE'), chalk.yellow(path.relative(root, data[1] || data[0]))) 114 | break 115 | 116 | case 'watch': 117 | // eslint-disable-next-line no-console 118 | console.log('%s %s', chalk.bold('WATCHING'), chalk.yellow(data)) 119 | break 120 | 121 | case 'max-depth': 122 | // eslint-disable-next-line no-console 123 | console.log('%s: %s too deep', chalk.bold.dim('MAX-DEPTH'), chalk.yellow(path.relative(root, data))) 124 | break 125 | 126 | case 'no-delete': 127 | // eslint-disable-next-line no-console 128 | console.log('%s: %s extraneous but not deleted (use %s)', 129 | chalk.bold.dim('IGNORED'), 130 | chalk.yellow(path.relative(root, data)), 131 | chalk.blue('--delete')) 132 | break 133 | 134 | // Fallback: forgotten logs, displayed only in verbose mode 135 | default: 136 | if (argv.verbose) { 137 | // eslint-disable-next-line no-console 138 | console.log('%s: %s', chalk.bold(event), data) 139 | } 140 | } 141 | }) 142 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /* globals process */ 2 | 3 | import fs from 'fs' 4 | import path from 'path' 5 | import globAll from 'glob-all' 6 | import chokidar from 'chokidar' 7 | import Promise, { promisify } from 'bluebird' 8 | 9 | import resolveTarget from './lib/resolve-target' 10 | import sourcesBases from './lib/sources-bases' 11 | import isGlob from './lib/is-glob' 12 | import trimQuotes from './lib/trim-quotes' 13 | import { copyDir, copyFile, remove, stat } from './lib/fs' 14 | 15 | Promise.config({ cancellation: true }) 16 | 17 | const defaults = { 18 | watch: false, 19 | delete: true, 20 | depth: Infinity, 21 | } 22 | 23 | /** 24 | * Synchronise files, directories and/or glob patterns, optionally watching for changes. 25 | * 26 | * @param {string|Array.} sources - A list of files, directories and/or glob patterns. 27 | * @param {string} target - The destination directory. 28 | * @param {Object} [options] - An optional configuration object. 29 | * @param {bool} [options.watch=false] - Enable or disable watch mode. 30 | * @param {bool} [options.delete=true] - Whether to delete the `target`'s content initially. 31 | * @param {bool} [options.depth=Infinity] - Chokidars `depth` (If set, limits how many levels of subdirectories will be traversed). 32 | * @param {string} [options.transform=false] - A module path resolved by node's `require`. 33 | * @param {NotifyCallback} [notify] - An optional notification callback. 34 | * @returns {CloseFunc} - Returns a close function which cancels active promises and watch mode. 35 | */ 36 | // eslint-disable-next-line consistent-return 37 | const syncGlob = (sources, target, options = {}, notify = () => {}) => { 38 | if (!Array.isArray(sources)) { 39 | // eslint-disable-next-line no-param-reassign 40 | sources = [sources] 41 | } 42 | // eslint-disable-next-line no-param-reassign 43 | sources = sources.map(trimQuotes) 44 | 45 | if (typeof options === 'function') { 46 | // eslint-disable-next-line no-param-reassign 47 | notify = options 48 | // eslint-disable-next-line no-param-reassign 49 | options = {} 50 | } 51 | 52 | // eslint-disable-next-line no-param-reassign 53 | options = { 54 | ...defaults, 55 | ...options, 56 | } 57 | 58 | const notifyError = (err) => { notify('error', err) } 59 | const bases = sourcesBases(sources) 60 | const resolveTargetFromBases = resolveTarget(bases) 61 | const { depth, watch } = options 62 | let { transform } = options 63 | 64 | if (typeof depth !== 'number' || isNaN(depth)) { 65 | notifyError('Expected valid number for option "depth"') 66 | return false 67 | } 68 | 69 | if (transform) { 70 | let transformPath = transform 71 | 72 | try { 73 | require.resolve(transformPath) 74 | } catch (err) { 75 | transformPath = path.join(process.cwd(), transformPath) 76 | 77 | try { 78 | require.resolve(transformPath) 79 | } catch (err2) { 80 | notifyError(err2) 81 | } 82 | } 83 | 84 | // eslint-disable-next-line 85 | transform = require(transformPath) 86 | } 87 | 88 | // Initial mirror 89 | const mirrorInit = [ 90 | promisify(globAll)(sources.map(source => (isGlob(source) === -1 91 | && fs.statSync(source).isDirectory() ? `${source}/**` : source))) 92 | .then(files => files.map(file => path.normalize(file))), 93 | ] 94 | 95 | if (options.delete) { 96 | mirrorInit.push(remove(target) 97 | .then(() => { 98 | notify('remove', [target]) 99 | }) 100 | .catch(notifyError) 101 | ) 102 | } else { 103 | notify('no-delete', target) 104 | } 105 | 106 | let mirrorPromiseAll = Promise.all(mirrorInit) 107 | .then(([files]) => Promise.all(files.map((source) => { 108 | const resolvedTarget = resolveTargetFromBases(source, target) 109 | 110 | return stat(source) 111 | .then((stats) => { 112 | let result 113 | 114 | if (stats.isFile()) { 115 | result = copyFile(source, resolvedTarget, transform) 116 | } else if (stats.isDirectory()) { 117 | result = copyDir(source, resolvedTarget) 118 | } 119 | 120 | if (result) { 121 | result = result.then(() => { 122 | notify('copy', [source, resolvedTarget]) 123 | }) 124 | } 125 | 126 | return result 127 | }) 128 | .catch(notifyError) 129 | }))) 130 | .then(() => { 131 | notify('mirror', [sources, target]) 132 | }) 133 | .catch(notifyError) 134 | .finally(() => { 135 | mirrorPromiseAll = null 136 | }) 137 | 138 | let watcher 139 | let activePromises = [] 140 | const close = () => { 141 | if (watcher) { 142 | watcher.close() 143 | watcher = null 144 | } 145 | 146 | if (mirrorPromiseAll) { 147 | mirrorPromiseAll.cancel() 148 | mirrorPromiseAll = null 149 | } 150 | 151 | if (activePromises) { 152 | activePromises.forEach((promise) => { 153 | promise.cancel() 154 | }) 155 | 156 | activePromises = null 157 | } 158 | } 159 | 160 | // Watcher to keep in sync from that 161 | if (watch) { 162 | watcher = chokidar.watch(sources, { 163 | persistent: true, 164 | depth, 165 | ignoreInitial: true, 166 | awaitWriteFinish: true, 167 | }) 168 | 169 | watcher.on('ready', notify.bind(undefined, 'watch', sources)) 170 | .on('all', (event, source) => { 171 | const resolvedTarget = resolveTargetFromBases(source, target) 172 | let promise 173 | 174 | switch (event) { 175 | case 'add': 176 | case 'change': 177 | promise = copyFile(source, resolvedTarget, transform) 178 | break 179 | 180 | case 'addDir': 181 | promise = copyDir(source, resolvedTarget) 182 | break 183 | 184 | case 'unlink': 185 | case 'unlinkDir': 186 | promise = remove(resolvedTarget) 187 | break 188 | 189 | default: 190 | return 191 | } 192 | 193 | activePromises.push(promise 194 | .then(() => { 195 | const eventMap = { 196 | add: 'copy', 197 | addDir: 'copy', 198 | change: 'copy', 199 | unlink: 'remove', 200 | unlinkDir: 'remove', 201 | } 202 | 203 | notify(eventMap[event] || event, [source, resolvedTarget]) 204 | }) 205 | .catch(notifyError) 206 | .finally(() => { 207 | if (activePromises) { 208 | const index = activePromises.indexOf(promise) 209 | 210 | if (index !== -1) { 211 | activePromises.slice(index, 1) 212 | } 213 | } 214 | 215 | promise = null 216 | }) 217 | ) 218 | }) 219 | .on('error', notifyError) 220 | 221 | process.on('SIGINT', close) 222 | process.on('SIGQUIT', close) 223 | process.on('SIGTERM', close) 224 | } 225 | 226 | return close 227 | } 228 | 229 | export default syncGlob 230 | 231 | /** 232 | * This callback notifies you about various steps, like: 233 | * - **copy:** File or directory has been copied to `target`. 234 | * - **remove:** File or directory has been removed from `target`. 235 | * - **no-delete:** No initial deletion of `target`s contents. 236 | * - **mirror:** Initial copy of all `sources` to `target` done. 237 | * - **watch:** Watch mode has started. 238 | * - **error:** Any error which may occurred during program execution. 239 | * 240 | * @callback NotifyCallback 241 | * @param {string} type - The type of notification. 242 | * @param {...any} args - Event specific variadic arguments. 243 | */ 244 | 245 | /** 246 | * A cleanup function which cancels all active promises and closes watch mode if enabled. 247 | * 248 | * @typedef {function} CloseFunc 249 | */ 250 | -------------------------------------------------------------------------------- /src/lib/fs.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra' 2 | import path from 'path' 3 | import Promise, { promisify } from 'bluebird' 4 | 5 | Promise.config({ cancellation: true }) 6 | 7 | const copy = promisify(fs.copy) 8 | const ensureDir = promisify(fs.ensureDir) 9 | const readFile = promisify(fs.readFile) 10 | const writeFile = promisify(fs.writeFile) 11 | 12 | /** 13 | * Copy file from `source` to `target`. 14 | * 15 | * @param {string} source - A file to be copied. 16 | * @param {string} target - A destination path where to copy. 17 | * @param {TransformFunc} [transform] - Optional transformation function. 18 | * @returns {Promise} 19 | */ 20 | export const copyFile = (source, target, transform) => new Promise((resolve) => { 21 | if (transform) { 22 | resolve(readFile(source).then((file) => { 23 | const transformed = transform(file, target) 24 | const isObject = typeof transformed === 'object' 25 | const data = (isObject && transformed.data) || transformed 26 | const newTarget = (isObject && transformed.target) || target 27 | 28 | return ensureDir(path.dirname(newTarget)).then(() => writeFile(newTarget, data)) 29 | })) 30 | } else { 31 | resolve(ensureDir(path.dirname(target)).then(() => copy(source, target))) 32 | } 33 | }) 34 | 35 | /** 36 | * Copy a directory from `source` to `target` (w/o contents). 37 | * 38 | * @param {string} source - A directory to be copied. 39 | * @param {string} target - A destination path where to copy. 40 | * @returns {Promise} 41 | */ 42 | export const copyDir = (source, target) => ensureDir(target) 43 | 44 | /** 45 | * Remove a file or directory. 46 | * 47 | * @param {string} fileordir - The file or directory to remove. 48 | * @returns {Promise} 49 | */ 50 | export const remove = promisify(fs.remove) 51 | 52 | export const stat = promisify(fs.stat) 53 | 54 | /** 55 | * A custom function which transforms a given `file` contents and/or `target`. 56 | * 57 | * @typedef {function} TransformFunc 58 | * @param {File} file - A file object obtained by `fs.readFile`. 59 | * @param {string} target - The destination where to copy this `file`. 60 | * @returns {File|{data: File, target: string}} - Returns the transformed `file` and/or renamed `target`. 61 | */ 62 | -------------------------------------------------------------------------------- /src/lib/is-glob.js: -------------------------------------------------------------------------------- 1 | const reGlobFirstChar = /^\{.*[^\\]}|^\*\*?|^\[.*[^\\]]|^[!?+*@]\(.*[^\\]\)|^\?|^!(?=[^([])(?=[^\\][^(])/ 2 | const reGlob = /(?!\\).(:?\{.*[^\\]}|\*\*?|\[.*[^\\]]|[!?+*@]\(.*[^\\]\)|\?)|^!(?=[^([])(?=[^\\][^(])/ 3 | 4 | /** 5 | * Determines whether a provided string contains a glob pattern. 6 | * 7 | * @param {string} str - The string to test for glob patterns. 8 | * @returns {number} - Returns the index of the first glob pattern or `-1` if it is not a glob. 9 | */ 10 | const isGlob = (str) => { 11 | const match = reGlob.exec(str) 12 | let matchFirst 13 | let index = match ? match.index : -1 14 | 15 | if (!match || index === 0) { 16 | matchFirst = reGlobFirstChar.exec(str) 17 | 18 | if (matchFirst) { 19 | index = matchFirst.index 20 | } 21 | } 22 | 23 | if ((index > 0 || index === 0) && !matchFirst) { 24 | // eslint-disable-next-line no-plusplus 25 | ++index 26 | } 27 | 28 | return index 29 | } 30 | 31 | export default isGlob 32 | -------------------------------------------------------------------------------- /src/lib/resolve-target.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | 3 | const cwd = process.cwd() 4 | 5 | const getBase = (source, bases) => bases.filter(base => source.indexOf(base) !== -1) 6 | // eslint-disable-next-line no-confusing-arrow 7 | .reduce((hit, base) => hit.length > base.length ? hit : base, '') 8 | 9 | /** 10 | * Determines the target structure by resolving a given `source` against a list of base paths. 11 | * 12 | * @param {Array.} bases - An array of base paths. 13 | * @returns {ResolveTargetFunc} - Returns an `source` to `target` resolving function. 14 | */ 15 | const resolveTarget = bases => (source, target) => { 16 | const from = path.join(cwd, getBase(source, bases)) 17 | 18 | return path.join(target, path.relative(from, source)) 19 | } 20 | 21 | export default resolveTarget 22 | 23 | /** 24 | * A function which resolves a given `source` to a given `target` based on list of base paths. 25 | * 26 | * @typedef {function} ResolveTargetFunc 27 | * @param {string} source - A file or dir to be resolved against a list of base paths. 28 | * @param {string} target - A destination folder where to append the diff of `source` and `bases`. 29 | * @returns {string} - Returns an expanded `target`. 30 | */ 31 | -------------------------------------------------------------------------------- /src/lib/sources-bases.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import path from 'path' 3 | 4 | import isGlob from './is-glob' 5 | 6 | const reDir = /\/|\\/ 7 | const reDirAll = new RegExp(reDir.source, 'gm') 8 | 9 | /** 10 | * Determine the base paths of `sources` like: 11 | * - **files:** `foo/bar.txt` -> `foo` 12 | * - **directories:** `foo/bar/` -> `foo/bar` 13 | * - **globs:** `foo/*` -> `foo` 14 | * 15 | * @param {string|Array.} sources - One or more files, directors or glob patterns. 16 | * @returns {Array.} - Returns the base paths of `sources`. 17 | */ 18 | const sourcesBases = (sources) => { 19 | if (!Array.isArray(sources)) { 20 | // eslint-disable-next-line no-param-reassign 21 | sources = [sources] 22 | } 23 | 24 | return sources.reduce((bases, pattern) => { 25 | if (pattern.charAt(0) === '!') { 26 | return bases 27 | } 28 | 29 | const index = isGlob(pattern) 30 | const foundGlob = index > -1 31 | let isDir 32 | 33 | if (index > -1) { 34 | const charBeforeGlob = pattern.charAt(index - 1) 35 | 36 | isDir = reDir.test(charBeforeGlob) 37 | // eslint-disable-next-line no-param-reassign 38 | pattern = pattern.substring(0, index) 39 | } 40 | 41 | if (pattern) { 42 | if ((foundGlob && !isDir) || 43 | (!foundGlob && fs.statSync(pattern).isFile())) { 44 | // eslint-disable-next-line no-param-reassign 45 | pattern = path.dirname(pattern) 46 | } else if (reDir.test(pattern.charAt(pattern.length - 1))) { 47 | // eslint-disable-next-line no-param-reassign 48 | pattern = pattern.slice(0, -1) 49 | } 50 | } 51 | 52 | // eslint-disable-next-line no-param-reassign 53 | pattern = pattern.replace(reDirAll, path.sep) 54 | 55 | if (bases.indexOf(pattern) === -1) { 56 | bases.push(pattern) 57 | } 58 | 59 | return bases 60 | }, []) 61 | } 62 | 63 | export default sourcesBases 64 | -------------------------------------------------------------------------------- /src/lib/trim-quotes.js: -------------------------------------------------------------------------------- 1 | const rePreQuotes = /^['"]/ 2 | const rePostQuotes = /['"]$/ 3 | /** 4 | * Trim quotes of a given string. 5 | * 6 | * @param {string} str - A string. 7 | * @returns {string} - Returns `str`, but trimmed from quotes like `'`, `"`. 8 | */ 9 | const trimQuotes = str => str.replace(rePreQuotes, '').replace(rePostQuotes, '') 10 | 11 | export default trimQuotes 12 | -------------------------------------------------------------------------------- /test/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "jest": true, 5 | "jasmine": true 6 | }, 7 | "rules": { 8 | "import/no-extraneous-dependencies": ["error", { 9 | "devDependencies": true, 10 | "optionalDependencies": false, 11 | "peerDependencies": false 12 | }] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /test/copy.spec.js: -------------------------------------------------------------------------------- 1 | import syncGlob from '../src/index' 2 | import { beforeEachSpec, afterAllSpecs, awaitCount, awaitMatch, compare, compareDir, noop, fs } from './helpers' 3 | 4 | describe('node-sync-glob copy', () => { 5 | beforeEach(beforeEachSpec) 6 | afterAll(afterAllSpecs) 7 | 8 | it('should copy a file', (done) => { 9 | const close = syncGlob('tmp/mock/a.txt', 'tmp/copy', awaitMatch( 10 | 'error', (err) => { 11 | fail(err) 12 | close() 13 | done() 14 | }, 15 | 'mirror', compare(done, 'tmp/mock/a.txt', 'tmp/copy/a.txt') 16 | )) 17 | }) 18 | 19 | it('should copy an array of files', (done) => { 20 | const close = syncGlob(['tmp/mock/a.txt', 'tmp/mock/b.txt'], 'tmp/copy', awaitMatch( 21 | 'error', (err) => { 22 | fail(err) 23 | close() 24 | done() 25 | }, 26 | 'mirror', () => { 27 | compare(noop, 'tmp/mock/a.txt', 'tmp/copy/a.txt') 28 | compare(noop, 'tmp/mock/b.txt', 'tmp/copy/b.txt') 29 | 30 | done() 31 | } 32 | )) 33 | }) 34 | 35 | it('should copy a directory (without contents)', (done) => { 36 | const awaitDone = awaitCount(4, done) 37 | 38 | const close = syncGlob('tmp/mock/foo', 'tmp/copy', awaitMatch( 39 | 'error', (err) => { 40 | fail(err) 41 | close() 42 | done() 43 | }, 44 | 'mirror', compareDir(awaitDone, 'tmp/mock/foo', 'tmp/copy') 45 | )) 46 | const close1 = syncGlob('tmp/mock/foo/', 'tmp/copy1', awaitMatch( 47 | 'error', (err) => { 48 | close1() 49 | fail(err) 50 | done() 51 | }, 52 | 'mirror', compareDir(awaitDone, 'tmp/mock/foo/', 'tmp/copy1') 53 | )) 54 | const close2 = syncGlob('tmp/mock/@org', 'tmp/copy2', awaitMatch( 55 | 'error', (err) => { 56 | close2() 57 | fail(err) 58 | done() 59 | }, 60 | 'mirror', compareDir(awaitDone, 'tmp/mock/@org', 'tmp/copy2') 61 | )) 62 | const close3 = syncGlob('tmp/mock/@org/', 'tmp/copy3', awaitMatch( 63 | 'error', (err) => { 64 | close3() 65 | fail(err) 66 | done() 67 | }, 68 | 'mirror', compareDir(awaitDone, 'tmp/mock/@org/', 'tmp/copy3') 69 | )) 70 | }) 71 | 72 | xit('should copy an array of directories (without contents)', (done) => { 73 | const close = syncGlob(['tmp/mock/foo', 'tmp/mock/bar/', 'tmp/mock/@org'], 'tmp/copy', awaitMatch( 74 | 'error', (err) => { 75 | fail(err) 76 | close() 77 | done() 78 | }, 79 | 'mirror', compare(done) 80 | )) 81 | }) 82 | 83 | it('should copy globs', (done) => { 84 | const awaitDone = awaitCount(3, done) 85 | 86 | const close = syncGlob('tmp/mock/@org/*.txt', 'tmp/copy', awaitMatch( 87 | 'error', (err) => { 88 | fail(err) 89 | close() 90 | done() 91 | }, 92 | 'mirror', compareDir(awaitDone, 'tmp/mock/@org', 'tmp/copy') 93 | )) 94 | 95 | const close1 = syncGlob('tmp/mock/foo/*.txt', 'tmp/copy1', awaitMatch( 96 | 'error', (err) => { 97 | close1() 98 | fail(err) 99 | done() 100 | }, 101 | 'mirror', compareDir(awaitDone, 'tmp/mock/foo', 'tmp/copy1') 102 | )) 103 | 104 | const close2 = syncGlob('tmp/mock/foo space/*.txt', 'tmp/copy 2', awaitMatch( 105 | 'error', (err) => { 106 | close2() 107 | fail(err) 108 | done() 109 | }, 110 | 'mirror', compareDir(awaitDone, 'tmp/mock/foo space', 'tmp/copy 2') 111 | )) 112 | }) 113 | 114 | it('should copy glob exclusion', (done) => { 115 | const close = syncGlob(['tmp/mock/foo/*', '!tmp/mock/foo/b.txt'], 'tmp/copy', awaitMatch( 116 | 'error', (err) => { 117 | fail(err) 118 | close() 119 | done() 120 | }, 121 | 'mirror', () => { 122 | compare(noop, 'tmp/mock/foo/d.txt', 'tmp/copy/d.txt') 123 | expect(fs.existsSync('tmp/copy/b.txt')).toBe(false) 124 | 125 | done() 126 | } 127 | )) 128 | }) 129 | 130 | it('should copy globstar', (done) => { 131 | const close = syncGlob('tmp/mock/**/*', 'tmp/copy', awaitMatch( 132 | 'error', (err) => { 133 | fail(err) 134 | close() 135 | done() 136 | }, 137 | 'mirror', compareDir(done, 'tmp/mock', 'tmp/copy') 138 | )) 139 | }) 140 | }) 141 | -------------------------------------------------------------------------------- /test/helpers.js: -------------------------------------------------------------------------------- 1 | import fsExtra from 'fs-extra' 2 | import path from 'path' 3 | import dirCompare from 'dir-compare' 4 | 5 | export const noop = () => {} 6 | 7 | export const fs = { 8 | removeSync: source => fsExtra.removeSync(path.normalize(source)), 9 | copySync: (source, target) => fsExtra.copySync(path.normalize(source), path.normalize(target)), 10 | appendFileSync: (source, ...args) => fsExtra.appendFileSync(path.normalize(source), ...args), 11 | existsSync: source => fsExtra.existsSync(path.normalize(source)), 12 | readFileSync: source => fsExtra.readFileSync(path.normalize(source)), 13 | } 14 | 15 | export const beforeEachSpec = () => { 16 | fs.removeSync('tmp') 17 | fs.copySync('test/mock', 'tmp/mock') 18 | } 19 | 20 | export const afterAllSpecs = () => { 21 | fs.removeSync('tmp') 22 | } 23 | 24 | export const awaitCount = (limit, done) => { 25 | let count = 0 26 | 27 | return () => { 28 | // eslint-disable-next-line no-plusplus 29 | if (++count === limit) done() 30 | } 31 | } 32 | 33 | export const awaitMatch = (...args) => { 34 | if (args.length % 2) { 35 | throw new Error('Args arity must be sets of two') 36 | } 37 | 38 | const normalizeMatch = (match) => { 39 | if (!Array.isArray(match)) { 40 | // eslint-disable-next-line no-param-reassign 41 | match = [match] 42 | } 43 | 44 | return match.reduce((matches, value) => { 45 | if (typeof value === 'object') { 46 | Object.keys(value).forEach((key) => { 47 | const count = value[key] 48 | 49 | for (let i = 0; i < count; i++) { 50 | matches.push(key) 51 | } 52 | }) 53 | } else { 54 | matches.push(value) 55 | } 56 | 57 | return matches 58 | }, []) 59 | } 60 | let match = normalizeMatch(args.shift()) 61 | let callback = args.shift() 62 | let onError 63 | 64 | if (match[0] === 'error') { 65 | onError = callback 66 | 67 | if (args.length) { 68 | match = normalizeMatch(args.shift()) 69 | callback = args.shift() 70 | } 71 | } 72 | 73 | return (event, data) => { 74 | if (event === 'error') { 75 | // eslint-disable-next-line no-console 76 | console.error(`${event} -> ${data}`) 77 | if (data.stack) { 78 | // eslint-disable-next-line no-console 79 | console.log(data.stack) 80 | } 81 | 82 | if (onError) { 83 | onError(data) 84 | } 85 | return 86 | } 87 | 88 | if (!match.length && !args.length) { 89 | return 90 | } 91 | 92 | const index = match.indexOf(event) 93 | 94 | if (index > -1) { 95 | match.splice(index, 1) 96 | } 97 | 98 | if (match.length === 0) { 99 | callback(event, data) 100 | 101 | if (args.length) { 102 | match = normalizeMatch(args.shift()) 103 | callback = args.shift() 104 | } 105 | } 106 | } 107 | } 108 | 109 | export const compare = (done, source, target, options) => (event, data) => { 110 | if (event) { 111 | if (Array.isArray(data) && data.length === 2 112 | && typeof data[0] === 'string' && typeof data[1] === 'string') { 113 | // eslint-disable-next-line no-param-reassign 114 | [source, target] = data 115 | } 116 | 117 | const res = dirCompare.compareSync(source, target, { 118 | ...options, 119 | compareSize: true, 120 | compareContent: true, 121 | }) 122 | 123 | expect(res.differences).toBe(0) 124 | expect(res.differencesFiles).toBe(0) 125 | expect(res.distinctFiles).toBe(0) 126 | expect(res.differencesDirs).toBe(0) 127 | expect(res.distinctDirs).toBe(0) 128 | 129 | if (done) { 130 | done() 131 | } 132 | } 133 | } 134 | 135 | export const compareDir = (done, source, target, options = {}) => (event) => { 136 | if (event) { 137 | const res = dirCompare.compareSync(source, target, { 138 | ...options, 139 | compareSize: true, 140 | compareContent: true, 141 | }) 142 | 143 | expect(res.differences).toBe(0) 144 | expect(res.differencesFiles).toBe(0) 145 | expect(res.distinctFiles).toBe(0) 146 | expect(res.differencesDirs).toBe(0) 147 | expect(res.distinctDirs).toBe(0) 148 | 149 | if (done) { 150 | done() 151 | } 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /test/lib/is-glob.spec.js: -------------------------------------------------------------------------------- 1 | import isGlob from '../../src/lib/is-glob' 2 | 3 | // eslint-disable-next-line no-bitwise 4 | const isGloby = value => !!~isGlob(value) 5 | 6 | describe('lib/is-glob', () => { 7 | describe('truthy', () => { 8 | it('should match asterisk wildcard', () => { 9 | expect(isGloby('*')).toBe(true) 10 | expect(isGloby('*.js')).toBe(true) 11 | expect(isGloby('/*.js')).toBe(true) 12 | }) 13 | 14 | it('should match globstar wildcard', () => { 15 | expect(isGloby('**')).toBe(true) 16 | expect(isGloby('**/a.js')).toBe(true) 17 | }) 18 | 19 | it('should match question mark wildcard', () => { 20 | expect(isGloby('?')).toBe(true) 21 | expect(isGloby('?.js')).toBe(true) 22 | expect(isGloby('/?.js')).toBe(true) 23 | }) 24 | 25 | it('should match braced sections', () => { 26 | expect(isGloby('{a,b}')).toBe(true) 27 | expect(isGloby('abc/{a,b}')).toBe(true) 28 | }) 29 | 30 | it('should match range wildcards', () => { 31 | expect(isGloby('[a-z]')).toBe(true) 32 | expect(isGloby('[a-z].js')).toBe(true) 33 | expect(isGloby('/[a-z]')).toBe(true) 34 | expect(isGloby('[!a-z]')).toBe(true) 35 | expect(isGloby('[^a-z]')).toBe(true) 36 | }) 37 | 38 | it('should match extended globs', () => { 39 | expect(isGloby('!(a|b|c)')).toBe(true) 40 | expect(isGloby('?(a|b|c)')).toBe(true) 41 | expect(isGloby('+(a|b|c)')).toBe(true) 42 | expect(isGloby('*(a|b|c)')).toBe(true) 43 | expect(isGloby('@(a|b|c)')).toBe(true) 44 | }) 45 | 46 | it('should match glob exclusion', () => { 47 | expect(isGloby('!foo')).toBe(true) 48 | expect(isGloby('!foo/bar')).toBe(true) 49 | expect(isGloby('!foo/*')).toBe(true) 50 | expect(isGloby('!foo/bar/**/*')).toBe(true) 51 | }) 52 | }) 53 | 54 | describe('falsy', () => { 55 | it('should not match escaped asterisks', () => { 56 | expect(isGloby('\\*')).toBe(false) 57 | expect(isGloby('\\*.js')).toBe(false) 58 | expect(isGloby('/\\*.js')).toBe(false) 59 | }) 60 | 61 | it('should not match escaped globstar', () => { 62 | expect(isGloby('\\*\\*')).toBe(false) 63 | expect(isGloby('\\*\\*/a.js')).toBe(false) 64 | }) 65 | 66 | it('should not match escaped question mark', () => { 67 | expect(isGloby('\\?')).toBe(false) 68 | expect(isGloby('\\?.js')).toBe(false) 69 | expect(isGloby('/\\?.js')).toBe(false) 70 | }) 71 | 72 | it('should not match escaped braced sections', () => { 73 | expect(isGloby('\\{a,b}')).toBe(false) 74 | expect(isGloby('abc/\\{a,b}')).toBe(false) 75 | 76 | expect(isGloby('{a,b\\}')).toBe(false) 77 | expect(isGloby('abc/{a,b\\}')).toBe(false) 78 | }) 79 | 80 | it('should not match escaped range wildcards', () => { 81 | expect(isGloby('\\[a-z]')).toBe(false) 82 | expect(isGloby('\\[a-z].js')).toBe(false) 83 | expect(isGloby('/\\[a-z]')).toBe(false) 84 | expect(isGloby('\\[!a-z]')).toBe(false) 85 | expect(isGloby('\\[^a-z]')).toBe(false) 86 | 87 | expect(isGloby('[a-z\\]')).toBe(false) 88 | expect(isGloby('[a-z\\].js')).toBe(false) 89 | expect(isGloby('/[a-z\\]')).toBe(false) 90 | expect(isGloby('[!a-z\\]')).toBe(false) 91 | expect(isGloby('[^a-z\\]')).toBe(false) 92 | }) 93 | 94 | it('should not match escaped extended globs', () => { 95 | expect(isGloby('\\!(a|b|c)')).toBe(false) 96 | expect(isGloby('\\?(a|b|c)')).toBe(false) 97 | expect(isGloby('\\+(a|b|c)')).toBe(false) 98 | expect(isGloby('\\*(a|b|c)')).toBe(false) 99 | expect(isGloby('\\@(a|b|c)')).toBe(false) 100 | 101 | expect(isGloby('!\\(a|b|c)')).toBe(false) 102 | // expect(isGloby('?\\(a|b|c)')).toBe(false) 103 | expect(isGloby('+\\(a|b|c)')).toBe(false) 104 | // expect(isGloby('*\\(a|b|c)')).toBe(false) 105 | expect(isGloby('@\\(a|b|c)')).toBe(false) 106 | 107 | expect(isGloby('!(a|b|c\\)')).toBe(false) 108 | // expect(isGloby('?(a|b|c\\)')).toBe(false) 109 | expect(isGloby('+(a|b|c\\)')).toBe(false) 110 | // expect(isGloby('*(a|b|c\\)')).toBe(false) 111 | expect(isGloby('@(a|b|c\\)')).toBe(false) 112 | }) 113 | 114 | it('should not match empty glob exclusion', () => { 115 | expect(isGloby('!')).toBe(false) 116 | }) 117 | 118 | it('should not match escaped glob exclusion', () => { 119 | expect(isGloby('\\!')).toBe(false) 120 | expect(isGloby('\\!foo')).toBe(false) 121 | expect(isGloby('\\!foo/bar')).toBe(false) 122 | }) 123 | 124 | it('should not match non-globs', () => { 125 | expect(isGloby('abc.js')).toBe(false) 126 | expect(isGloby('abc/def/ghi.js')).toBe(false) 127 | expect(isGloby('foo.js')).toBe(false) 128 | expect(isGloby('abc/@.js')).toBe(false) 129 | expect(isGloby('abc/+.js')).toBe(false) 130 | expect(isGloby()).toBe(false) 131 | expect(isGloby(null)).toBe(false) 132 | }) 133 | }) 134 | }) 135 | -------------------------------------------------------------------------------- /test/lib/resolve-target.spec.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | 3 | import resolveTarget from '../../src/lib/resolve-target' 4 | 5 | describe('lib/resolve-target', () => { 6 | it('should resolve targets', () => { 7 | const bases = [ 8 | 'test', 9 | 'test/mock', 10 | 'test/mock/foo', 11 | 'test/mock/bar', 12 | 'test/mock/@org', 13 | ] 14 | const resolveTargetFromBases = resolveTarget(bases) 15 | const target = 'dist' 16 | 17 | expect(resolveTargetFromBases('test/a.txt', target)).toEqual(path.normalize('dist/a.txt')) 18 | expect(resolveTargetFromBases('test/lib/a.txt', target)).toEqual(path.normalize('dist/lib/a.txt')) 19 | expect(resolveTargetFromBases('test/mock/a.txt', target)).toEqual(path.normalize('dist/a.txt')) 20 | expect(resolveTargetFromBases('test/mock/lib/a.txt', target)).toEqual(path.normalize('dist/lib/a.txt')) 21 | expect(resolveTargetFromBases('test/mock/foo/a.txt', target)).toEqual(path.normalize('dist/a.txt')) 22 | expect(resolveTargetFromBases('test/mock/foo/lib/a.txt', target)).toEqual(path.normalize('dist/lib/a.txt')) 23 | expect(resolveTargetFromBases('test/mock/bar/a.txt', target)).toEqual(path.normalize('dist/a.txt')) 24 | expect(resolveTargetFromBases('test/mock/bar/lib/a.txt', target)).toEqual(path.normalize('dist/lib/a.txt')) 25 | expect(resolveTargetFromBases('test/mock/@org/a.txt', target)).toEqual(path.normalize('dist/a.txt')) 26 | expect(resolveTargetFromBases('test/mock/@org/lib/a.txt', target)).toEqual(path.normalize('dist/lib/a.txt')) 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /test/lib/sources-bases.spec.js: -------------------------------------------------------------------------------- 1 | import { sep } from 'path' 2 | import sourcesBases from '../../src/lib/sources-bases' 3 | 4 | describe('lib/sources-bases', () => { 5 | it('should resolve the base path of globs', () => { 6 | expect(sourcesBases('*')).toEqual(['']) 7 | expect(sourcesBases('test/mock/*.txt')).toEqual([`test${sep}mock`]) 8 | expect(sourcesBases('test/mock/a*txt')).toEqual([`test${sep}mock`]) 9 | 10 | expect(sourcesBases('test/**/*.txt')).toEqual(['test']) 11 | 12 | expect(sourcesBases('test/mock/?.txt')).toEqual([`test${sep}mock`]) 13 | expect(sourcesBases('test/mock/a?txt')).toEqual([`test${sep}mock`]) 14 | 15 | expect(sourcesBases('test/mock/{@org,foo}/.txt')).toEqual([`test${sep}mock`]) 16 | 17 | expect(sourcesBases('test/[a-z]')).toEqual(['test']) 18 | expect(sourcesBases('test/[!a-z]')).toEqual(['test']) 19 | expect(sourcesBases('test/[^a-z]')).toEqual(['test']) 20 | 21 | expect(sourcesBases('test/!(a|b|c)')).toEqual(['test']) 22 | expect(sourcesBases('test/?(a|b|c)')).toEqual(['test']) 23 | expect(sourcesBases('test/+(a|b|c)')).toEqual(['test']) 24 | expect(sourcesBases('test/*(a|b|c)')).toEqual(['test']) 25 | expect(sourcesBases('test/@(a|b|c)')).toEqual(['test']) 26 | }) 27 | 28 | it('should resolve the base path of files', () => { 29 | expect(sourcesBases('test/mock/a.txt')).toEqual([`test${sep}mock`]) 30 | expect(sourcesBases('test/mock/@org/a.txt')).toEqual([`test${sep}mock${sep}@org`]) 31 | }) 32 | 33 | it('should leave directories unchanged', () => { 34 | expect(sourcesBases('test/mock')).toEqual([`test${sep}mock`]) 35 | expect(sourcesBases('test/mock/')).toEqual([`test${sep}mock`]) 36 | expect(sourcesBases('test/mock/@org')).toEqual([`test${sep}mock${sep}@org`]) 37 | expect(sourcesBases('test/mock/@org/')).toEqual([`test${sep}mock${sep}@org`]) 38 | }) 39 | 40 | it('should ignore exclude patterns', () => { 41 | expect(sourcesBases('!test/mock/*.txt')).toEqual([]) 42 | }) 43 | 44 | it('should list multiple distinct base paths', () => { 45 | expect(sourcesBases([ 46 | 'test/mock/a.txt', 47 | 'test/mock/bar/c.txt', 48 | 'test/mock/@org', 49 | 'test/mock/foo/*.txt', 50 | ])).toEqual([ 51 | `test${sep}mock`, 52 | `test${sep}mock${sep}bar`, 53 | `test${sep}mock${sep}@org`, 54 | `test${sep}mock${sep}foo`, 55 | ]) 56 | }) 57 | 58 | it('should list common base baths no more than once', () => { 59 | expect(sourcesBases(['test/mock/a.txt', 'test/mock/b.txt'])).toEqual([`test${sep}mock`]) 60 | expect(sourcesBases(['test/mock', 'test/mock/'])).toEqual([`test${sep}mock`]) 61 | expect(sourcesBases(['test/*.txt', 'test/*.txt'])).toEqual(['test']) 62 | }) 63 | }) 64 | -------------------------------------------------------------------------------- /test/lib/trim-quotes.spec.js: -------------------------------------------------------------------------------- 1 | import trimQuotes from '../../src/lib/trim-quotes' 2 | 3 | describe('lib/trim-quotes', () => { 4 | it('should trim single quotes', () => { 5 | expect(trimQuotes("'foo'")).toBe('foo') 6 | }) 7 | 8 | it('should trim double quotes', () => { 9 | expect(trimQuotes('"foo"')).toBe('foo') 10 | }) 11 | 12 | it('should not remove quotes within text', () => { 13 | expect(trimQuotes('foo"bar')).toBe('foo"bar') 14 | expect(trimQuotes("foo'bar")).toBe("foo'bar") 15 | }) 16 | }) 17 | -------------------------------------------------------------------------------- /test/mock/@org/a.txt: -------------------------------------------------------------------------------- 1 | a 2 | -------------------------------------------------------------------------------- /test/mock/@org/b.txt: -------------------------------------------------------------------------------- 1 | b 2 | -------------------------------------------------------------------------------- /test/mock/@org/d.txt: -------------------------------------------------------------------------------- 1 | d 2 | -------------------------------------------------------------------------------- /test/mock/a.txt: -------------------------------------------------------------------------------- 1 | a 2 | -------------------------------------------------------------------------------- /test/mock/b.txt: -------------------------------------------------------------------------------- 1 | b 2 | -------------------------------------------------------------------------------- /test/mock/bar/c.txt: -------------------------------------------------------------------------------- 1 | c 2 | -------------------------------------------------------------------------------- /test/mock/foo space/a.txt: -------------------------------------------------------------------------------- 1 | a 2 | -------------------------------------------------------------------------------- /test/mock/foo space/b.txt: -------------------------------------------------------------------------------- 1 | b 2 | -------------------------------------------------------------------------------- /test/mock/foo/b.txt: -------------------------------------------------------------------------------- 1 | b 2 | -------------------------------------------------------------------------------- /test/mock/foo/d.txt: -------------------------------------------------------------------------------- 1 | d 2 | -------------------------------------------------------------------------------- /test/mock/transform.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | module.exports = function transform(data, target) { 3 | console.log('transform %s', target) 4 | 5 | data += '\n\nTransformed file' 6 | target = target.replace(/b\.txt$/, 'b-replaced.txt') 7 | 8 | return { 9 | data: data, 10 | target: target, 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /test/sync.spec.js: -------------------------------------------------------------------------------- 1 | import syncGlob from '../src/index' 2 | import { beforeEachSpec, afterAllSpecs, awaitMatch, compare, compareDir, fs } from './helpers' 3 | 4 | const watch = true 5 | 6 | describe('node-sync-glob watch', () => { 7 | beforeEach(beforeEachSpec) 8 | afterAll(afterAllSpecs) 9 | 10 | it('should sync a file', (done) => { 11 | const close = syncGlob('tmp/mock/a.txt', 'tmp/sync/', { watch }, awaitMatch( 12 | 'error', (err) => { 13 | fail(err) 14 | close() 15 | done() 16 | }, 17 | ['watch', 'mirror'], () => { 18 | fs.appendFileSync('tmp/mock/a.txt', 'foobarbaz') 19 | }, 20 | 'copy', compare(() => { 21 | fs.removeSync('tmp/mock/a.txt') 22 | }), 23 | 'remove', () => { 24 | expect(fs.existsSync('tmp/sync/a.txt')).toBe(false) 25 | close() 26 | done() 27 | } 28 | )) 29 | }) 30 | 31 | it('should sync an array of files', (done) => { 32 | const close = syncGlob(['tmp/mock/a.txt', 'tmp/mock/b.txt'], 'tmp/sync', { watch }, awaitMatch( 33 | 'error', (err) => { 34 | fail(err) 35 | close() 36 | done() 37 | }, 38 | ['watch', 'mirror'], () => { 39 | fs.appendFileSync('tmp/mock/b.txt', 'foobarbaz') 40 | }, 41 | 'copy', compare(() => { 42 | fs.removeSync('tmp/mock/b.txt') 43 | }), 44 | 'remove', () => { 45 | expect(fs.existsSync('tmp/sync/a.txt')).toBe(true) 46 | expect(fs.existsSync('tmp/sync/b.txt')).toBe(false) 47 | close() 48 | done() 49 | } 50 | )) 51 | }) 52 | 53 | it('should sync a directory', (done) => { 54 | const close = syncGlob('tmp/mock/foo', 'tmp/sync/', { watch }, awaitMatch( 55 | 'error', (err) => { 56 | fail(err) 57 | close() 58 | done() 59 | }, 60 | ['watch', 'mirror'], () => { 61 | fs.appendFileSync('tmp/mock/foo/b.txt', 'foobarbaz') 62 | }, 63 | 'copy', compareDir(() => { 64 | fs.removeSync('tmp/mock/foo/d.txt') 65 | }, 'tmp/mock/foo', 'tmp/sync'), 66 | 'remove', () => { 67 | expect(fs.existsSync('tmp/sync/b.txt')).toBe(true) 68 | expect(fs.existsSync('tmp/sync/d.txt')).toBe(false) 69 | close() 70 | done() 71 | } 72 | )) 73 | }) 74 | 75 | it('should sync globstar', (done) => { 76 | const close = syncGlob('tmp/mock/**/*', 'tmp/sync', { watch }, awaitMatch( 77 | 'error', (err) => { 78 | fail(err) 79 | close() 80 | done() 81 | }, 82 | ['mirror', 'watch'], compareDir(() => { 83 | fs.appendFileSync('tmp/mock/foo/b.txt', 'foobarbaz') 84 | }, 'tmp/mock', 'tmp/sync'), 85 | 'copy', compareDir(() => { 86 | fs.removeSync('tmp/mock/foo/d.txt') 87 | }, 'tmp/mock', 'tmp/sync'), 88 | 'remove', () => { 89 | expect(fs.existsSync('tmp/sync/foo/b.txt')).toBe(true) 90 | expect(fs.existsSync('tmp/sync/foo/d.txt')).toBe(false) 91 | close() 92 | done() 93 | } 94 | )) 95 | }) 96 | }) 97 | -------------------------------------------------------------------------------- /test/transform.spec.js: -------------------------------------------------------------------------------- 1 | import syncGlob from '../src/index' 2 | import { beforeEachSpec, afterAllSpecs, awaitMatch, fs } from './helpers' 3 | 4 | describe('node-sync-glob transform', () => { 5 | beforeEach(beforeEachSpec) 6 | afterAll(afterAllSpecs) 7 | 8 | it('should transform a file', (done) => { 9 | const close = syncGlob('tmp/mock/b.txt', 'tmp/trans', { transform: 'test/mock/transform.js' }, awaitMatch( 10 | 'error', (err) => { 11 | fail(err) 12 | close() 13 | done() 14 | }, 15 | 'mirror', () => { 16 | expect(fs.existsSync('tmp/trans/b.txt')).toBe(false) 17 | expect(fs.existsSync('tmp/trans/b-replaced.txt')).toBe(true) 18 | 19 | expect(`${fs.readFileSync('tmp/mock/b.txt')}\n\nTransformed file`).toEqual(`${fs.readFileSync('tmp/trans/b-replaced.txt')}`) 20 | 21 | close() 22 | done() 23 | } 24 | )) 25 | }) 26 | }) 27 | --------------------------------------------------------------------------------