import { spawn } from'child_process' import path from 'path' /** * Rsync is a wrapper class to configure and execute an `rsync` command * in a fluent and convenient way. * * A new command can be set up by creating a new `Rsync` instance of * obtaining one through the `build` method. * * @example * // using the constructor * var rsync = new Rsync() * .source('/path/to/source') * .destination('myserver:destination/'); * * // using the build method with options * var rsync = Rsync.build({ * source: '/path/to/source', * destination: 'myserver:destination/' * }); * * Executing the command can be done using the `execute` method. The command * is executed as a child process and three callbacks can be registered. See * the `execute` method for more details. * * @example * rsync.execute(function(error, code, cmd) { * // function called when the child process is finished * }, function(stdoutChunk) { * // function called when a chunk of text is received on stdout * }, function stderrChunk) { * // function called when a chunk of text is received on stderr * }); * * @author Mattijs Hoitink * @copyright Copyright (c) 2013, Mattijs Hoitink * @license The MIT License * * @constructor * @param {Object} config Configuration settings for the Rsync wrapper. */ function Rsync(config) { if (!(this instanceof Rsync)) { return new Rsync(config) } // Parse config config = config || {} if (typeof(config) !== 'object') { throw new Error('Rsync config must be an Object') } // executable this._executable = hasOP(config, 'executable') ? config.executable : 'rsync' // shell this._executableShell = hasOP(config, 'executableShell') ? config.executableShell : '/bin/sh' // source(s) and destination this._sources = [] this._destination = '' // ordered list of file patterns to include/exclude this._patterns = [] // just a list of exclude only patterns for use elsewhere like a watcher this._exclude = [] // list of exclude files this._excludeFiles = [] // options this._options = {} // output callbacks this._outputHandlers = { stdout: null, stderr: null } this._cwd = process.cwd() // Allow child_process.spawn env overriding this._env = process.env // Debug parameter this._debug = hasOP(config, 'debug') ? config.debug : false } /** * Build a new Rsync command from an options Object. * @param {Object} options * @return {Rsync} */ Rsync.build = function(options) { var command = new Rsync() // Process all options for (var key in options) { if (hasOP(options, key)) { var value = options[key] // Only allow calling methods on the Rsync command if (typeof(command[key]) === 'function') { command[key](value) } } } return command } /** * Set an option. * @param {String} option * @param mixed value * @return Rsync */ Rsync.prototype.set = function(option, value) { option = stripLeadingDashes(option) if (option && option.length > 0) { this._options[option] = value || null } return this } /** * Unset an option. * @param {String} option * @return Rsync */ Rsync.prototype.unset = function(option) { option = stripLeadingDashes(option) if (option && Object.keys(this._options).indexOf(option) >= 0) { delete this._options[option] } return this } /** * Set or unset one or more flags. A flag is a single letter option without a value. * * Flags can be presented as a single String, an Array containing Strings or an Object * with the flags as keys. * * When flags are presented as a String or Array the set or unset method will be determined * by the second parameter. * When the flags are presented as an Object the set or unset method will be determined by * the value corresponding to each flag key. * * @param {String|Array|Object} flags * @param {Boolean} set * @return Rsync */ Rsync.prototype.flags = function(flags, set) { // Do some argument handling if (!arguments.length) { return this } else if (arguments.length === 1) { set = true } else { // There are more than 1 arguments, assume flags are presented as strings flags = Array.prototype.slice.call(arguments) // Check if the last argument is a boolean if (typeof(flags[flags.length - 1]) === 'boolean') { set = flags.pop() } else { set = true } // Join the remainder of the arguments to treat them as flags flags = flags.join('') } // Split multiple flags if (typeof(flags) === 'string') { flags = stripLeadingDashes(flags).split('') } // Turn array into an object if (isArray(flags)) { var obj = {} flags.forEach(function(f) { obj[f] = set }) flags = obj } // set/unset each flag for (var key in flags) { if (hasOP(flags, key)) { var method = (flags[key]) ? 'set' : 'unset' this[method](stripLeadingDashes(key)) } } return this } /** * Check if an option is set. * @param {String} option * @return {Boolean} */ Rsync.prototype.isSet = function(option) { option = stripLeadingDashes(option) return Object.keys(this._options).indexOf(option) >= 0 } /** * Get an option by name. * @param {String} name * @return mixed */ Rsync.prototype.option = function(name) { name = stripLeadingDashes(name) return this._options[name] } /** * Register a list of file patterns to include/exclude in the transfer. Patterns can be * registered as an array of Strings or Objects. * * When registering a pattern as a String it must be prefixed with a `+` or `-` sign to * signal include or exclude for the pattern. The sign will be stripped of and the * pattern will be added to the ordered pattern list. * * When registering the pattern as an Object it must contain the `action` and * `pattern` keys where `action` contains the `+` or `-` sign and the `pattern` * key contains the file pattern, without the `+` or `-` sign. * * @example * // on an existing rsync object * rsync.patterns(['-docs', { action: '+', pattern: '/subdir/*.py' }]); * * // using Rsync.build for a new rsync object * rsync = Rsync.build({ * ... * patterns: [ '-docs', { action: '+', pattern: '/subdir/*.py' }] * ... * }) * * @param {Array} patterns * @return Rsync */ Rsync.prototype.patterns = function(patterns) { if (arguments.length > 1) { patterns = Array.prototype.slice.call(arguments, 0) } if (!isArray(patterns)) { patterns = [ patterns ] } patterns.forEach(function(pattern) { var action = '?' if (typeof(pattern) === 'string') { action = pattern.charAt(0) pattern = pattern.substring(1) } else if ( typeof(pattern) === 'object' && hasOP(pattern, 'action') && hasOP(pattern, 'pattern') ) { action = pattern.action pattern = pattern.pattern } // Check if the pattern is an include or exclude if (action === '-') { this.exclude(pattern) } else if (action === '+') { this.include(pattern) } else { throw new Error('Invalid pattern: ' + pattern) } }, this) return this } /** * Exclude a file pattern from transfer. The pattern will be appended to the ordered list * of patterns for the rsync command. * * @param {String|Array} patterns * @return Rsync */ Rsync.prototype.exclude = function(patterns) { if (arguments.length > 1) { patterns = Array.prototype.slice.call(arguments, 0) } if (!isArray(patterns)) { patterns = [ patterns ] } // save this list for other uses this._exclude = [...this._exclude,...patterns] patterns.forEach(function(pattern) { this._patterns.push({ action: '-', pattern: pattern }) }, this) return this } /** * Exclude a file of globs/patterns. The file path will to a list * of file paths to be used with --exclude-from. * dgk added this * @param {String|Array} filePaths * @return Rsync */ Rsync.prototype.excludeFrom = function(filePaths) { if (arguments.length > 1) { filePaths = Array.prototype.slice.call(arguments, 0) } if (!isArray(filePaths)) { filePaths = [ filePaths ] } filePaths.forEach(function(filePath) { if (filePath.indexOf('/') < 0) filePath=path.normalize(`${this._sources[0]}/${filePath}`) // TODO only add to list if file exsists this._excludeFiles.push(filePath) }, this) // this._excludeFiles = [...this._excludeFiles,...filePaths] return this } /** * Include a file pattern for transfer. The pattern will be appended to the ordered list * of patterns for the rsync command. * * @param {String|Array} patterns * @return Rsync */ Rsync.prototype.include = function(patterns) { if (arguments.length > 1) { patterns = Array.prototype.slice.call(arguments, 0) } if (!isArray(patterns)) { patterns = [ patterns ] } patterns.forEach(function(pattern) { this._patterns.push({ action: '+', pattern: pattern }) }, this) return this } /** * Get the command that is going to be executed. * @return {String} */ Rsync.prototype.command = function() { return this.executable() + ' ' + this.args().join(' ') } /** * String representation of the Rsync command. This is the command that is * going to be executed when calling Rsync::execute. * @return {String} */ Rsync.prototype.toString = Rsync.prototype.command /** * Get the arguments for the rsync command. * @return {Array} */ Rsync.prototype.args = function() { // Gathered arguments var args = [] // Add options. Short options (one letter) without values are gathered together. // Long options have a value but can also be a single letter. var short = [] var long = [] // Split long and short options for (var key in this._options) { if (hasOP(this._options, key)) { var value = this._options[key] var noval = (value === null || value === undefined) // Check for short option (single letter without value) if (key.length === 1 && noval) { short.push(key) } else { if (isArray(value)) { value.forEach(function (val) { long.push(buildOption(key, val, escapeShellArg)) }) } else { long.push(buildOption(key, value, escapeShellArg)) } } } } // Add combined short options if any are present if (short.length > 0) { args.push('-' + short.join('')) } // Add long options if any are present if (long.length > 0) { args = args.concat(long) } // add exclude-from file paths this._excludeFiles.forEach(function(file) { args.push(`--exclude-from=${escapeFileArg(file)}`) }) // Add includes/excludes in order this._patterns.forEach(function(def) { if (def.action === '-') { args.push(buildOption('exclude', def.pattern, escapeFileArg)) } else if (def.action === '+') { args.push(buildOption('include', def.pattern, escapeFileArg)) } else { debug(this, 'Unknown pattern action ' + def.action) } }) // Add sources if (this.source().length > 0) { args = args.concat(this.source().map(escapeFileArg)) } // Add destination if (this.destination()) { args.push(escapeFileArg(this.destination())) } return args } /** * Get and set rsync process cwd directory. * * @param {string} cwd= Directory path relative to current process directory. * @return {string} Return current _cwd. */ Rsync.prototype.cwd = function(cwd) { if (arguments.length > 0) { if (typeof cwd !== 'string') { throw new Error('Directory should be a string') } this._cwd = path.resolve(cwd) } return this._cwd } /** * Get and set rsync process environment variables * * @param {string} env= Environment variables * @return {string} Return current _env. */ Rsync.prototype.env = function(env) { if (arguments.length > 0) { if (typeof env !== 'object') { throw new Error('Environment should be an object') } this._env = env } return this._env } /** * Register an output handlers for the commands stdout and stderr streams. * These functions will be called once data is streamed on one of the output buffers * when the command is executed using `execute`. * * Only one callback function can be registered for each output stream. Previously * registered callbacks will be overridden. * * @param {Function} stdout Callback Function for stdout data * @param {Function} stderr Callback Function for stderr data * @return Rsync */ Rsync.prototype.output = function(stdout, stderr) { // Check for single argument so the method can be used with Rsync.build if (arguments.length === 1 && Array.isArray(stdout)) { stderr = stdout[1] stdout = stdout[0] } if (typeof(stdout) === 'function') { this._outputHandlers.stdout = stdout } if (typeof(stderr) === 'function') { this._outputHandlers.stderr = stderr } return this } /** * Execute the rsync command. * * The callback function is called with an Error object (or null when there was none), * the exit code from the executed command and the executed command as a String. * * When stdoutHandler and stderrHandler functions are provided they will be used to stream * data from stdout and stderr directly without buffering. * * @param {Function} callback Called when rsync finishes (optional) * @param {Function} stdoutHandler Called on each chunk received from stdout (optional) * @param {Function} stderrHandler Called on each chunk received from stderr (optional) */ Rsync.prototype.execute = function(callback, stdoutHandler, stderrHandler) { // Register output handlers this.output(stdoutHandler, stderrHandler) // Execute the command as a child process // see https://github.com/joyent/node/blob/937e2e351b2450cf1e9c4d8b3e1a4e2a2def58bb/lib/child_process.js#L589 var cmdProc if ('win32' === process.platform) { cmdProc = spawn('cmd.exe', ['/s', '/c', '"' + this.command() + '"'], { stdio: 'pipe', windowsVerbatimArguments: true, cwd: this._cwd, env: this._env }) } else { cmdProc = spawn(this._executableShell, ['-c', this.command()], { stdio: 'pipe', cwd: this._cwd, env: this._env }) } // Capture stdout and stderr if there are output handlers configured if (typeof(this._outputHandlers.stdout) === 'function') { cmdProc.stdout.on('data', this._outputHandlers.stdout) } if (typeof(this._outputHandlers.stderr) === 'function') { cmdProc.stderr.on('data', this._outputHandlers.stderr) } // Wait for the command to finish cmdProc.on('close', function(code) { var error = null // Check rsyncs error code // @see http://bluebones.net/2007/06/rsync-exit-codes/ if (code !== 0) { error = new Error('rsync exited with code ' + code) } // Check for callback if (typeof(callback) === 'function') { callback(error, code, this.command()) } }.bind(this)) // Return the child process object so it can be cleaned up // if the process exits return(cmdProc) } /** * Get or set the debug property. * * The property is set to the boolean provided so unsetting the debug * property has to be done by passing false to this method. * * @function * @name debug * @memberOf Rsync.prototype * @param {Boolean} debug the value of the debug property (optional) * @return {Rsync|Boolean} */ createValueAccessor('debug') /** * Get or set the executable to use for the rsync process. * * When setting the executable path the Rsync instance is returned for * the fluent interface. Otherwise the configured executable path * is returned. * * @function * @name executable * @memberOf Rsync.prototype * @param {String} executable path to the executable (optional) * @return {Rsync|String} */ createValueAccessor('executable') /** * Get or set the shell to use on non-Windows (Unix or Mac OS X) systems. * * When setting the shell the Rsync instance is returned for the * fluent interface. Otherwise the configured shell is returned. * * @function * @name executableShell * @memberOf Rsync.prototype * @param {String} shell to use on non-Windows systems (optional) * @return {Rsync|String} */ createValueAccessor('executableShell') /** * Get or set the destination for the transfer. * * When setting the destination the Rsync instance is returned for * the fluent interface. Otherwise the configured destination path * is returned. * * @function * @name destination * @memberOf Rsync.prototype * @param {String} destination the destination (optional) * @return {Rsync|String} */ createValueAccessor('destination') /** * Add one or more sources for the command or get the list of configured * sources. * * The sources are appended to the list of known sources if they were not * included yet and the Rsync instance is returned for the fluent * interface. Otherwise the configured list of source is returned. * * @function * @name source * @memberOf Rsync.prototype * @param {String|Array} sources the source or list of sources to configure (optional) * @return {Rsync|Array} */ createListAccessor('source', '_sources') /** * Set the shell to use when logging in on a remote server. * * This is the same as setting the `rsh` option. * * @function * @name shell * @memberOf Rsync.prototype * @param {String} shell the shell option to use * @return {Rsync} */ exposeLongOption('rsh', 'shell') /** * Add a chmod instruction to the command. * * @function * @name chmod * @memberOf Rsync.prototype * @param {String|Array} * @return {Rsync|Array} */ exposeMultiOption('chmod', 'chmod') /** * Set the delete flag. * * This is the same as setting the `--delete` commandline flag. * * @function * @name delete * @memberOf Rsync.prototype * @return {Rsync} */ exposeShortOption('delete') /** * Set the progress flag. * * This is the same as setting the `--progress` commandline flag. * * @function * @name progress * @memberOf Rsync.prototype * @return {Rsync} */ exposeShortOption('progress') /** * Set the archive flag. * * @function * @name archive * @memberOf Rsync.prototype * @return {Rsync} */ exposeShortOption('a', 'archive') /** * Set the compress flag. * * @function * @name compress * @memberOf Rsync.prototype * @return {Rsync} */ exposeShortOption('z', 'compress') /** * Set the recursive flag. * * @function * @name recursive * @memberOf Rsync.prototype * @return {Rsync} */ exposeShortOption('r', 'recursive') /** * Set the update flag. * * @function * @name update * @memberOf Rsync.prototype * @return {Rsync} */ exposeShortOption('u', 'update') /** * Set the quiet flag. * * @function * @name quiet * @memberOf Rsync.prototype * @return {Rsync} */ exposeShortOption('q', 'quiet') /** * Set the dirs flag. * * @function * @name dirs * @memberOf Rsync.prototype * @return {Rsync} */ exposeShortOption('d', 'dirs') /** * Set the links flag. * * @function * @name links * @memberOf Rsync.prototype * @return {Rsync} */ exposeShortOption('l', 'links') /** * Set the dry flag. * * @function * @name dry * @memberOf Rsync.prototype * @return {Rsync} */ exposeShortOption('n', 'dry') /** * Set the hard links flag preserving hard links for the files transmitted. * * @function * @name hardLinks * @memberOf Rsync.prototype * @return {Rsync} */ exposeShortOption('H', 'hardLinks') /** * Set the perms flag. * * @function * @name perms * @memberOf Rsync.prototype * @return {Rsync} */ exposeShortOption('p', 'perms') /** * Set the executability flag to preserve executability for the files * transmitted. * * @function * @name executability * @memberOf Rsync.prototype * @return {Rsync} */ exposeShortOption('E', 'executability') /** * Set the group flag to preserve the group permissions of the files * transmitted. * * @function * @name group * @memberOf Rsync.prototype * @return {Rsync} */ exposeShortOption('g', 'group') /** * Set the owner flag to preserve the owner of the files transmitted. * * @function * @name owner * @memberOf Rsync.prototype * @return {Rsync} */ exposeShortOption('o', 'owner') /** * Set the acls flag to preserve the ACLs for the files transmitted. * * @function * @name acls * @memberOf Rsync.prototype * @return {Rsync} */ exposeShortOption('A', 'acls') /** * Set the xattrs flag to preserve the extended attributes for the files * transmitted. * * @function * @name xattrs * @memberOf Rsync.prototype * @return {Rsync} */ exposeShortOption('X', 'xattrs') /** * Set the devices flag to preserve device files in the transfer. * * @function * @name devices * @memberOf Rsync.prototype * @return {Rsync} */ exposeShortOption('devices') /** * Set the specials flag to preserve special files. * * @function * @name specials * @memberOf Rsync.prototype * @return {Rsync} */ exposeShortOption('specials') /** * Set the times flag to preserve times for the files in the transfer. * * @function * @name times * @memberOf Rsync.prototype * @return {Rsync} */ exposeShortOption('t', 'times') export default Rsync export { Rsync } /* **** */ /** * Create a chainable function on the Rsync prototype for getting and setting an * internal value. * @param {String} name * @param {String} internal */ function createValueAccessor(name, internal) { var container = internal || '_' + name Rsync.prototype[name] = function(value) { if (!arguments.length) return this[container] this[container] = value return this } } /** * @param {String} name * @param {String} internal */ function createListAccessor(name, internal) { var container = internal || '_' + name Rsync.prototype[name] = function(value) { if (!arguments.length) return this[container] if (isArray(value)) { value.forEach(this[name], this) } else if (typeof(value) !== 'string') { throw new Error('Value for Rsync::' + name + ' must be a String') } else if (this[container].indexOf(value) < 0) { this[container].push(value) } return this } } /** * Create a shorthand method on the Rsync prototype for setting and unsetting a simple option. * @param {String} option * @param {String} name */ function exposeShortOption(option, name) { name = name || option Rsync.prototype[name] = function(set) { // When no arguments are passed in assume the option // needs to be set if (!arguments.length) set = true var method = (set) ? 'set' : 'unset' return this[method](option) } } /** * Create a function for an option that can be set multiple time. The option * will accumulate all values. * * @param {String} option * @param {[String]} name */ function exposeMultiOption(option, name) { name = name || option Rsync.prototype[name] = function(value) { // When not arguments are passed in assume the options // current value is requested if (!arguments.length) return this.option(option) if (!value) { // Unset the option on falsy this.unset(option) } else if (isArray(value)) { // Call this method for each array value value.forEach(this[name], this) } else { // Add the value var current = this.option(option) if (!current) { value = [ value ] } else if (!isArray(current)) { value = [ current, value ] } else { value = current.concat(value) } this.set(option, value) } return this } } /** * Expose an rsync long option on the Rsync prototype. * @param {String} option The option to expose * @param {String} name An optional alternative name for the option. */ function exposeLongOption(option, name) { name = name || option Rsync.prototype[name] = function(value) { // When not arguments are passed in assume the options // current value is requested if (!arguments.length) return this.option(option) var method = (value) ? 'set' : 'unset' return this[method](option, value) } } /** * Build an option for use in a shell command. * * @param {String} name * @param {String} value * @param {Function|boolean} escapeArg * @return {String} */ function buildOption(name, value, escapeArg) { if (typeof escapeArg === 'boolean') { escapeArg = (!escapeArg) ? noop : null } if (typeof escapeArg !== 'function') { escapeArg = escapeShellArg } // Detect single option key var single = (name.length === 1) ? true : false // Decide on prefix and value glue var prefix = (single) ? '-' : '--' var glue = (single) ? ' ' : '=' // Build the option var option = prefix + name if (arguments.length > 1 && value) { value = escapeArg(String(value)) option += glue + value } return option } /** * Escape an argument for use in a shell command when necessary. * @param {String} arg * @return {String} */ function escapeShellArg(arg) { if (!/(["'`\\$ ])/.test(arg)) { return arg } return '"' + arg.replace(/(["'`\\$])/g, '\\$1') + '"' } /** * Escape a filename for use in a shell command. * @param {String} filename the filename to escape * @return {String} the escaped version of the filename */ function escapeFileArg(filename) { filename = filename.replace(/(["'`\s\\\(\)\\$])/g,'\\$1') if (!/(\\\\)/.test(filename)) { return filename } // Under Windows rsync (with cygwin) and OpenSSH for Windows // (http://www.mls-software.com/opensshd.html) are using // standard linux directory separator so need to replace it if ('win32' === process.platform) { filename = filename.replace(/\\\\/g,'/').replace(/^["]?[A-Z]\:\//ig,'/') } return filename } /** * Strip the leading dashes from a value. * @param {String} value * @return {String} */ function stripLeadingDashes(value) { if (typeof(value) === 'string') { value = value.replace(/^[\-]*/, '') } return value } /** * Simple function for checking if a value is an Array. Will use the native * Array.isArray method if available. * @private * @param {Mixed} value * @return {Boolean} */ function isArray(value) { if (typeof(Array.isArray) === 'function') { return Array.isArray(value) } else { return toString.call(value) == '[object Array]' } } /** * Simple hasOwnProperty wrapper. This will call hasOwnProperty on the obj * through the Object prototype. * @private * @param {Object} obj The object to check the property on * @param {String} key The name of the property to check * @return {Boolean} */ function hasOP(obj, key) { return Object.prototype.hasOwnProperty.call(obj, key) } function noop() {} /** * Simple debug printer. * * @private * @param {Rsync} cmd * @param {String} message */ function debug(cmd, message) { if (!cmd._debug) return }