├── fixtures ├── test.txt ├── test-2.txt ├── folder-1 │ ├── test-2.txt │ ├── test.txt │ └── nested │ │ ├── test.txt │ │ └── test-2.txt └── folder-2 │ ├── test-2.txt │ └── test.txt ├── .npmrc ├── .gitattributes ├── .eslintrc.json ├── src ├── types │ └── patterns.ts ├── utils │ ├── path.spec.ts │ ├── fs.ts │ └── path.ts ├── managers │ ├── options.ts │ ├── options.spec.ts │ ├── log.ts │ └── log.spec.ts ├── syncy.ts └── syncy.spec.ts ├── index.js ├── .vsts ├── steps-tests.yml ├── steps-build.yml └── build.yml ├── .gitignore ├── .npmignore ├── .editorconfig ├── tsconfig.json ├── LICENSE ├── package.json └── README.md /fixtures/test.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fixtures/test-2.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fixtures/folder-1/test-2.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fixtures/folder-1/test.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fixtures/folder-2/test-2.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fixtures/folder-2/test.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /fixtures/folder-1/nested/test.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /fixtures/folder-1/nested/test-2.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "mrmlnc" 3 | } 4 | -------------------------------------------------------------------------------- /src/types/patterns.ts: -------------------------------------------------------------------------------- 1 | export type Pattern = string; 2 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const syncy = require('./out/syncy').default; 2 | module.exports = syncy; 3 | module.exports.default = syncy; 4 | -------------------------------------------------------------------------------- /.vsts/steps-tests.yml: -------------------------------------------------------------------------------- 1 | steps: 2 | - script: npm run lint 3 | displayName: Run Hygiene Checks 4 | 5 | - script: npm run test 6 | displayName: Run Unit Tests 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs/ 3 | *.log 4 | npm-debug.log* 5 | 6 | # IDE & editors 7 | .idea 8 | .vscode 9 | 10 | # Dependency directory 11 | node_modules/ 12 | 13 | # Compiled and temporary files 14 | .eslintcache 15 | .tmp/ 16 | out/ 17 | fixtures/ 18 | 19 | # Other files 20 | package-lock.json 21 | yarn.lock 22 | -------------------------------------------------------------------------------- /.vsts/steps-build.yml: -------------------------------------------------------------------------------- 1 | steps: 2 | - task: NodeTool@0 3 | inputs: 4 | versionSpec: '${{parameters.node_version}}' 5 | 6 | - script: node --version && npm --version 7 | displayName: Environment information 8 | 9 | - task: Npm@1 10 | displayName: Install dependencies 11 | inputs: 12 | command: install 13 | 14 | - script: npm run compile 15 | displayName: Compile Sources 16 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Common files 2 | .editorconfig 3 | .gitattributes 4 | .gitignore 5 | .vsts 6 | tsconfig.json 7 | .eslintrc.json 8 | 9 | # Log & Cache files 10 | .eslintcache 11 | 12 | # Output files 13 | out/tests 14 | out/**/*.js.map 15 | out/**/*.spec.{d.ts,js} 16 | 17 | # Editor directories 18 | .vscode 19 | 20 | # Other directories 21 | .tmp 22 | .github 23 | fixtures 24 | src 25 | node_modules 26 | typings 27 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = tab 6 | indent_size = 4 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [{.travis.yml,circle.yml,appveyor.yml,.vsts/**}] 13 | indent_style = space 14 | indent_size = 2 15 | 16 | [{npm-shrinkwrap.json,package-lock.json,package.json}] 17 | indent_style = space 18 | indent_size = 2 19 | -------------------------------------------------------------------------------- /src/utils/path.spec.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'assert'; 2 | 3 | import * as util from './path'; 4 | 5 | describe('Utils → Path', () => { 6 | it('normalizePath', () => { 7 | assert.ok(!util.normalizePath('test\\file.js').includes('\\')); 8 | }); 9 | 10 | it('pathFromDestToSource', () => { 11 | assert.strictEqual(util.pathFromDestinationToSource('file.js', 'dest'), 'dest/file.js'); 12 | }); 13 | 14 | it('pathFromSourceToDest', () => { 15 | assert.strictEqual(util.pathFromSourceToDestination('src/file.js', 'dest', 'src'), 'dest/file.js'); 16 | }); 17 | 18 | it('expandDirectoryTree', () => { 19 | const tree = util.expandDirectoryTree('src/files/**/*.js'); 20 | 21 | assert.ok(tree.includes('src')); 22 | assert.ok(tree.includes('src/files')); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | 7 | "rootDir": "src", 8 | "outDir": "out", 9 | 10 | "strict": true, 11 | "alwaysStrict": true, 12 | "strictFunctionTypes": true, 13 | "strictNullChecks": true, 14 | "strictPropertyInitialization": true, 15 | 16 | "forceConsistentCasingInFileNames": true, 17 | "noImplicitAny": true, 18 | "noImplicitReturns": true, 19 | "noImplicitThis": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | 24 | "emitDecoratorMetadata": true, 25 | "experimentalDecorators": true, 26 | "downlevelIteration": true, 27 | "declaration": true, 28 | 29 | "pretty": true 30 | }, 31 | "include": [ 32 | "src/**/*" 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /src/managers/options.ts: -------------------------------------------------------------------------------- 1 | import { Pattern } from '../types/patterns'; 2 | import { Log } from './log'; 3 | 4 | export type Options = { 5 | /** 6 | * Display log messages when copying and removing files. 7 | */ 8 | verbose: boolean | Log; 9 | /** 10 | * The base path to be removed from the path. 11 | */ 12 | base: string; 13 | /** 14 | * Remove all files from `dest` that are not found in `src`. 15 | */ 16 | updateAndDelete: boolean; 17 | /** 18 | * Never remove specified files from destination directory. 19 | */ 20 | ignoreInDest: Pattern[]; 21 | }; 22 | 23 | export type PartialOptions = Partial; 24 | 25 | export function prepare(options?: PartialOptions): Options { 26 | return { 27 | verbose: false, 28 | base: '', 29 | updateAndDelete: true, 30 | ignoreInDest: [], 31 | ...options 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /src/managers/options.spec.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'assert'; 2 | 3 | import * as manager from './options'; 4 | 5 | describe('Managers → Options', () => { 6 | describe('.prepare', () => { 7 | it('should returns builded options for empty object', () => { 8 | const expected: manager.Options = { 9 | verbose: false, 10 | base: '', 11 | updateAndDelete: true, 12 | ignoreInDest: [] 13 | }; 14 | 15 | const actual = manager.prepare(); 16 | 17 | assert.deepStrictEqual(actual, expected); 18 | }); 19 | 20 | it('should returns builded options for provided object', () => { 21 | const expected: manager.Options = { 22 | verbose: false, 23 | base: 'base', 24 | updateAndDelete: true, 25 | ignoreInDest: [] 26 | }; 27 | 28 | const actual = manager.prepare({ base: 'base' }); 29 | 30 | assert.deepStrictEqual(actual, expected); 31 | }); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Denis Malinochkin 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 | -------------------------------------------------------------------------------- /src/utils/fs.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as fs from 'fs'; 4 | 5 | import * as mkpath from 'mkpath'; 6 | import * as rimraf from 'rimraf'; 7 | 8 | export function pathExists(filepath: string): Promise { 9 | return new Promise((resolve) => { 10 | fs.access(filepath, (error) => resolve(error === null)); 11 | }); 12 | } 13 | 14 | export function statFile(filepath: string): Promise { 15 | return new Promise((resolve, reject) => { 16 | fs.stat(filepath, (error, stats) => { 17 | if (error !== null) { 18 | return reject(error); 19 | } 20 | 21 | resolve(stats); 22 | }); 23 | }); 24 | } 25 | 26 | export function makeDirectory(filepath: string): Promise { 27 | return new Promise((resolve, reject) => { 28 | mkpath(filepath, (error) => { 29 | if (error !== null) { 30 | return reject(error); 31 | } 32 | 33 | resolve(); 34 | }); 35 | }); 36 | } 37 | 38 | export function removeFile(filepath: string, options: rimraf.Options): Promise { 39 | return new Promise((resolve, reject) => { 40 | rimraf(filepath, options, (error) => { 41 | if (error !== null) { 42 | return reject(error); 43 | } 44 | 45 | resolve(); 46 | }); 47 | }); 48 | } 49 | -------------------------------------------------------------------------------- /src/managers/log.ts: -------------------------------------------------------------------------------- 1 | import { Options } from './options'; 2 | 3 | export type LogEntry = { 4 | action: 'copy' | 'remove'; 5 | from: string; 6 | to?: string; 7 | }; 8 | 9 | export type Log = (entry: LogEntry) => void; 10 | 11 | export default class LogManager { 12 | private readonly _logger: Log; 13 | 14 | constructor(private readonly _options: Options) { 15 | this._logger = this._getLogger(); 16 | } 17 | 18 | public info(entry: LogEntry): void { 19 | this._logger(entry); 20 | } 21 | 22 | public log(message: string): void { 23 | console.log(message); 24 | } 25 | 26 | private _getLogger(): Log { 27 | if (this._options.verbose === true) { 28 | return this._defaultLogger; 29 | } 30 | 31 | if (typeof this._options.verbose === 'function') { 32 | return this._options.verbose; 33 | } 34 | 35 | return () => undefined; 36 | } 37 | 38 | private _defaultLogger(entry: LogEntry): void { 39 | const message = this._formatMessage(entry); 40 | 41 | this.log(message); 42 | } 43 | 44 | private _formatMessage(entry: LogEntry): string { 45 | if (entry.action === 'remove') { 46 | return `Removing: ${entry.from}`; 47 | } 48 | 49 | return `Copying: ${entry.from} -> ${entry.to}`; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /.vsts/build.yml: -------------------------------------------------------------------------------- 1 | trigger: 2 | branches: 3 | include: 4 | - master 5 | - releases/* 6 | 7 | jobs: 8 | - job: Build 9 | strategy: 10 | matrix: 11 | UBUNTU_NODE_8: 12 | IMAGE_TYPE: 'ubuntu-latest' 13 | NODE_VERSION: 8.x 14 | UBUNTU_NODE_10: 15 | IMAGE_TYPE: 'ubuntu-latest' 16 | NODE_VERSION: 10.x 17 | UBUNTU_NODE_12: 18 | IMAGE_TYPE: 'ubuntu-latest' 19 | NODE_VERSION: 12.x 20 | 21 | WINDOWS_NODE_8: 22 | IMAGE_TYPE: 'windows-latest' 23 | NODE_VERSION: 8.x 24 | WINDOWS_NODE_10: 25 | IMAGE_TYPE: 'windows-latest' 26 | NODE_VERSION: 10.x 27 | WINDOWS_NODE_12: 28 | IMAGE_TYPE: 'windows-latest' 29 | NODE_VERSION: 12.x 30 | 31 | MACOS_NODE_8: 32 | IMAGE_TYPE: 'macOS-latest' 33 | NODE_VERSION: 8.x 34 | MACOS_NODE_10: 35 | IMAGE_TYPE: 'macOS-latest' 36 | NODE_VERSION: 10.x 37 | MACOS_NODE_12: 38 | IMAGE_TYPE: 'macOS-latest' 39 | NODE_VERSION: 12.x 40 | pool: 41 | vmImage: $(IMAGE_TYPE) 42 | steps: 43 | - template: steps-build.yml 44 | parameters: 45 | node_version: $(NODE_VERSION) 46 | - template: steps-tests.yml 47 | -------------------------------------------------------------------------------- /src/utils/path.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | 3 | export function normalizePath(filepath: string): string { 4 | return filepath.replace(/\\/g, '/'); 5 | } 6 | 7 | /** 8 | * Processing path to the source directory from destination directory 9 | */ 10 | export function pathFromDestinationToSource(destinationPath: string, basePath?: string): string { 11 | let filepath = destinationPath; 12 | 13 | if (basePath !== undefined) { 14 | filepath = path.join(basePath, destinationPath); 15 | } 16 | 17 | return normalizePath(filepath); 18 | } 19 | 20 | /** 21 | * Processing path to the destination directory from source directory 22 | */ 23 | export function pathFromSourceToDestination(sourcePath: string, destinationPath: string, basePath?: string): string { 24 | let filepath = sourcePath; 25 | 26 | if (basePath !== undefined) { 27 | filepath = path.relative(basePath, sourcePath); 28 | } 29 | 30 | return normalizePath(path.join(destinationPath, filepath)); 31 | } 32 | 33 | /** 34 | * Expanding of the directories in path 35 | */ 36 | export function expandDirectoryTree(filepath: string): string[] { 37 | const directories = filepath.split('/'); 38 | const tree = [directories[0]]; 39 | 40 | directories.reduce((sum, current) => { 41 | const next = normalizePath(path.join(sum, current)); 42 | 43 | tree.push(next); 44 | 45 | return next; 46 | }); 47 | 48 | return tree; 49 | } 50 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "syncy", 3 | "version": "2.2.2", 4 | "description": "One-way synchronization of directories with glob", 5 | "license": "MIT", 6 | "repository": "mrmlnc/syncy", 7 | "author": { 8 | "name": "Denis Malinochkin", 9 | "url": "https://mrmlnc.com" 10 | }, 11 | "engines": { 12 | "node": ">=8.0.0" 13 | }, 14 | "main": "index.js", 15 | "typings": "./out/syncy.d.ts", 16 | "devDependencies": { 17 | "@nodelib/fs.macchiato": "^1.0.2", 18 | "@types/glob-parent": "^3.1.1", 19 | "@types/minimatch": "^3.0.3", 20 | "@types/mkpath": "^0.1.29", 21 | "@types/mocha": "^2.2.48", 22 | "@types/node": "^10.17.5", 23 | "@types/recursive-readdir": "^2.2.0", 24 | "@types/rimraf": "^2.0.2", 25 | "eslint": "^6.6.0", 26 | "eslint-config-mrmlnc": "^1.0.3", 27 | "mocha": "^5.0.0", 28 | "recursive-readdir": "^2.2.2", 29 | "typescript": "~3.6.3" 30 | }, 31 | "dependencies": { 32 | "cp-file": "7.0.0", 33 | "fast-glob": "^2.0.2", 34 | "glob-parent": "^3.1.0", 35 | "minimatch": "3.0.4", 36 | "mkpath": "1.0.0", 37 | "rimraf": "2.6.2" 38 | }, 39 | "scripts": { 40 | "clean": "rimraf out", 41 | "lint": "eslint \"src/**/*.ts\" --cache", 42 | "compile": "tsc", 43 | "test": "mocha \"out/**/*.spec.js\" -s 0", 44 | "build": "npm run clean && npm run compile && npm run lint && npm test", 45 | "watch": "npm run clean && npm run compile -- --sourceMap --watch" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/managers/log.spec.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'assert'; 2 | 3 | import LogManager, { LogEntry } from './log'; 4 | import * as optionsManager from './options'; 5 | 6 | type Options = optionsManager.Options; 7 | type PartialOptions = optionsManager.PartialOptions; 8 | 9 | class FakeLogger extends LogManager { 10 | public lastMessage?: string = undefined; 11 | 12 | public log(message: string): void { 13 | this.lastMessage = message; 14 | } 15 | } 16 | 17 | function getLogger(options?: PartialOptions): FakeLogger { 18 | const preparedOptions: Options = optionsManager.prepare(options); 19 | 20 | return new FakeLogger(preparedOptions); 21 | } 22 | 23 | function getLogEntry(entry?: Partial): LogEntry { 24 | return { 25 | action: 'copy', 26 | from: 'from', 27 | to: 'to', 28 | ...entry 29 | }; 30 | } 31 | 32 | describe('Managers → Logger', () => { 33 | describe('Constructor', () => { 34 | it('should create instance of class', () => { 35 | const logger = getLogger(); 36 | 37 | assert.ok(logger instanceof LogManager); 38 | }); 39 | }); 40 | 41 | describe('.info', () => { 42 | it('should do nothing when the «verbose» option is disabled', () => { 43 | const logger = getLogger({ verbose: false }); 44 | 45 | const expected = undefined; 46 | 47 | const logEntry = getLogEntry(); 48 | 49 | logger.info(logEntry); 50 | 51 | const actual = logger.lastMessage; 52 | 53 | assert.strictEqual(actual, expected); 54 | }); 55 | 56 | it('should do use default logger with «copy» action when the «verbose» option is enabled', () => { 57 | const logger = getLogger({ verbose: true }); 58 | 59 | const expected = 'Copying: from -> to'; 60 | 61 | const logEntry = getLogEntry(); 62 | 63 | logger.info(logEntry); 64 | 65 | const actual = logger.lastMessage; 66 | 67 | assert.strictEqual(actual, expected); 68 | }); 69 | 70 | it('should do use default logger with «remove» action when the «verbose» option is enabled', () => { 71 | const logger = getLogger({ verbose: true }); 72 | 73 | const expected = 'Removing: from'; 74 | 75 | const logEntry = getLogEntry({ action: 'remove' }); 76 | 77 | logger.info(logEntry); 78 | 79 | const actual = logger.lastMessage; 80 | 81 | assert.strictEqual(actual, expected); 82 | }); 83 | 84 | it('should do use custom logger when the «verbose» option is function', () => { 85 | let message: string | undefined; 86 | 87 | const logger = getLogger({ 88 | verbose: (entry) => { 89 | message = entry.action; 90 | } 91 | }); 92 | 93 | const expected = 'copy'; 94 | 95 | const logEntry = getLogEntry(); 96 | 97 | logger.info(logEntry); 98 | 99 | const actual = message; 100 | 101 | assert.strictEqual(actual, expected); 102 | }); 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SyncY 2 | 3 | > One-way synchronization of directories with [glob](https://github.com/isaacs/node-glob). 4 | 5 | [![Build Status](https://travis-ci.org/mrmlnc/syncy.svg?branch=master)](https://travis-ci.org/mrmlnc/syncy) 6 | [![Build status](https://ci.appveyor.com/api/projects/status/wgoewjpky294okam?svg=true)](https://ci.appveyor.com/project/mrmlnc/syncy) 7 | 8 | ## :bulb: Highlights 9 | 10 | * :rocket: Fast by using streams and Promises. Used [cp-file](https://github.com/sindresorhus/cp-file) and [rimraf](https://github.com/isaacs/rimraf). 11 | * :beginner: User-friendly by accepting globs. 12 | 13 | ## Donate 14 | 15 | If you want to thank me, or promote your Issue. 16 | 17 | [![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://paypal.me/mrmlnc) 18 | 19 | > Sorry, but I have work and support for packages requires some time after work. I will be glad of your support and PR's. 20 | 21 | ## Install 22 | 23 | ``` 24 | $ npm install --save syncy 25 | ``` 26 | 27 | ## Usage 28 | 29 | ```js 30 | const syncy = require('syncy'); 31 | 32 | syncy(['src/**', '!src/folder/**'], 'dest') 33 | .then(() => { 34 | console.log('Done!'); 35 | }) 36 | .catch(console.error); 37 | ``` 38 | 39 | ## API 40 | 41 | ``` 42 | syncy(patterns, dest, [options]) 43 | ``` 44 | 45 | #### patterns 46 | 47 | * Type: `string|string[]` 48 | 49 | Glob patterns that represent files to copy 50 | 51 | #### dest 52 | 53 | * Type: `string|string[]` 54 | 55 | Destination directory or directories. 56 | 57 | #### options 58 | 59 | * Type: `object` 60 | 61 | ```js 62 | { 63 | // Display log messages when copying and removing files 64 | verbose: false, 65 | // Or create your own function. 66 | verbose: (stamp) { 67 | // action - `copy` or `remove` 68 | // to - only for `copy` action 69 | console.log(stamp.action + ' | ' + stamp.from + ' | ' + stamp.to); 70 | }, 71 | // The base path to be removed from the path. Default: none 72 | base: 'base_path' 73 | // Remove all files from dest that are not found in src. Default: true 74 | updateAndDelete: true, 75 | // Never remove js files from destination. Default: false 76 | ignoreInDest: ['**/*.js'] 77 | } 78 | ``` 79 | 80 | ## How to work with Gulp? 81 | 82 | ```js 83 | const gulp = require('gulp'); 84 | const syncy = require('syncy'); 85 | 86 | gulp.task('sync', (done) => { 87 | syncy(['node_modules/gulp/**'], 'dest') 88 | .then(() => { 89 | done(); 90 | }) 91 | .catch((err) => { 92 | done(err); 93 | }); 94 | }); 95 | ``` 96 | 97 | ## How to work with Grunt? 98 | 99 | ```js 100 | const syncy = require('syncy'); 101 | 102 | module.exports = (grunt) => { 103 | // Default task(s). 104 | grunt.registerTask('default', function() { 105 | const done = this.async(); 106 | syncy(['node_modules/grunt/**'], 'dest') 107 | .then(() => { 108 | done(); 109 | }) 110 | .catch((err) => { 111 | done(err); 112 | }); 113 | }); 114 | }; 115 | ``` 116 | 117 | ## Tests 118 | 119 | **Tech specs**: 120 | 121 | * Intel Core i7-3610QM 122 | * RAM 8GB 123 | * SSD (555MB/S, 530MB/S) 124 | * Windows 10 125 | * Node.js v6.4.0 126 | 127 | **Files**: [AngularJS](https://github.com/angular/angular.js/releases/tag/v1.6.0-rc.1) from release v1.6.0-rc.1. 128 | 129 | **Note**: `UpdateAndDelete` option is enabled in the grunt-sync, because other plugins have this option initially. 130 | 131 | | Description of tests | syncy | gulp-directory-sync | grunt-sync | 132 | |------------------------------------------------------|-------|---------------------|------------| 133 | | First run | 3,7s | 9,1s | 10,1s | 134 | | Re-run | 0,7s | 1,0s | 0,8s | 135 | | Delete single file from dest directory | 0,7s | 0,9s | 0,8s | 136 | 137 | 138 | ## Changelog 139 | 140 | See the [Releases section of our GitHub project](https://github.com/mrmlnc/syncy/releases) for changelogs for each release version. 141 | 142 | ## License 143 | 144 | This software is released under the terms of the MIT license. 145 | -------------------------------------------------------------------------------- /src/syncy.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as fs from 'fs'; 4 | 5 | import * as cpf from 'cp-file'; 6 | import * as fg from 'fast-glob'; 7 | import * as minimatch from 'minimatch'; 8 | 9 | import LogManager, { Log } from './managers/log'; 10 | import * as optionsManager from './managers/options'; 11 | import { Pattern } from './types/patterns'; 12 | import * as fsUtils from './utils/fs'; 13 | import * as pathUtils from './utils/path'; 14 | 15 | // eslint-disable-next-line @typescript-eslint/no-require-imports 16 | import globParent = require('glob-parent'); 17 | 18 | type Options = optionsManager.Options; 19 | type PartialOptions = optionsManager.PartialOptions; 20 | 21 | /** 22 | * The reason to not update the file 23 | */ 24 | export function skipUpdate(source: fs.Stats, destination: fs.Stats | null, updateAndDelete: boolean): boolean { 25 | if (destination !== null && !updateAndDelete) { 26 | return true; 27 | } 28 | 29 | if (source.isDirectory()) { 30 | return true; 31 | } 32 | 33 | if (destination !== null && compareTime(source, destination)) { 34 | return true; 35 | } 36 | 37 | return false; 38 | } 39 | 40 | /** 41 | * Compare update time of two files 42 | */ 43 | export function compareTime(source: fs.Stats, destination: fs.Stats): boolean { 44 | return source.ctime.getTime() < destination.ctime.getTime(); 45 | } 46 | 47 | export function getDestinationEntries(destination: string): Promise { 48 | return fg('**', { cwd: destination, dot: true, onlyFiles: false }); 49 | } 50 | 51 | export function getSourceEntries(patterns: Pattern | Pattern[]): Promise { 52 | return fg(patterns, { dot: true, onlyFiles: false }); 53 | } 54 | 55 | /** 56 | * Get all the parts of a file path for excluded paths. 57 | */ 58 | export function getPartsOfExcludedPaths(destinationFiles: string[], options: Options): string[] { 59 | return options.ignoreInDest 60 | .reduce((collection, pattern) => collection.concat(minimatch.match(destinationFiles, pattern, { dot: true })), [] as string[]) 61 | .map((filepath) => pathUtils.pathFromDestinationToSource(filepath, options.base)) 62 | .reduce((collection, filepath) => collection.concat(pathUtils.expandDirectoryTree(filepath)), [] as string[]); 63 | } 64 | 65 | export function getTreeOfBasePaths(patterns: Pattern[]): string[] { 66 | return patterns.reduce((collection, pattern) => { 67 | const parentDirectory = globParent(pattern); 68 | const treePaths = pathUtils.expandDirectoryTree(parentDirectory); 69 | 70 | return collection.concat(treePaths); 71 | }, ['']); 72 | } 73 | 74 | export async function run(patterns: Pattern[], destination: string, sourceFiles: string[], options: Options, log: Log): Promise { 75 | const arrayOfPromises: Array> = []; 76 | 77 | // If destination directory not exists then create it 78 | const isExists = await fsUtils.pathExists(destination); 79 | if (!isExists) { 80 | await fsUtils.makeDirectory(destination); 81 | } 82 | 83 | // Get files from destination directory 84 | const destinationFiles = await getDestinationEntries(destination); 85 | const partsOfExcludedFiles = getPartsOfExcludedPaths(destinationFiles, options); 86 | 87 | // Removing files from the destination directory 88 | if (options.updateAndDelete) { 89 | // Create a full list of the basic directories 90 | const treeOfBasePaths = getTreeOfBasePaths(patterns); 91 | const fullSourcePaths = sourceFiles.concat(treeOfBasePaths, partsOfExcludedFiles); 92 | 93 | // Deleting files 94 | for (const destinationFile of destinationFiles) { 95 | // To files in the source directory are added paths to basic directories 96 | const pathFromDestinationToSource = pathUtils.pathFromDestinationToSource(destinationFile, options.base); 97 | 98 | // Search unique files to the destination directory 99 | let skipIteration = false; 100 | for (const fullSourcePath of fullSourcePaths) { 101 | if (fullSourcePath.includes(pathFromDestinationToSource)) { 102 | skipIteration = true; 103 | break; 104 | } 105 | } 106 | 107 | if (skipIteration) { 108 | continue; 109 | } 110 | 111 | const pathFromSourceToDestination = pathUtils.pathFromSourceToDestination(destinationFile, destination); 112 | const removePromise = fsUtils.removeFile(pathFromSourceToDestination, { disableGlob: true }).then(() => { 113 | log({ 114 | action: 'remove', 115 | from: destinationFile, 116 | to: undefined 117 | }); 118 | }).catch((error) => { 119 | throw new Error(`Cannot remove '${pathFromSourceToDestination}': ${error.code}`); 120 | }); 121 | 122 | arrayOfPromises.push(removePromise); 123 | } 124 | } 125 | 126 | // Copying files 127 | for (const from of sourceFiles) { 128 | const to = pathUtils.pathFromSourceToDestination(from, destination, options.base); 129 | 130 | // Get stats for source & dest file 131 | const statFrom = fsUtils.statFile(from); 132 | const statDestination = fsUtils.statFile(to).catch(() => null); 133 | 134 | const copyAction = Promise.all([statFrom, statDestination]).then((stat) => { 135 | // We should update this file? 136 | if (skipUpdate(stat[0], stat[1], options.updateAndDelete)) { 137 | return undefined; 138 | } 139 | 140 | return cpf(from, to).then(() => { 141 | log({ 142 | from, 143 | to, 144 | action: 'copy' 145 | }); 146 | }).catch((error) => { 147 | throw new Error(`'${from}' to '${to}': ${error.message}`); 148 | }); 149 | }); 150 | 151 | arrayOfPromises.push(copyAction); 152 | } 153 | 154 | return Promise.all(arrayOfPromises); 155 | } 156 | 157 | export default async function syncy(source: Pattern | Pattern[], destination: string | string[], options?: PartialOptions): Promise { 158 | const patterns = ([] as Pattern[]).concat(source); 159 | const destinations = ([] as string[]).concat(destination); 160 | 161 | const preparedOptions = optionsManager.prepare(options); 162 | 163 | const logManager = new LogManager(preparedOptions); 164 | const logger = logManager.info.bind(logManager); 165 | 166 | return getSourceEntries(source).then((sourceFiles) => { 167 | return Promise.all(destinations.map((item) => run(patterns, item, sourceFiles, preparedOptions, logger))); 168 | }); 169 | } 170 | -------------------------------------------------------------------------------- /src/syncy.spec.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as assert from 'assert'; 4 | import * as fs from 'fs'; 5 | import * as path from 'path'; 6 | import * as util from 'util'; 7 | 8 | import * as cpf from 'cp-file'; 9 | import * as recursiveReaddir from 'recursive-readdir'; 10 | import { Stats } from '@nodelib/fs.macchiato'; 11 | 12 | import * as fsUtils from './utils/fs'; 13 | 14 | import syncy, { compareTime, skipUpdate } from './syncy'; 15 | 16 | import { LogEntry } from './managers/log'; 17 | 18 | const writeFile = util.promisify(fs.writeFile); 19 | const readFile = util.promisify(fs.readFile); 20 | 21 | // Creating test files 22 | async function createFiles(filepath: string, count: number): Promise { 23 | await fsUtils.makeDirectory(filepath); 24 | 25 | const promises: Array> = []; 26 | 27 | for (let i = 0; i < count; i++) { 28 | promises.push(writeFile(path.join(filepath, `test-${i}.txt`), 'test')); 29 | } 30 | 31 | await Promise.all(promises); 32 | } 33 | 34 | // Look ma, it's cp -R 35 | async function copyRecursive(source: string, destination: string): Promise { 36 | const files = await recursiveReaddir(source); 37 | 38 | const promises = files.map((filepath: string) => cpf(filepath, path.join(destination, filepath))); 39 | 40 | await Promise.all(promises); 41 | } 42 | 43 | describe('Syncy', () => { 44 | describe('.compareTime', () => { 45 | it('should return true if second date bigger then first', () => { 46 | const left = new Stats({ 47 | ctime: new Date(10 * 1000) 48 | }); 49 | 50 | const right = new Stats({ 51 | ctime: new Date(100 * 1000) 52 | }); 53 | 54 | assert.ok(compareTime(left, right)); 55 | }); 56 | 57 | it('should return false is first date bigger then second', () => { 58 | const left = new Stats({ 59 | ctime: new Date(10 * 1000) 60 | }); 61 | 62 | const right = new Stats({ 63 | ctime: new Date(10 * 1000) 64 | }); 65 | 66 | assert.ok(!compareTime(left, right)); 67 | }); 68 | }); 69 | 70 | describe('.skipUpdate', () => { 71 | it('should return true if source is directory', () => { 72 | const source = new Stats({ 73 | isDirectory: true 74 | }); 75 | 76 | const destination = new Stats(); 77 | 78 | assert.ok(skipUpdate(source, destination, true /* updateAndDelete */)); 79 | }); 80 | 81 | it('should return false if dest is outdated', () => { 82 | const source = new Stats({ 83 | isDirectory: false, 84 | ctime: new Date(100 * 1000) 85 | }); 86 | 87 | const destination = new Stats({ 88 | ctime: new Date(10 * 1000) 89 | }); 90 | 91 | assert.ok(!skipUpdate(source, destination, true /* updateAndDelete */)); 92 | }); 93 | 94 | it('should return true if «updateAndDelete» is disabled', () => { 95 | const source = new Stats(); 96 | const destination = new Stats(); 97 | 98 | assert.ok(skipUpdate(source, destination, false /* updateAndDelete */)); 99 | }); 100 | 101 | it('should return true if «updateAndDelete» is enabled and dest is up-to-date', () => { 102 | const source = new Stats({ 103 | isDirectory: false, 104 | ctime: new Date(10 * 1000) 105 | }); 106 | 107 | const destination = new Stats({ 108 | ctime: new Date(100 * 1000) 109 | }); 110 | 111 | assert.ok(skipUpdate(source, destination, true /* updateAndDelete */)); 112 | }); 113 | 114 | it('should return true if dest is up-to-date', () => { 115 | const source = new Stats({ 116 | isDirectory: false, 117 | ctime: new Date(10 * 1000) 118 | }); 119 | 120 | const destination = new Stats({ 121 | ctime: new Date(100 * 1000) 122 | }); 123 | 124 | assert.ok(skipUpdate(source, destination, true /* updateAndDelete */)); 125 | }); 126 | }); 127 | 128 | describe('Basic tests', () => { 129 | it('basic-0: Should create destination directory if it does not exist', () => { 130 | return syncy('test/**/*', '.tmp/basic-0').then(() => { 131 | return fsUtils.pathExists('.tmp/basic-0').then((status) => { 132 | assert.ok(status); 133 | }); 134 | }); 135 | }); 136 | 137 | it('basic-1: Should just copy files', () => { 138 | return syncy('fixtures/**/*', '.tmp/basic-1') 139 | .then(() => recursiveReaddir('.tmp/basic-1')) 140 | .then((result) => { 141 | assert.strictEqual(result.length, 8); 142 | }); 143 | }); 144 | 145 | it('basic-2: Should just copy files without `base` option in paths', () => { 146 | return syncy('fixtures/**', '.tmp/basic-2', { base: 'fixtures' }) 147 | .then(() => recursiveReaddir('.tmp/basic-2')) 148 | .then((result) => { 149 | assert.strictEqual(result.length, 8); 150 | }); 151 | }); 152 | 153 | it('basic-3: Removing files', () => { 154 | return createFiles('.tmp/basic-2/fixtures', 3) 155 | .then(() => syncy('fixtures/**', '.tmp/basic-3')) 156 | .then(() => recursiveReaddir('.tmp/basic-3')) 157 | .then((result) => { 158 | assert.strictEqual(result.length, 8); 159 | }); 160 | }); 161 | 162 | it('basic-4: Skipping files', () => { 163 | return syncy('fixtures/**', '.tmp/basic-4') 164 | .then(() => syncy('fixtures/**', '.tmp/basic-4')) 165 | .then(() => recursiveReaddir('.tmp/basic-4')) 166 | .then((result) => { 167 | assert.strictEqual(result.length, 8); 168 | }); 169 | }); 170 | }); 171 | 172 | describe('Updating files', () => { 173 | it('updating-0: Remove file in `dest` directory', () => { 174 | return syncy('fixtures/**', '.tmp/updating-0') 175 | // Remove one file in the destination directory 176 | .then(() => fsUtils.removeFile('.tmp/updating-0/fixtures/folder-1/test.txt', { disableGlob: true })) 177 | .then(() => syncy('fixtures/**', '.tmp/updating-0')) 178 | .then(() => recursiveReaddir('.tmp/updating-0')) 179 | .then((result) => { 180 | assert.strictEqual(result.length, 8); 181 | }); 182 | }); 183 | 184 | it('updating-1: Remove file in `src` directory', () => { 185 | // Backup test files 186 | return copyRecursive('fixtures', '.tmp/fixtures-backup') 187 | .then(() => syncy('.tmp/fixtures-backup/**', '.tmp/updating-1')) 188 | // Remove one file in the source directory 189 | .then(() => fsUtils.removeFile('.tmp/fixtures-backup/fixtures/folder-1/test.txt', { disableGlob: true })) 190 | .then(() => syncy('.tmp/fixtures-backup/**', '.tmp/updating-1')) 191 | .then(() => recursiveReaddir('.tmp/updating-1')) 192 | .then((result) => { 193 | assert.strictEqual(result.length, 7); 194 | }); 195 | }); 196 | 197 | it('updating-2: Remove file in `src` (with `**/*` pattern)', () => { 198 | // Backup test files 199 | return copyRecursive('fixtures', '.tmp/fixtures-backup') 200 | .then(() => syncy('.tmp/fixtures-backup/**', '.tmp/updating-2')) 201 | // Remove one file in the source directory 202 | .then(() => fsUtils.removeFile('.tmp/fixtures-backup/fixtures/folder-1/test.txt', { disableGlob: true })) 203 | .then(() => syncy('.tmp/fixtures-backup/**/*.txt', '.tmp/updating-2')) 204 | .then(() => recursiveReaddir('.tmp/updating-2')) 205 | .then((result) => { 206 | assert.strictEqual(result.length, 7); 207 | }); 208 | }); 209 | 210 | it('updating-3: Update the contents of a file', () => { 211 | // Backup test files 212 | return copyRecursive('fixtures', '.tmp/fixtures-backup') 213 | .then(() => syncy('fixtures/**', '.tmp/updating-3', { base: 'fixtures' })) 214 | .then(() => writeFile('.tmp/fixtures-backup/fixtures/folder-2/test.txt', 'test')) 215 | .then(() => syncy('.tmp/fixtures-backup/**', '.tmp/updating-3', { base: '.tmp/fixtures-backup/fixtures' })) 216 | .then(() => readFile('.tmp/updating-3/folder-2/test.txt', 'utf-8')) 217 | .then((data) => { 218 | assert.strictEqual(data, 'test'); 219 | }); 220 | }); 221 | 222 | it('updating-4: No update and delete files from dest (updateAndDelete)', () => { 223 | return createFiles('.tmp/updating-4/fixtures', 3) 224 | .then(() => syncy('fixtures/**', '.tmp/updating-4', { updateAndDelete: false })) 225 | .then(() => recursiveReaddir('.tmp/updating-4')) 226 | .then((result) => { 227 | // File `test-2.txt` overwritten 228 | assert.strictEqual(result.length, 10); 229 | }); 230 | }); 231 | }); 232 | 233 | // eslint-disable-next-line mocha/no-skipped-tests 234 | describe.skip('Console information', () => { 235 | it('console-0: Verbose (true)', () => { 236 | // Hook for console output 237 | const clgDump = console.log; 238 | let stdout = ''; 239 | console.log = (message: string) => { 240 | stdout += JSON.stringify(message); 241 | }; 242 | 243 | return syncy('fixtures/**', '.tmp/console-0', { verbose: true }).then(() => { 244 | console.log = clgDump; 245 | assert.strictEqual(/fixtures\/test-2.txt/.test(stdout), true); 246 | }); 247 | }); 248 | 249 | it('console-1: Verbose (function)', () => { 250 | let lastAction = ''; 251 | 252 | const verbose = (log: LogEntry): void => { 253 | lastAction = log.action; 254 | }; 255 | 256 | return syncy('fixtures/**', '.tmp/console-1', { verbose }).then(() => { 257 | assert.strictEqual(lastAction, 'copy'); 258 | }); 259 | }); 260 | }); 261 | 262 | describe('Ignore files', () => { 263 | it('ignore-0: Ignore `test-0.txt` in dest directory (ignoreInDest)', () => { 264 | return createFiles('.tmp/ignore-0/fixtures', 1) 265 | .then(() => syncy('fixtures/**', '.tmp/ignore-0', { ignoreInDest: ['**/test-0.txt'] })) 266 | .then(() => recursiveReaddir('.tmp/ignore-0')) 267 | .then((result) => { 268 | assert.strictEqual(result.length, 9); 269 | }); 270 | }); 271 | 272 | it('ignore-1: Don\'t remove directory with ignored files', () => { 273 | return createFiles('.tmp/ignore-1/fixtures/main', 1) 274 | .then(() => syncy('fixtures/**', '.tmp/ignore-1', { ignoreInDest: ['**/*.txt'] })) 275 | .then(() => recursiveReaddir('.tmp/ignore-1')) 276 | .then((result) => { 277 | assert.strictEqual(result.length, 9); 278 | }); 279 | }); 280 | 281 | it('ignore-2: Don\'t remove directory with multiple ignored files', () => { 282 | return Promise.all([ 283 | createFiles('.tmp/ignore-2/one', 1), 284 | createFiles('.tmp/ignore-2/two', 1) 285 | ]) 286 | .then(() => syncy('fixtures/**', '.tmp/ignore-2', { base: 'fixtures', ignoreInDest: ['one/**/*', 'two/**/*'] })) 287 | .then(() => recursiveReaddir('.tmp/ignore-2')) 288 | .then((result) => { 289 | assert.strictEqual(result.length, 10); 290 | }); 291 | }); 292 | }); 293 | 294 | describe('Multiple destination', () => { 295 | it('multiple-0: Multiple destination directories', () => { 296 | return syncy('fixtures/**', ['.tmp/multiple-0-one', '.tmp/multiple-0-two']) 297 | .then(() => Promise.all([ 298 | recursiveReaddir('.tmp/multiple-0-one'), 299 | recursiveReaddir('.tmp/multiple-0-two') 300 | ])) 301 | .then((result) => { 302 | assert.strictEqual(result[0].length + result[1].length, 16); 303 | }); 304 | }); 305 | 306 | it('multiple-1: Remove file in both `dest` directories', () => { 307 | return syncy('fixtures/**', ['.tmp/multiple-1-one', '.tmp/multiple-1-two']) 308 | // Remove one file in both destination directories 309 | .then(() => Promise.all([ 310 | fsUtils.removeFile('.tmp/multiple-1-one/fixtures/folder-1/test.txt', { disableGlob: true }), 311 | fsUtils.removeFile('.tmp/multiple-1-two/fixtures/folder-1/test.txt', { disableGlob: true }) 312 | ])) 313 | .then(() => syncy('fixtures/**', ['.tmp/multiple-1-one', '.tmp/multiple-1-two'])) 314 | .then(() => Promise.all([ 315 | recursiveReaddir('.tmp/multiple-1-one'), 316 | recursiveReaddir('.tmp/multiple-1-two') 317 | ])) 318 | .then((result) => { 319 | assert.strictEqual(result[0].length + result[1].length, 16); 320 | }); 321 | }); 322 | }); 323 | }); 324 | --------------------------------------------------------------------------------