uci-utils-sync/src/rsync.js

1127 lines
26 KiB
JavaScript
Executable File

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 <mattijs@monkeyandmachine.com>
* @copyright Copyright (c) 2013, Mattijs Hoitink <mattijs@monkeyandmachine.com>
* @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
}