quadre-git
Version:
Integration of Git into Quadre
329 lines (284 loc) • 12.9 kB
text/typescript
import { ExtensionUtils, NodeConnection } from "./brackets-modules";
import * as Promise from "bluebird";
import * as Strings from "strings";
import * as ErrorHandler from "./ErrorHandler";
import ExpectedError from "./ExpectedError";
import * as Preferences from "./Preferences";
import * as Utils from "./Utils";
const debugOn = Preferences.get("debugMode");
const gitTimeout = Preferences.get("gitTimeout") * 1000;
const domainName = "quadre-git";
const nodeConnection = new NodeConnection();
let nextCliId = 0;
const deferredMap = {};
const MAX_COUNTER_VALUE = 4294967295; // 2^32 - 1
const EVENT_NAMESPACE = ".quadreGitEvent";
function getNextCliId() {
if (nextCliId >= MAX_COUNTER_VALUE) {
nextCliId = 0;
}
return ++nextCliId;
}
function attachEventHandlers() {
nodeConnection
.off(EVENT_NAMESPACE)
.on(domainName + ":progress" + EVENT_NAMESPACE, (err, cliId, time, message) => {
if (err) {
ErrorHandler.logError(`Progress event error: ${err}`);
}
const deferred = deferredMap[cliId];
if (deferred && !deferred.isResolved()) {
deferred.progress(message);
} else {
ErrorHandler.logError("Progress sent for a non-existing process(" + cliId + "): " + message);
}
});
}
let connectPromise = null;
// return true/false to state if wasConnected before
function connectToNode() {
if (connectPromise) {
return connectPromise;
}
const moduleDirectory = Utils.getExtensionDirectory();
const domainModulePath = moduleDirectory + "dist/node/cli";
connectPromise = new Promise((resolve, reject) => {
if (nodeConnection.connected()) {
return resolve(true);
}
// we don't want automatic reconnections as we handle the reconnect manually
nodeConnection.connect(false).then(() => {
nodeConnection.loadDomains([domainModulePath], false).then(() => {
attachEventHandlers();
resolve(false);
}).fail((err) => { // jQuery promise - .fail is fine
reject(ErrorHandler.toError(err));
});
}).fail((err) => { // jQuery promise - .fail is fine
if (ErrorHandler.contains(err, "Max connection attempts reached")) {
Utils.consoleLog("Max connection attempts reached, trying again.", "warn");
// try again
connectPromise = null;
connectToNode()
.then((result) => { resolve(result); })
.catch((err2) => { reject(err2); });
return;
}
reject(ErrorHandler.toError(err));
});
});
connectPromise.finally(() => connectPromise = null);
return connectPromise;
}
function normalizePathForOs(path) {
return brackets.platform === "win" ? path.replace(/\//g, "\\") : path;
}
// this functions prevents sensitive info from going further (like http passwords)
function sanitizeOutput(str): string {
if (typeof str !== "string") {
if (str != null) { // checks for both null & undefined
return str.toString();
}
return "";
}
return str;
}
function logDebug(opts, debugInfo, method, type, out?) {
const processInfo = [];
const duration = (new Date()).getTime() - debugInfo.startTime;
processInfo.push(duration + "ms");
if (!debugInfo.wasConnected) {
processInfo.push("+conn");
}
if (opts.cliId) {
processInfo.push("ID=" + opts.cliId);
}
let msg = "cmd-" + method + "-" + type + " (" + processInfo.join(";") + ")";
if (out) { msg += ": \"" + out + "\""; }
Utils.consoleDebug(msg);
}
export interface CliOptions {
cwd?: string;
nonblocking?: boolean;
timeout?: number | boolean;
timeoutCheck?: () => any;
timeoutExpected?: boolean;
}
export function cliHandler(method, cmd, args = [], opts: CliOptions = {}, retry = false): Promise<string | null> {
const cliId = getNextCliId();
const deferred = Promise.defer() as Promise.Resolver<string | null>;
deferredMap[cliId] = deferred;
const watchProgress = args.indexOf("--progress") !== -1;
// it is possible to set a custom working directory in options
// otherwise the current project root is used to execute commands
if (!opts.cwd) {
opts.cwd = Preferences.get("currentGitRoot") || Utils.getProjectRoot();
}
// convert paths like c:/foo/bar to c:\foo\bar on windows
opts.cwd = normalizePathForOs(opts.cwd);
// log all cli communication into console when debug mode is on
let startTime;
if (debugOn) {
startTime = (new Date()).getTime();
Utils.consoleDebug("cmd-" + method + (watchProgress ? "-watch" : "") + ": " +
opts.cwd + " -> " +
cmd + " " + args.join(" "));
}
// we connect to node (promise is returned immediately if we are already connected)
connectToNode().catch((err) => {
// failed to connect to node for some reason
throw ErrorHandler.showError(new ExpectedError(err), Strings.ERROR_CONNECT_NODEJS);
}).then((wasConnected) => {
let resolved = false;
const timeoutLength = typeof opts.timeout === "number" && opts.timeout > 0 ? (opts.timeout * 1000) : gitTimeout;
const domainOpts = { cliId, watchProgress };
const debugInfo = { startTime, wasConnected };
if (watchProgress) {
deferred.progress("Running command: git " + args.join(" "));
}
// nodeConnection returns jQuery deferred
nodeConnection.domains[domainName][method](opts.cwd, cmd, args, domainOpts)
.fail((_stderr) => { // jQuery promise - .fail is fine
if (!resolved) {
const stderr = sanitizeOutput(_stderr);
if (debugOn) {
logDebug(domainOpts, debugInfo, method, "fail", stderr);
}
delete deferredMap[cliId];
const err = ErrorHandler.toError(stderr);
// spawn ENOENT error
const invalidCwdErr = "spawn ENOENT";
if (err.stack && err.stack.indexOf(invalidCwdErr)) {
err.message = err.message.replace(invalidCwdErr, invalidCwdErr + " (" + opts.cwd + ")");
err.stack = err.stack.replace(invalidCwdErr, invalidCwdErr + " (" + opts.cwd + ")");
}
// socket was closed so we should try this once again (if not already retrying)
if (err.stack && err.stack.indexOf("WebSocket.self._ws.onclose") !== -1 && !retry) {
cliHandler(method, cmd, args, opts, true)
.then((response) => deferred.resolve(response))
.catch((err2) => deferred.reject(err2));
return;
}
deferred.reject(err);
}
})
.then((stdout) => {
if (!resolved) {
const out = sanitizeOutput(stdout);
if (debugOn) {
logDebug(domainOpts, debugInfo, method, "out", out);
}
delete deferredMap[cliId];
deferred.resolve(out);
}
})
.always(() => resolved = true)
.done();
function timeoutPromise() {
if (debugOn) {
logDebug(domainOpts, debugInfo, method, "timeout");
}
const err = new Error("cmd-" + method + "-timeout: " + cmd + " " + args.join(" "));
if (!opts.timeoutExpected) {
ErrorHandler.logError(err);
}
// process still lives and we need to kill it
nodeConnection.domains[domainName].kill(domainOpts.cliId)
.fail((err2) => ErrorHandler.logError(err2));
delete deferredMap[cliId];
deferred.reject(ErrorHandler.toError(err));
resolved = true;
}
let lastProgressTime = 0;
function timeoutCall() {
setTimeout(() => {
if (!resolved) {
if (typeof opts.timeoutCheck === "function") {
Promise.cast(opts.timeoutCheck())
.catch((err) => {
ErrorHandler.logError("timeoutCheck failed: " + opts.timeoutCheck.toString());
ErrorHandler.logError(err);
})
.then((continueExecution) => {
if (continueExecution) {
// check again later
timeoutCall();
} else {
timeoutPromise();
}
});
} else if (domainOpts.watchProgress) {
// we are watching the promise progress in the domain
// so we should check if the last message was sent in more than timeout time
const currentTime = (new Date()).getTime();
const diff = currentTime - lastProgressTime;
if (diff > timeoutLength) {
if (debugOn) {
Utils.consoleDebug(
"cmd(" + cliId + ") - last progress message was sent " + diff + "ms ago - timeout"
);
}
timeoutPromise();
} else {
if (debugOn) {
Utils.consoleDebug(
"cmd(" + cliId + ") - last progress message was sent " + diff + "ms ago - delay"
);
}
timeoutCall();
}
} else {
// we don't have any custom handler, so just kill the promise here
// note that command WILL keep running in the background
// so even when timeout occurs, operation might finish after it
timeoutPromise();
}
}
}, timeoutLength);
}
// when opts.timeout === false then never timeout the process
if (opts.timeout !== false) {
// if we are watching for progress events, mark the time when last progress was made
if (domainOpts.watchProgress) {
deferred.promise.progressed(() => lastProgressTime = (new Date()).getTime());
}
// call the method which will timeout the promise after a certain period of time
timeoutCall();
}
}).catch((err) => {
throw ErrorHandler.showError(
err, "Unexpected error in CLI handler - close all instances of Quadre and start again to reload"
);
});
return deferred.promise;
}
export function which(cmd) {
return cliHandler("which", cmd);
}
export function pathExists(path) {
return cliHandler("pathExists", path).then((response) => {
return typeof response === "string" ? response === "true" : response;
});
}
export function spawnCommand(cmd, args = [], opts = {}): Promise<string | null> {
return cliHandler("spawn", cmd, args, opts);
}
export function executeCommand(cmd, args = [], opts = {}): Promise<string | null> {
return cliHandler("execute", cmd, args, opts);
}
// this is to be used together with executeCommand
// spawnCommand does the escaping automatically
export function escapeShellArg(str) {
if (typeof str !== "string") {
throw new Error("escapeShellArg argument is not a string: " + typeof str);
}
if (str.length === 0) {
return str;
}
if (brackets.platform !== "win") {
// http://steve-parker.org/sh/escape.shtml
return "\"" + str.replace(/["$`\\]/g, (m) => "\\" + m) + "\"";
}
// http://stackoverflow.com/questions/7760545/cmd-escape-double-quotes-in-parameter
return "\"" + str.replace(/"/g, () => "\"\"\"") + "\"";
}