Sublime: SVN or GIT Build System
/*
// Use this in your SublimeText build script file:
{
// Use node to run the build_hyper.js script, and pass it the name of the project,
// which should be the same as the name of the SVN or GIT repository and project directory (local to the current working directory)
// on your computer.
//
// For example:
// svn://test_repo
// or:
// git://test_repo
//
// If first argument is not starting with `git://` or `svn://`, it will be assummed that it is a path to the local repo directory,
// and Builder will try to check if it's an SVN or GIT repository by checking for `.svn` and `.git` subdirectories.
//
// Last argument(s) can be URL or scripts that should be called after the commit. It may be used to make server update
// site from the SVN or GIT. You can use "[REPO_NAME]" inside that URL, to pass repo name to the URL, e.g.,
// http://www.example.com/updateFromSVN/[REPO_NAME]
// and/or:
// node://some_script_in_the_working_dir.js
// and/or:
// python://some_script_in_the_working_dir.js
// etc... supported are: node, python, php and ruby
//
// There is also special command supported to upload files changed between revisions to some server:
// sftp://IP_OR_DOMAIN_OF_SERVER/PATH_TO_ROOT_DIRECTORY/?ignorePrefix=trunk/publish/
// ignorePrefix defines which part of the project path should be stripped and only files inside that prefix will be uploaded.
//
// Here it is the same directory as the one which keeps all the repo directories and all the project files.
"cmd": ["node", "$project_path\\sublime_build_hyper.js", "${project_name/.sublime-project//}", "http://your.site.com/update.php?repo=[REPO_NAME]"],
// This should not be needed, but it's here just in case you may need it (if you are getting encoding errors).
//"encoding": "iso-8859-2",
// We do not need to open shell window.
"shell": false,
// This should be set to the directory which contains all the repo directories.
// Here it is the same directory which contains project files (*.sublime-project).
"working_dir": "$project_path\\"
// This allows specyfying different versions of builds or additional tasks.
"variants": [
// Here we declare a variant which will ask user about revisions to deploy to target server.
{
"cmd": ["node", "sublime_build_hyper.js", "${project_name/.sublime-project//}", "sftp://user@dev.example.com/myApplication/"],
"name": "Deploy to DEV"
}
]
}
*/
var querystring = require('querystring');
var emitter = require('events').EventEmitter;
var crypto = require('crypto');
var spawn = require('child_process').spawn;
var https = require('https');
var http = require('http');
var exec = require('child_process').exec;
var util = require('util');
var path = require('path');
var url = require('url');
var fs = require('fs');
var SUBLIME = (process.platform === 'win32' ? 'C:\\Program Files\\Sublime Text 2\\sublime_text.exe' : 'subl');
/**
* Sublime builder in node.js.
* Repository name is a name of the directory, relative to the current working directory.
* If it does not start with either "svn://" or "git://", Builder will try to check which repository type it is,
* by checking for `.svn` and `.git` subdirectory.
*
* @constructor
*
* @param {string} repository - name of the repository directory, relative to the current working directory
*/
var Builder = function(repository){
"use strict";
// Inherit EventEmitter.
emitter.call(this);
/**
* @private
*/
var self = this;
/**
* @private
*/
var REPO_DIR = repository;
var REPO_TYPE = false;
/**
* @private
*/
var MODULES = {};
/**
* @private
*/
var PROTOCOLS = {};
/**
* This callback type is called `execCallback` and is displayed as a global symbol.
*
* @callback execCallback
* @param {Error} error
* @param {Buffer} stdout
* @param {Buffer} stderr
*/
/**
* Call URL. It can be either HTTP(S) or custom, like node://, php://, etc...
*
* @param {string} targetURL
* @param {execCallback} callback
*/
this.callURL = function(targetURL, callback) {
var urlObject = url.parse(targetURL.replace(/\[REPO_NAME\]/g, REPO_DIR));
if (PROTOCOLS.hasOwnProperty(urlObject.protocol) && PROTOCOLS[urlObject.protocol]) {
PROTOCOLS[urlObject.protocol](urlObject, callback);
}
else {
callback(-1, '', 'ERROR: protocol '+urlObject.protocol+' is not supported!'+"\n");
}
};
/**
* Spawn process and callback when done.
* command example:
*
* ['svn', 'log', '-l', '1', 'myrepository']
*
* @param {Array} command - array of command line arguments, including command as a first item in the array
* @param {String} directory - if not empty, change working directory to it while running the command
* @param {execCallback} callback
*/
this.exec = function(command, directory, callback){
console.log('Calling '+command.join(' ')+(directory ? ' inside '+directory : ''));
var cwd = process.cwd();
if (directory) {
process.chdir(directory);
}
var child = spawn(command.shift(), command);
var stdout = '';
var stderr = '';
child.stdout.on('data', function(data){
stdout += data;
});
child.stderr.on('data', function(data){
stderr += data;
});
child.on('close', function(code){
if (directory) {
process.chdir(cwd);
}
callback(code, stdout, stderr);
});
child.on('error', function(error){
console.log(error);
});
};
/**
* Require a module, installing it when needed.
*
* @param {string} module - name of the module
* @param {execCallback} callback
*/
this.require = function(module, callback){
if (MODULES.hasOwnProperty(module)) {
callback(false, '', '');
return;
}
MODULES[module] = false;
// Require uses current file's directory for resolution, not current working directory,
// so we switch working directory temporarily.
var cwd = process.cwd();
process.chdir(__dirname);
try {
MODULES[module] = require(module);
process.chdir(cwd);
callback(false, '', '');
}
catch (e) {
console.log(e);
self.exec([(process.platform === 'win32' ? 'npm.cmd' : 'npm'), 'install', module], false, function(error, stdout, stderr){
if (!error) {
try {
MODULES[module] = require(module);
}
catch (e) {
error = e.Error;
MODULES[module] = false;
}
}
process.chdir(cwd);
callback(error, stdout, stderr);
});
}
};
/**
* Recursive read of directory to list all the files.
* From: https://gist.github.com/DelvarWorld/825583
*
* @param {string} start - directory path
* @param {function} callback(error, found)
*/
this.readDir = function(start, callback) {
// Use lstat to resolve symlink if we are passed a symlink
fs.lstat(start, function(err, stat) {
if (err) {
return callback(err);
}
var found = {dirs: [], files: []};
var total = 0;
var processed = 0;
function isDir(abspath) {
fs.stat(abspath, function(err, stat) {
if (stat.isDirectory()) {
found.dirs.push(abspath);
// If we found a directory, recurse!
self.readDir(abspath, function(err, data) {
found.dirs = found.dirs.concat(data.dirs);
found.files = found.files.concat(data.files);
if (++processed == total) {
callback(null, found);
}
});
} else {
found.files.push(abspath);
if (++processed == total) {
callback(null, found);
}
}
});
}
// Read through all the files in this directory
if (stat.isDirectory()) {
fs.readdir(start, function (err, files) {
total = files.length;
for (var x=0, l=files.length; x<l; x++) {
isDir(path.join(start, files[x]));
}
if (total === 0) {
callback(null, found);
}
});
} else {
return callback(new Error("path: " + start + " is not a directory"));
}
});
};
/**
* Delete files.
*
* @param {Array} fileList - list of files to be deleted
* @param {execCallback} callback
*/
this.filesDelete = function(fileList, callback){
var _filesDelete;
var files = fileList.slice();
_filesDelete = function(error) {
if (files.length < 1) {
callback();
return;
}
else if (error) {
callback(true, '', error);
return;
}
fs.unlink(files.shift(), _filesDelete);
};
_filesDelete();
};
/**
* Read through the fileList, find all directories and recursively add all subdirectories and their files to the fileList.
*
* @param {Array} fileList - list of files to add files to
* @param {execCallback} callback
*/
this.filesAddToList = function(fileList, callback){
var _filesAddToList;
var files = fileList.slice();
_filesAddToList = function() {
if (files.length < 1) {
// Make sure that the files and directories are unique.
var temp = {};
for (var i = 0; i < fileList.length; i++) {
temp[fileList[i]] = 1;
}
fileList = Object.keys(temp);
fileList.sort();
callback();
return;
}
var file = files.shift();
var stat = fs.statSync(file);
if (stat.isDirectory()) {
// Make sure that there are no spaces in any of the directory names.
if (file.match(/\s+/)) {
callback(true, '', 'ERROR: there is a whitespace in one of the file or directory names: "'+file+'"');
return;
}
self.readDir(file, function(error, found){
if (!error && found) {
fileList.push.apply(fileList, found.dirs);
fileList.push.apply(fileList, found.files);
}
_filesAddToList();
});
}
else {
_filesAddToList();
}
};
_filesAddToList();
};
/**
* Show user a text to edit, run callback with edited text.
*
* @param {string} data - text presented to the user to edit
* @param {integer} startAtLine - line number at which editor cursor will start by default
* @param {integer} startAtChar - character position at which editor will start by default
* @param {function} callback - function(Buffer dataEditedByUser) will be called after user closes editor
* @returns {string} filename - file in which user data is saved temporarily
*/
this.getDataFromUser = function(data, startAtLine, startAtChar, callback){
console.log('Getting data from the user');
if (isNaN(startAtLine)) {
startAtLine = 0;
}
if (isNaN(startAtChar)) {
startAtChar = 0;
}
data = "#\n# Close the window (CTRL+SHIFT+W) and confirm the file save (ENTER) after you are done with the configuration.\n#\n" + data;
startAtLine += 3;
var tempfilename = Date.now() + '_' + crypto.randomBytes(4).readUInt32LE(0) + '.txt';
fs.writeFileSync(tempfilename, data);
var child = spawn(SUBLIME, ['-w', '-s', '-n', tempfilename+':'+startAtLine+':'+startAtChar]);
child.on('close', function(){
var newData = fs.readFileSync(tempfilename);
if (!newData) {
console.log('ERROR: could not read data.');
}
fs.unlinkSync(tempfilename);
callback(newData);
});
return tempfilename;
};
/**
* Call for SVN update, then ask user for commit message and changelist, then commit to SVN.
*
* @param {execCallback} callback
*/
this.commit = function(callback){
// Get list of changes.
self.repo.status(function(error, stdout, stderr){
var changes = self.repo.parseChanges(stdout);
console.log(changes);
// Update from SVN to be up-todate with changes commited in the meantime.
self.repo.update(function(error, stdout, stderr){
console.log(stdout+(error ? stderr : ''));
// Check if there is anything to do, and return early if not.
if (!changes || (changes.A.length < 1 && changes.M.length < 1 && changes.D.length < 1)) {
callback(false, 'Nothing to commit', '');
return;
}
// Delete files that were restored by update, but should be deleted according to last status check.
var deletedFiles = [];
if (changes && changes.D.length > 0) {
changes.D.forEach(function(value, index){
if (stdout.indexOf('Restored \''+value) > -1) {
deletedFiles.push(value);
}
});
}
self.filesDelete(deletedFiles, function(){
self.filesAddToList(changes.A, function(error, stdout, stderr){
if (error) {
console.log(stdout+stderr);
return;
}
console.log(changes);
// Continue with the commit process.
self.repo.lastCommit(function(error, stdout, stderr){
var message = self.repo.parseMessage(stdout);
var data = "# Enter you commit message below. DO NOT CHANGE OR REMOVE THIS LINE!\n\n" + message + "\n\n# Commit following changes (DO NOT CHANGE OR REMOVE THIS LINE!):\n\n";
changes.A.forEach(function(value, index){
data += 'A '+value+"\n";
});
changes.M.forEach(function(value, index){
data += 'M '+value+"\n";
});
changes.D.forEach(function(value, index){
data += 'D '+value+"\n";
});
// Ask user for commit message and list of files and/or directories to be committed.
self.getDataFromUser(data, 3, 0, function(data){
var info = data.toString();
var list = info.match(/# Commit following changes [\w\W]+/);
message = info.replace(list[0], '').replace(/(^|[\r\n]+)#\s[^\r\n]*/g, '').replace(/^[\r\n\s]+|[\r\n\s]+$/g, '');
list = list[0].match(/(^|[\r\n]+)([AMD]) ([^\r\n]+)/g);
// Check if there is anything to do, and return early if not.
if (!list || list.length < 1) {
callback(false, 'Nothing to commit', '');
return;
}
// Check if there is commit message.
if (!message) {
callback(false, 'No commit message', '');
return;
}
var newChanges = {A: [], M: [], D: []};
list.forEach(function(value, index){
var temp = value.replace(/^[\r\n\s]+|[\r\n\s]+$/g, '');
newChanges[temp.charAt(0)].push(temp.substring(2));
});
// Check if there is anything to do, and return early if not.
if (newChanges.A.length < 1 && newChanges.M.length < 1 && newChanges.D.length < 1) {
callback(false, 'Nothing to commit', '');
return;
}
self.repo.save(newChanges, message, callback);
});
});
});
});
});
});
};
/**
* Repository Manager is used to work with SVN or GIT.
*
* @typedef {Object} RepoManager
* @property {Function} parseChanges
* @property {Function} parseMessage
* @property {Function} parseRevision
* @property {Function} status
* @property {Function} diff
* @property {Function} add
* @property {Function} delete
* @property {Function} update
* @property {Function} commit
* @property {Function} lastCommit
* @property {Function} lastRevision
* @property {Function} save
*/
/**
* @member {RepoManager} repo manager that will be used
*/
this.repo = null;
/**
* @member {RepoManager} svn manager
*/
this.svn = {};
/**
* RepoChanges is used to keep list of changed files and/or directories.
*
* @typedef {Object} RepoChanges
* @property {Array} A - list of added files and/or directories
* @property {Array} M - list of modified files
* @property {Array} D - list of deleted files and/or directories
*/
/**
* Parse list of changed files from SVN.
* This function is synchronous!
*
* @param {string} stdout - data returned from `svn status` or `svn diff --summarize` commands
*
* @returns {RepoChanges}
*/
this.svn.parseChanges = function(stdout){
var match = null;
var regex = /([\?!DAM])\s+([^\r\n]+)/g;
var result = {A: [], M: [], D: []};
while ((match = regex.exec(stdout))) {
if (match[1] === '!') {
match[1] = 'D';
}
else if (match[1] === '?') {
match[1] = 'A';
}
result[match[1]].push(match[2]);
}
return result;
};
/**
* Parse commit message from SVN log stdout.
* This function is synchronous!
*
* @param {string} stdout - data returned from `svn log` command
*
* @returns {string}
*/
this.svn.parseMessage = function(stdout){
return stdout.replace(/^([-]+[\r\n]+)(r\d+[^\r\n]+[\r\n]+)/, '').replace(/([\r\n]*)([-]+[\r\n]+)$/, '');
};
/**
* Parse revision number from SVN info format.
* This function is synchronous!
*
* @param {string} stdout - data returned from `svn log` command
*
* @returns {string}
*/
this.svn.parseRevision = function(stdout){
var revision = stdout.match(/[\r\n\s]+Revision:\s*(\d+)/i);
if (revision) {
revision = revision[1].replace(/[^\d]+/g, '');
}
else {
revision = false;
}
return revision;
};
/**
* Get SVN status and pass it to the callback function.
*
* @param {execCallback} callback
*/
this.svn.status = function(callback){
exec('svn status '+REPO_DIR, callback);
};
/**
* Get SVN diff between two revisions and pass output (list of changed files) to the callback function.
*
* @param {integer} start - start revision number
* @param {integer} end - end revision number
* @param {execCallback} callback
*/
this.svn.diff = function(start, end, callback){
exec('svn diff '+REPO_DIR+' -r'+start+':'+end+' --summarize', callback);
};
/**
* Add files to SVN.
*
* @param {Array} fileList - list of files to be added to the SVN
* @param {execCallback} callback
*/
this.svn.add = function(fileList, callback){
var _svnadd;
var files = fileList.slice();
_svnadd = function(){
if (files.length < 1) {
callback();
return;
}
self.exec(['svn', 'add', files.shift()], false, _svnadd);
};
_svnadd();
};
/**
* Delete files from SVN.
*
* @param {Array} fileList - list of files to be deleted from the SVN
* @param {execCallback} callback
*/
this.svn.delete = function(fileList, callback){
var _svndelete;
var files = fileList.slice();
_svndelete = function() {
if (files.length < 1) {
callback();
return;
}
self.exec(['svn', 'delete', files.shift()], false, _svndelete);
};
_svndelete();
};
/**
* Add files to the SVN changelist to be commited at a later time.
*
* @param {string} changeset - name of the changeset
* @param {Array} fileList - list of files to be deleted from the SVN
* @param {execCallback} callback
*/
this.svn.changeList = function(changeset, fileList, callback){
var _svnchangelist;
var files = fileList.slice();
var directories = [];
_svnchangelist = function() {
if (files.length < 1) {
if (directories.length > 0) {
self.svn.add(directories, callback);
}
else {
callback();
}
return;
}
var file = files.shift();
fs.lstat(file, function(err, stat){
// Changelist do not work for directories.
if (!err && stat.isDirectory()) {
directories.push(file);
_svnchangelist();
}
else {
self.exec(['svn', 'changelist', changeset, file], false, _svnchangelist);
}
});
};
_svnchangelist();
};
/**
* Remove SVN changelist.
*
* @param {string} changeset - name of the changeset
* @param {execCallback} callback
*/
this.svn.removeChangeList = function(changeset, callback){
exec('svn changelist --remove --recursive --cl '+changeset+' '+REPO_DIR, function(){
callback();
});
};
/**
* Update from SVN and pass output to the callback function.
*
* @param {execCallback} callback
*/
this.svn.update = function(callback){
exec('svn up '+REPO_DIR, callback);
};
/**
* Commit to SVN.
*
* @param {string} message - commit message
* @param {string} changeset - name of the changeset
* @param {execCallback} callback
*/
this.svn.commit = function(message, changeset, callback){
var command = ['svn', 'commit', '-m', message];
if (changeset) {
command.push('--changelist');
command.push(changeset);
}
command.push(REPO_DIR);
self.exec(command, false, callback);
};
/**
* Get last commit info from SVN and pass output to the callback function.
*
* @param {execCallback} callback
*/
this.svn.lastCommit = function(callback){
exec('svn log -l 1 '+REPO_DIR, callback);
};
/**
* Get last revision number from SVN and pass output to the callback function.
*
* @param {execCallback} callback
*/
this.svn.lastRevision = function(callback){
exec('svn info -rHEAD '+REPO_DIR, callback);
};
/**
* Commit all changes to the SVN repository.
*
* @param {RepoChanges} changes
* @param {String} message
* @param {execCallback} callback
*/
this.svn.save = function(changes, message, callback){
// Add and Delete files to/from SVN.
self.svn.add(changes.A, function(){
self.svn.delete(changes.D, function(){
// Prepare SVN changelist.
var changeset = Date.now() + '_' + crypto.randomBytes(4).readUInt32LE(0);
self.svn.changeList(changeset, changes.A.concat(changes.M, changes.D), function(){
// Just in case, run regular commit for possible new directories first.
self.svn.commit(message, false, function(error, stdout, stderr){
// Finally, commit changeset to the SVN repository.
self.svn.commit(message, changeset, function(error, stdout, stderr){
callback(error, stdout, stderr);
});
});
});
});
});
};
/**
* @member {RepoManager} svn manager
*/
this.git = {};
/**
* Parse list of changed files from GIT.
* This function is synchronous!
*
* @param {string} stdout - data returned from `git status --porcelain` or `git diff --name-status` commands
*
* @returns {RepoChanges}
*/
this.git.parseChanges = function(stdout){
var match = null;
var regex = /([\?!DAM]+)\s+([^\r\n]+)/g;
var result = {A: [], M: [], D: []};
while ((match = regex.exec(stdout))) {
if (match[1] === '!') {
match[1] = 'D';
}
else if (match[1] === '??') {
match[1] = 'A';
}
result[match[1]].push(path.join(REPO_DIR, match[2]));
}
return result;
};
/**
* Parse commit message from GIT log stdout.
* This function is synchronous!
*
* @param {string} stdout - data returned from `git log --name-status --pretty="format:commit: %H%n%n%s%n%b%n%n"` command
*
* @returns {string}
*/
this.git.parseMessage = function(stdout){
return stdout.replace(/^[\r\n\s]*commit:?\s*[^\r\n]+/, '').replace(/[\r\n]+([AMD\?\!]+\s+[^\r\n]+[\r\n])*$/, '').trim();
};
/**
* Parse revision number from GIT info format.
* This function is synchronous!
*
* @param {string} stdout - data returned from `git log --name-status --pretty="format:commit: %H%n%n%s%n%b%n%n"` command
*
* @returns {string}
*/
this.svn.parseRevision = function(stdout){
var revision = stdout.match(/[\r\n\s]+commit:?\s*([0-9a-z]{40})/i);
if (revision) {
revision = revision[1];
}
else {
revision = false;
}
return revision;
};
/**
* Get GIT status and pass it to the callback function.
*
* @param {execCallback} callback
*/
this.git.status = function(callback){
self.exec(['git', 'status', '--porcelain'], REPO_DIR, callback);
};
/**
* Get GIT diff between two revisions and pass output (list of changed files) to the callback function.
*
* @param {string} start - start revision SHA
* @param {string} end - end revision SHA
* @param {execCallback} callback
*/
this.git.diff = function(start, end, callback){
self.exec(['git', 'diff', start, end, '--name-status'], REPO_DIR, callback);
};
/**
* Add files to GIT.
*
* @param {Array} fileList - list of files to be added to the GIT
* @param {execCallback} callback
*/
this.git.add = function(fileList, callback){
var _gitadd;
var files = fileList.slice();
_gitadd = function(){
if (files.length < 1) {
callback();
return;
}
// Remove REPO_DIR from the beginning of path, which was added when list of changes was generated.
var file = path.join.apply(files.shift().split(path.sep).slice(1));
self.exec(['git', 'add', file], REPO_DIR, _gitadd);
};
_gitadd();
};
/**
* Delete files from GIT.
*
* @param {Array} fileList - list of files to be deleted from the GIT
* @param {execCallback} callback
*/
this.git.delete = function(fileList, callback){
var _gitdelete;
var files = fileList.slice();
_gitdelete = function() {
if (files.length < 1) {
callback();
return;
}
// Remove REPO_DIR from the beginning of path, which was added when list of changes was generated.
var file = path.join.apply(files.shift().split(path.sep).slice(1));
self.exec(['git', 'rm', file], REPO_DIR, _gitdelete);
};
_gitdelete();
};
/**
* Get the name of the main remote repository.
* Name of the remote will be in stdout.
*
* @param {execCallback} callback
*/
this.git.remote = function(callback){
self.exec(['git', 'remote'], REPO_DIR, function(error, stdout, stderr){
callback(error, stdout.trim(), stderr);
});
};
/**
* Update from GIT and pass output to the callback function.
*
* @param {execCallback} callback
*/
this.git.update = function(callback){
self.git.remote(function(error, stdout, stderr) {
if (!error && stdout) {
self.exec(['git', 'pull'], REPO_DIR, callback);
}
else {
callback(false, '', '');
}
});
};
/**
* Commit to GIT.
*
* @param {string} message - commit message
* @param {null} dummy - not used
* @param {execCallback} callback
*/
this.git.commit = function(message, dummy, callback){
self.exec(['git', 'commit', '-a', '-m', message], REPO_DIR, function(error, stdout, stderr){
if (error) {
callback(error, stdout, stderr);
return;
}
self.git.remote(function(error, stdout, stderr) {
if (!error && stdout) {
self.exec(['git', 'push'], REPO_DIR, callback);
}
else {
callback(false, '', '');
}
});
});
};
/**
* Get last commit info from GIT and pass output to the callback function.
*
* @param {execCallback} callback
*/
this.git.lastCommit = function(callback){
self.exec(['git', 'log', '--name-status', '--pretty=commit: %H%n%n%s%n%b%n%n', '-1'], REPO_DIR, callback);
};
/**
* Get last revision number from GIT and pass output to the callback function.
*
* @param {execCallback} callback
*/
this.git.lastRevision = function(callback){
self.exec(['git', 'log', '--name-status', '--pretty="commit: %H%n%n%s%n%b%n%n"', '-1'], REPO_DIR, callback);
};
/**
* Commit all changes to the GIT repository.
*
* @param {RepoChanges} changes
* @param {String} message
* @param {execCallback} callback
*/
this.git.save = function(changes, message, callback){
console.log('saving..');
// Add and Delete files to/from GIT.
self.git.add(changes.A, function(){
console.log('added');
self.git.delete(changes.D, function(){
console.log('deleted');
// Finally, commit changeset to the GIT repository.
self.git.commit(message, false, function(error, stdout, stderr){
callback(error, stdout, stderr);
});
});
});
};
/**
* "Ping" URL using HTTP GET.
*
* @param {Object} urlObject - parsed URL (http://nodejs.org/api/url.html#apicontent)
* @param {execCallback} callback
*/
this.callServer = function(urlObject, callback) {
if (!(urlObject instanceof Object)) {
urlObject = url.parse(urlObject);
}
console.log('Calling '+urlObject.href);
(urlObject.protocol == 'https:' ? https : http).get(urlObject.href, function(res) {
var stdout = '';
res.on('data', function(chunk) {
stdout += chunk;
});
res.on('end', function(chunk) {
stdout += (chunk ? chunk : '');
callback(false, stdout, '');
});
}).on('error', function(e) {
callback(true, '', e.message);
});
};
/**
* Execute a script.
* urlObject's protocol can be one of: 'node:', 'python:', 'php:', 'sh:' or 'ruby:'.
* urlObject's hostname and pathname will be used as a first argument passed to the executable.
* urlObject's query variables will be used as additional arguments passed to the executable, e.g.,
*
* node:///home/user/script.js?--something=value&--else=value2
*
* will form a command:
*
* node /home/user/script.js --somethin value --else value2
*
* @param {Object} urlObject - parsed URL (http://nodejs.org/api/url.html#apicontent)
* @param {execCallback} callback
*/
this.callScript = function(urlObject, callback) {
if (!(urlObject instanceof Object)) {
urlObject = url.parse(urlObject);
}
var command = [urlObject.protocol.replace(/\:$/, ''), path.join(urlObject.hostname || '', urlObject.pathname || '')];
if (urlObject.query) {
for (var a in urlObject.query) {
if (urlObject.query.hasOwnProperty(a)) {
command.push(a);
if (urlObject.query[a]) {
command.push(urlObject.query[a]);
}
}
}
}
self.exec(command, callback);
};
/**
* Make sure that path exists in target SFTP directory.
*
* @param {Object} sftp connection
* @param {string} targetDirectory
* @param {string} localDirectory
* @param {execCallback} callback
*/
this.sftpSyncronizeDirectoryPath = function(sftp, targetDirectory, localDirectory, callback){
var _sftpSyncronizeDirectoryPath;
var dirs = localDirectory.split(path.sep);
var cwd = targetDirectory;
_sftpSyncronizeDirectoryPath = function() {
if (dirs.length < 1) {
callback();
return;
}
cwd += dirs.shift() + '/';
sftp.stat(cwd, function(error){
if (!error) {
_sftpSyncronizeDirectoryPath();
return;
}
sftp.mkdir(cwd, function(error){
if (!error) {
_sftpSyncronizeDirectoryPath();
return;
}
callback(true, '', error);
});
});
};
_sftpSyncronizeDirectoryPath();
};
/**
* Upload files to target SFTP directory.
*
* @param {Object} sftp connection
* @param {string} targetDirectory
* @param {string} ignorePrefix - remove prefix from path before uploading, e.g., trunk/publish so file from that directory will land in targetDirectory, instead of targetDirectory/trunk/publish.
* @param {Array} fileList - list of files to be uploaded
* @param {execCallback} callback
*/
this.sftpUploadList = function(sftp, targetDirectory, ignorePrefix, fileList, callback){
var _sftpUploadList;
var files = fileList.slice();
var resultError = false;
var resultStdout = '';
var resultStderr = '';
var _logged = '';
var _logClear = function(){
process.stdout.write('SFTP: ');
_logged = '';
};
var _logStep = function(transferred, chunk, total){
var done = Math.floor(transferred / total * 20);
if (_logged.length < done) {
var out = '';
for (var i = 0; i < done - _logged.length; i++) {
_logged += '.';
out += '.';
}
process.stdout.write(out);
}
};
var ignorePath = [];
if (ignorePrefix) {
ignorePath = ignorePrefix.split(path.sep);
}
_sftpUploadList = function() {
if (files.length < 1) {
callback(resultError, resultStdout, resultStderr);
return;
}
var filePath = files.shift();
fs.stat(filePath, function(error, stats){
if (error) {
resultError = true;
stderr += error;
callback(resultError, resultStdout, resultStderr);
return;
}
var targetFile = filePath.split(path.sep);
// First part is local REPO_DIR, so shift it out.
if (targetFile[0] === REPO_DIR) {
targetFile.shift();
}
if (ignorePath.length > 0) {
ignorePath.forEach(function(value){
if (targetFile[0] === value) {
targetFile.shift();
}
});
}
targetFile = targetFile.join('/');
self.sftpSyncronizeDirectoryPath(sftp, targetDirectory, (stats.isDirectory() ? targetFile : path.dirname(targetFile)), function(error, stdout, stderr) {
resultStdout += stdout;
if (error) {
resultError = true;
resultStderr += stderr;
callback(resultError, resultStdout, resultStderr);
return;
}
targetFile = targetDirectory + targetFile;
console.log('SFTP: Uploading: ' + targetFile);
_logClear();
sftp.fastPut(filePath, targetFile, {step: _logStep}, function(error){
if (error) {
resultError = true;
resultStderr += error;
callback(resultError, resultStdout, resultStderr);
return;
}
process.stdout.write("\n");
console.log('SFTP: Uploaded: ' + targetFile);
_sftpUploadList();
});
});
});
};
_sftpUploadList();
};
/**
* Upload files to server.
* urlObject should point to the root directory, where the files will be uploaded.
*
* @param {Object} urlObject - parsed URL (http://nodejs.org/api/url.html#apicontent)
* @param {execCallback} callback
*/
this.callSFTP = function(urlObject, callback) {
if (!(urlObject instanceof Object)) {
urlObject = url.parse(urlObject);
}
if (urlObject.pathname[urlObject.pathname.length-1] != '/') {
console.log('ERROR: SFTP target directory path should end with a slash ("/").');
}
var auth = (urlObject.auth || 'username:password').split(':');
var params = (urlObject.query ? querystring.parse(urlObject.query) : false);
var settings = {
host: urlObject.hostname,
port: urlObject.port || 22,
path: urlObject.pathname || '/',
username: auth[0] || 'username',
password: auth[1] || 'password',
start: '',
end: 'HEAD',
ignorePrefix: (params && params.hasOwnProperty('ignorePrefix') ? params.ignorePrefix : '')
};
var parseUserData = function(data) {
var info = data.replace(/(^|[\r\n]+)#\s[^\r\n]*/g, '').replace(/^[\r\n\s]+|[\r\n\s]+$/g, '').split(/[\r\n]+/);
var u = url.parse(info[0]);
settings.host = u.hostname;
settings.port = u.port;
settings.path = u.pathname;
settings.start = info[1];
settings.end = info[2];
settings.username = info[3];
settings.password = info[4];
params = (u.query ? querystring.parse(u.query) : false);
settings.ignorePrefix = (params && params.hasOwnProperty('ignorePrefix') ? params.ignorePrefix : '');
};
var addChangesToString = function(data, changes) {
var result = data.replace(/(^|[\r\n]+)\# Changed:([\w\W])*$/g, '') + "\n\n# Changed:\n";
changes.A.forEach(function(value, index){
result += 'A '+value+"\n";
});
changes.M.forEach(function(value, index){
result += 'M '+value+"\n";
});
changes.D.forEach(function(value, index){
result += 'D '+value+"\n";
});
return result;
};
self.svn.lastRevision(function(error, stdout, stderr){
var HEAD = parseInt(self.svn.parseRevision(stdout), 10) || 1;
settings.end = HEAD;
settings.start = HEAD - 1;
var intro = "# Enter server URL with root path, start revision number, end revision number, user name and password - each in a new line, e.g.,\n# \tsftp://example.com:22/root/some/directory/myproject/\n# \t123\n# \t125\n# \tmyusername\n# \tmypassword\n# \n# End revision defaults to current HEAD.\n# Start revision defaults to HEAD-1.\n\nsftp://" + settings.host + ':' + settings.port + settings.path + (settings.ignorePrefix ? '?ignorePrefix='+settings.ignorePrefix : '') + "\n" + settings.start + "\n" + settings.end + "\n" + settings.username + "\n" + settings.password;
self.svn.diff(settings.start, settings.end, function(error, stdout, stderr){
var changes = self.svn.parseChanges(stdout);
var data = addChangesToString(intro, changes);
var filename = self.getDataFromUser(data, 12, 0, function(data){
parseUserData(data.toString());
//console.log(settings);
self.svn.diff(settings.start, settings.end, function(error, stdout, stderr){
var changes = self.svn.parseChanges(stdout);
//console.log(changes);
var c = new MODULES.ssh2();
c.on('connect', function(){
console.log('SSH: Connected to '+settings.host+':'+settings.port);
});
c.on('ready', function(){
console.log('SSH: ready.');
c.sftp(function(error, sftp){
if (error) {
console.log('SFTP: ERROR: '+error);
c.end();
return;
}
sftp.on('end', function(){
console.log('SFTP: end.');
c.end();
});
self.sftpUploadList(sftp, settings.path, settings.ignorePrefix, changes.A.concat(changes.M), function(error, stdout, stderr){
if (error) {
console.log('SFTP: ERROR: '+stderr);
}
else {
console.log('SFTP: upload finished.');
}
c.end();
});
});
});
c.on('error', function(error){
console.log('SSH: ERROR: '+error);
});
c.on('end', function(){
console.log('SSH: end.');
});
c.on('close', function(){
console.log('SSH: closed.');
callback(false, 'Finished', '');
});
console.log('SSH: connecting to server...');
c.connect({
host: settings.host,
port: settings.port,
username: settings.username,
password: settings.password
});
});
});
fs.watch(filename, function(event){
var start = settings.start;
var end = settings.end;
if (event === 'change') {
var data = fs.readFileSync(filename);
parseUserData(data.toString());
if (settings.start != start || settings.end != end) {
self.svn.diff(settings.start, settings.end, function(error, stdout, stderr){
var changes = self.svn.parseChanges(stdout);
data = addChangesToString(fs.readFileSync(filename).toString(), changes);
fs.writeFileSync(filename, data);
});
}
}
});
});
});
};
/**
* Initialize protocols map.
* This function is synchronous!
*/
this.initProtocols = function(){
PROTOCOLS['python:'] = self.callScript;
PROTOCOLS['node:'] = self.callScript;
PROTOCOLS['ruby:'] = self.callScript;
PROTOCOLS['php:'] = self.callScript;
PROTOCOLS['cmd:'] = self.callScript;
PROTOCOLS['sh:'] = self.callScript;
PROTOCOLS['https:'] = self.callServer;
PROTOCOLS['http:'] = self.callServer;
PROTOCOLS['svn:'] = self.commit;
PROTOCOLS['git:'] = self.commit;
if (MODULES.hasOwnProperty('ssh2') && MODULES.ssh2) {
PROTOCOLS['sftp:'] = self.callSFTP;
}
};
/**
* Automagically select repository manager.
*
* @param {execCallback} callback
*/
this.initRepositoryManager = function(managers, callback){
var _checkRepo;
var _managers = managers.slice();
self.repo = null;
_checkRepo = function(){
if (managers.length < 1) {
callback('No supported repository could be found.');
return;
}
var type = managers.shift();
fs.exists(path.join(REPO_DIR, '.'+type), function(exists){
if (exists) {
self.repo = self[type];
callback(false, 'Using '+type.toUpperCase()+' repository.', '');
}
else {
_checkRepo();
}
});
};
_checkRepo();
};
// Parse REPO_NAME and REPO_TYPE (if available).
(function(){
var type = REPO_DIR.match(/^(svn|git):\/\//i);
if (type && type.length > 1 && type[1]) {
REPO_DIR = REPO_DIR.replace(/^(svn|git):\/\//i, '');
REPO_TYPE = type[1];
}
})();
// Some initialization checks.
fs.exists(REPO_DIR, function(exists){
if (!exists) {
process.nextTick(function(){
self.emit('error', 'could not find REPO_DIR called "'+REPO_DIR+'"');
});
return;
}
var repoManagers = (REPO_TYPE ? [REPO_TYPE] : ['git', 'svn']);
self.initRepositoryManager(repoManagers, function(error, stdout, stderr){
if (error) {
self.emit('error', error + (stderr ? stderr : ''));
return;
}
console.log(stdout);
// Try to install/require ssh2 module (https://github.com/mscdex/ssh2), for our sftp command to use.
self.require('ssh2', function(error, stdout, stderr){
if (error) {
console.log('ERROR: could not install ssh2 module - ssh2ftp command will not be available.');
console.log(stdout+stderr);
}
self.initProtocols();
process.nextTick(function(){
self.emit('ready');
});
});
});
});
};
// Inherit Builder from EventEmitter.
util.inherits(Builder, emitter);
// Autorun when called directly as a utility, instead of required as a library.
if (process.argv[1] != path.basename(__filename)) (function(){
var repo = process.argv[2] || false;
if (!repo) {
console.log('USAGE: node '+path.basename(__filename)+' REPOSITORY_NAME [URL, ...]');
return;
}
var builder = new Builder(repo);
builder.on('ready', function(){
builder.commit(function(error, stdout, stderr){
console.log(stdout+(error ? stderr : ''));
var commands = process.argv.length - 3;
var outputter = function(error, stdout, stderr){
console.log(stdout+(error ? stderr : ''));
commands--;
if (commands < 1) {
process.exit();
}
};
if (commands > 0 && process.argv[3]) {
for (var i = 3; i < process.argv.length; i++) {
builder.callURL(process.argv[i], outputter);
}
}
else {
process.exit();
}
});
});
builder.on('error', function(error){
console.log('ERROR: '+error);
process.exit(-1);
});
}());