adobe/brackets

View on GitHub
src/extensibility/node/ExtensionManagerDomain.js

Summary

Maintainability
F
5 days
Test Coverage
/*
 * Copyright (c) 2013 - present Adobe Systems Incorporated. All rights reserved.
 *
 * Permission is hereby granted, free of charge, to any person obtaining a
 * copy of this software and associated documentation files (the "Software"),
 * to deal in the Software without restriction, including without limitation
 * the rights to use, copy, modify, merge, publish, distribute, sublicense,
 * and/or sell copies of the Software, and to permit persons to whom the
 * Software is furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
 * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 * DEALINGS IN THE SOFTWARE.
 *
 */

/*eslint-env node */
/*jslint node: true */

"use strict";

var semver   = require("semver"),
    path     = require("path"),
    request  = require("request"),
    fs       = require("fs-extra"),
    temp     = require("temp"),
    validate = require("./package-validator").validate;

// Automatically clean up temp files on exit
temp.track();

var Errors = {
    API_NOT_COMPATIBLE: "API_NOT_COMPATIBLE",
    MISSING_REQUIRED_OPTIONS: "MISSING_REQUIRED_OPTIONS",
    DOWNLOAD_ID_IN_USE: "DOWNLOAD_ID_IN_USE",
    BAD_HTTP_STATUS: "BAD_HTTP_STATUS",             // {0} is the HTTP status code
    NO_SERVER_RESPONSE: "NO_SERVER_RESPONSE",
    CANNOT_WRITE_TEMP: "CANNOT_WRITE_TEMP",
    CANCELED: "CANCELED"
};

var Statuses = {
    FAILED: "FAILED",
    INSTALLED: "INSTALLED",
    ALREADY_INSTALLED: "ALREADY_INSTALLED",
    SAME_VERSION: "SAME_VERSION",
    OLDER_VERSION: "OLDER_VERSION",
    NEEDS_UPDATE: "NEEDS_UPDATE",
    DISABLED: "DISABLED"
};

/**
 * Maps unique download ID to info about the pending download. No entry if download no longer pending.
 * outStream is only present if we've started receiving the body.
 * @type {Object.<string, {request:!http.ClientRequest, callback:!function(string, string), localPath:string, outStream:?fs.WriteStream}>}
 */
var pendingDownloads = {};

/**
 * Private function to remove the installation directory if the installation fails.
 * This does not call any callbacks. It's assumed that the callback has already been called
 * and this cleanup routine will do its best to complete in the background. If there's
 * a problem here, it is simply logged with console.error.
 *
 * @param {string} installDirectory Directory to remove
 */
function _removeFailedInstallation(installDirectory) {
    fs.remove(installDirectory, function (err) {
        if (err) {
            console.error("Error while removing directory after failed installation", installDirectory, err);
        }
    });
}

/**
 * Private function to unzip to the correct directory.
 *
 * @param {string} Absolute path to the package zip file
 * @param {string} Absolute path to the destination directory for unzipping
 * @param {Object} the return value with the useful information for the client
 * @param {Function} callback function that is called at the end of the unzipping
 */
function _performInstall(packagePath, installDirectory, validationResult, callback) {
    validationResult.installedTo = installDirectory;

    function fail(err) {
        _removeFailedInstallation(installDirectory);
        callback(err, null);
    }

    function finish() {
        // The status may have already been set previously (as in the
        // DISABLED case.
        if (!validationResult.installationStatus) {
            validationResult.installationStatus = Statuses.INSTALLED;
        }
        callback(null, validationResult);
    }

    fs.mkdirs(installDirectory, function (err) {
        if (err) {
            callback(err);
            return;
        }
        var sourceDir = path.join(validationResult.extractDir, validationResult.commonPrefix);

        fs.copy(sourceDir, installDirectory, function (err) {
            if (err) {
                return fail(err);
            }
            finish();
        });
    });
}

/**
 * Private function to remove the target directory and then install.
 *
 * @param {string} Absolute path to the package zip file
 * @param {string} Absolute path to the destination directory for unzipping
 * @param {Object} the return value with the useful information for the client
 * @param {Function} callback function that is called at the end of the unzipping
 */
function _removeAndInstall(packagePath, installDirectory, validationResult, callback) {
    // If this extension was previously installed but disabled, we will overwrite the
    // previous installation in that directory.
    fs.remove(installDirectory, function (err) {
        if (err) {
            callback(err);
            return;
        }
        _performInstall(packagePath, installDirectory, validationResult, callback);
    });
}

function _checkExistingInstallation(validationResult, installDirectory, systemInstallDirectory, callback) {
    // If the extension being installed does not have a package.json, we can't
    // do any kind of version comparison, so we just signal to the UI that
    // it already appears to be installed.
    if (!validationResult.metadata) {
        validationResult.installationStatus = Statuses.ALREADY_INSTALLED;
        callback(null, validationResult);
        return;
    }

    fs.readJson(path.join(installDirectory, "package.json"), function (err, packageObj) {
        // if the package.json is unreadable, we assume that the new package is an update
        // that is the first to include a package.json.
        if (err) {
            validationResult.installationStatus = Statuses.NEEDS_UPDATE;
        } else {
            // Check to see if the version numbers signal an update.
            if (semver.lt(packageObj.version, validationResult.metadata.version)) {
                validationResult.installationStatus = Statuses.NEEDS_UPDATE;
            } else if (semver.gt(packageObj.version, validationResult.metadata.version)) {
                // Pass a message back to the UI that the new package appears to be an older version
                // than what's installed.
                validationResult.installationStatus = Statuses.OLDER_VERSION;
                validationResult.installedVersion = packageObj.version;
            } else {
                // Signal to the UI that it looks like the user is re-installing the
                // same version.
                validationResult.installationStatus = Statuses.SAME_VERSION;
            }
        }
        callback(null, validationResult);
    });
}

/**
 * A "legacy package" is an extension that was installed based on the GitHub name without
 * a package.json file. Checking for the presence of these legacy extensions will help
 * users upgrade if the extension developer puts a different name in package.json than
 * the name of the GitHub project.
 *
 * @param {string} legacyDirectory directory to check for old-style extension.
 */
function legacyPackageCheck(legacyDirectory) {
    return fs.existsSync(legacyDirectory) && !fs.existsSync(path.join(legacyDirectory, "package.json"));
}

/**
 * Implements the "install" command in the "extensions" domain.
 *
 * There is no need to call validate independently. Validation is the first
 * thing that is done here.
 *
 * After the extension is validated, it is installed in destinationDirectory
 * unless the extension is already present there. If it is already present,
 * a determination is made about whether the package being installed is
 * an update. If it does appear to be an update, then result.installationStatus
 * is set to NEEDS_UPDATE. If not, then it's set to ALREADY_INSTALLED.
 *
 * If the installation succeeds, then result.installationStatus is set to INSTALLED.
 *
 * The extension is unzipped into a directory in destinationDirectory with
 * the name of the extension (the name is derived either from package.json
 * or the name of the zip file).
 *
 * The destinationDirectory will be created if it does not exist.
 *
 * @param {string} Absolute path to the package zip file
 * @param {string} the destination directory
 * @param {{disabledDirectory: !string, apiVersion: !string, nameHint: ?string,
 *      systemExtensionDirectory: !string}} additional settings to control the installation
 * @param {function} callback (err, result)
 * @param {function} pCallback (msg) callback for notifications about operation progress
 * @param {boolean} _doUpdate  private argument to signal that an update should be performed
 */
function _cmdInstall(packagePath, destinationDirectory, options, callback, pCallback, _doUpdate) {
    if (!options || !options.disabledDirectory || !options.apiVersion || !options.systemExtensionDirectory) {
        callback(new Error(Errors.MISSING_REQUIRED_OPTIONS), null);
        return;
    }

    function validateCallback(err, validationResult) {
        validationResult.localPath = packagePath;

        // This is a wrapper for the callback that will delete the temporary
        // directory to which the package was unzipped.
        function deleteTempAndCallback(err) {
            if (validationResult.extractDir) {
                fs.remove(validationResult.extractDir);
                delete validationResult.extractDir;
            }
            callback(err, validationResult);
        }

        // If there was trouble at the validation stage, we stop right away.
        if (err || validationResult.errors.length > 0) {
            validationResult.installationStatus = Statuses.FAILED;
            deleteTempAndCallback(err);
            return;
        }

        // Prefers the package.json name field, but will take the zip
        // file's name if that's all that's available.
        var extensionName, guessedName;
        if (options.nameHint) {
            guessedName = path.basename(options.nameHint, ".zip");
        } else {
            guessedName = path.basename(packagePath, ".zip");
        }
        if (validationResult.metadata) {
            extensionName = validationResult.metadata.name;
        } else {
            extensionName = guessedName;
        }

        validationResult.name = extensionName;
        var installDirectory = path.join(destinationDirectory, extensionName),
            legacyDirectory = path.join(destinationDirectory, guessedName),
            systemInstallDirectory = path.join(options.systemExtensionDirectory, extensionName);

        if (validationResult.metadata && validationResult.metadata.engines &&
                validationResult.metadata.engines.brackets) {
            var compatible = semver.satisfies(options.apiVersion,
                                              validationResult.metadata.engines.brackets);
            if (!compatible) {
                installDirectory = path.join(options.disabledDirectory, extensionName);
                validationResult.installationStatus = Statuses.DISABLED;
                validationResult.disabledReason = Errors.API_NOT_COMPATIBLE;
                _removeAndInstall(packagePath, installDirectory, validationResult, deleteTempAndCallback);
                return;
            }
        }

        // The "legacy" stuff should go away after all of the commonly used extensions
        // have been upgraded with package.json files.
        var hasLegacyPackage = validationResult.metadata && legacyPackageCheck(legacyDirectory);

        // If the extension is already there, we signal to the front end that it's already installed
        // unless the front end has signaled an intent to update.
        if (hasLegacyPackage || fs.existsSync(installDirectory) || fs.existsSync(systemInstallDirectory)) {
            if (_doUpdate === true) {
                if (hasLegacyPackage) {
                    // When there's a legacy installed extension, remove it first,
                    // then also remove any new-style directory the user may have.
                    // This helps clean up if the user is in a state where they have
                    // both legacy and new extensions installed.
                    fs.remove(legacyDirectory, function (err) {
                        if (err) {
                            deleteTempAndCallback(err);
                            return;
                        }
                        _removeAndInstall(packagePath, installDirectory, validationResult, deleteTempAndCallback);
                    });
                } else {
                    _removeAndInstall(packagePath, installDirectory, validationResult, deleteTempAndCallback);
                }
            } else if (hasLegacyPackage) {
                validationResult.installationStatus = Statuses.NEEDS_UPDATE;
                validationResult.name = guessedName;
                deleteTempAndCallback(null);
            } else {
                _checkExistingInstallation(validationResult, installDirectory, systemInstallDirectory, deleteTempAndCallback);
            }
        } else {
            // Regular installation with no conflicts.
            validationResult.disabledReason = null;
            _performInstall(packagePath, installDirectory, validationResult, deleteTempAndCallback);
        }
    }

    validate(packagePath, options, validateCallback);
}

/**
 * Implements the "update" command in the "extensions" domain.
 *
 * Currently, this just wraps _cmdInstall, but will remove the existing directory
 * first.
 *
 * There is no need to call validate independently. Validation is the first
 * thing that is done here.
 *
 * After the extension is validated, it is installed in destinationDirectory
 * unless the extension is already present there. If it is already present,
 * a determination is made about whether the package being installed is
 * an update. If it does appear to be an update, then result.installationStatus
 * is set to NEEDS_UPDATE. If not, then it's set to ALREADY_INSTALLED.
 *
 * If the installation succeeds, then result.installationStatus is set to INSTALLED.
 *
 * The extension is unzipped into a directory in destinationDirectory with
 * the name of the extension (the name is derived either from package.json
 * or the name of the zip file).
 *
 * The destinationDirectory will be created if it does not exist.
 *
 * @param {string} Absolute path to the package zip file
 * @param {string} the destination directory
 * @param {{disabledDirectory: !string, apiVersion: !string, nameHint: ?string,
 *      systemExtensionDirectory: !string}} additional settings to control the installation
 * @param {function} callback (err, result)
 * @param {function} pCallback (msg) callback for notifications about operation progress
 */
function _cmdUpdate(packagePath, destinationDirectory, options, callback, pCallback) {
    _cmdInstall(packagePath, destinationDirectory, options, callback, pCallback, true);
}

/**
 * Wrap up after the given download has terminated (successfully or not). Closes connections, calls back the
 * client's callback, and IF there was an error, delete any partially-downloaded file.
 *
 * @param {string} downloadId Unique id originally passed to _cmdDownloadFile()
 * @param {?string} error If null, download was treated as successful
 */
function _endDownload(downloadId, error) {
    var downloadInfo = pendingDownloads[downloadId];
    delete pendingDownloads[downloadId];

    if (error) {
        // Abort the download if still pending
        // Note that this will trigger response's "end" event
        downloadInfo.request.abort();

        // Clean up any partially-downloaded file
        // (if no outStream, then we never got a response back yet and never created any file)
        if (downloadInfo.outStream) {
            downloadInfo.outStream.end(function () {
                fs.unlink(downloadInfo.localPath);
            });
        }

        downloadInfo.callback(error, null);

    } else {
        // Download completed successfully. Flush stream to disk and THEN signal completion
        downloadInfo.outStream.end(function () {
            downloadInfo.callback(null, downloadInfo.localPath);
        });
    }
}

/**
 * Implements "downloadFile" command, asynchronously.
 */
function _cmdDownloadFile(downloadId, url, proxy, callback, pCallback) {
    // Backwards compatibility check, added in 0.37
    if (typeof proxy === "function") {
        callback = proxy;
        proxy = undefined;
    }

    if (pendingDownloads[downloadId]) {
        callback(Errors.DOWNLOAD_ID_IN_USE, null);
        return;
    }

    var req = request.get({
        url: url,
        encoding: null,
        proxy: proxy
    },
        // Note: we could use the traditional "response"/"data"/"end" events too if we wanted to stream data
        // incrementally, limit download size, etc. - but the simple callback is good enough for our needs.
        function (error, response, body) {
            if (error) {
                // Usually means we never got a response - server is down, no DNS entry, etc.
                _endDownload(downloadId, Errors.NO_SERVER_RESPONSE);
                return;
            }
            if (response.statusCode !== 200) {
                _endDownload(downloadId, [Errors.BAD_HTTP_STATUS, response.statusCode]);
                return;
            }

            var stream = temp.createWriteStream("brackets");
            if (!stream) {
                _endDownload(downloadId, Errors.CANNOT_WRITE_TEMP);
                return;
            }
            pendingDownloads[downloadId].localPath = stream.path;
            pendingDownloads[downloadId].outStream = stream;

            stream.write(body);
            _endDownload(downloadId);
        });

    pendingDownloads[downloadId] = { request: req, callback: callback };
}

/**
 * Implements "abortDownload" command, synchronously.
 */
function _cmdAbortDownload(downloadId) {
    if (!pendingDownloads[downloadId]) {
        // This may mean the download already completed
        return false;
    } else {
        _endDownload(downloadId, Errors.CANCELED);
        return true;
    }
}

/**
 * Implements the remove extension command.
 */
function _cmdRemove(extensionDir, callback, pCallback) {
    fs.remove(extensionDir, function (err) {
        if (err) {
            callback(err);
        } else {
            callback(null);
        }
    });
}

/**
 * Initialize the "extensions" domain.
 * The extensions domain handles downloading, unpacking/verifying, and installing extensions.
 */
function init(domainManager) {
    if (!domainManager.hasDomain("extensionManager")) {
        domainManager.registerDomain("extensionManager", {major: 0, minor: 1});
    }
    domainManager.registerCommand(
        "extensionManager",
        "validate",
        validate,
        true,
        "Verifies that the contents of the given ZIP file are a valid Brackets extension package",
        [{
            name: "path",
            type: "string",
            description: "absolute filesystem path of the extension package"
        }, {
            name: "options",
            type: "{requirePackageJSON: ?boolean}",
            description: "options to control the behavior of the validator"
        }],
        [{
            name: "errors",
            type: "string|Array.<string>",
            description: "download error, if any; first string is error code (one of Errors.*); subsequent strings are additional info"
        }, {
            name: "metadata",
            type: "{name: string, version: string}",
            description: "all package.json metadata (null if there's no package.json)"
        }]
    );
    domainManager.registerCommand(
        "extensionManager",
        "install",
        _cmdInstall,
        true,
        "Installs the given Brackets extension if it is valid (runs validation command automatically)",
        [{
            name: "path",
            type: "string",
            description: "absolute filesystem path of the extension package"
        }, {
            name: "destinationDirectory",
            type: "string",
            description: "absolute filesystem path where this extension should be installed"
        }, {
            name: "options",
            type: "{disabledDirectory: !string, apiVersion: !string, nameHint: ?string, systemExtensionDirectory: !string, proxy: ?string}",
            description: "installation options: disabledDirectory should be set so that extensions can be installed disabled."
        }],
        [{
            name: "errors",
            type: "string|Array.<string>",
            description: "download error, if any; first string is error code (one of Errors.*); subsequent strings are additional info"
        }, {
            name: "metadata",
            type: "{name: string, version: string}",
            description: "all package.json metadata (null if there's no package.json)"
        }, {
            name: "disabledReason",
            type: "string",
            description: "reason this extension was installed disabled (one of Errors.*), none if it was enabled"
        }, {
            name: "installationStatus",
            type: "string",
            description: "Current status of the installation (an extension can be valid but not installed because it's an update"
        }, {
            name: "installedTo",
            type: "string",
            description: "absolute path where the extension was installed to"
        }, {
            name: "commonPrefix",
            type: "string",
            description: "top level directory in the package zip which contains all of the files"
        }]
    );
    domainManager.registerCommand(
        "extensionManager",
        "update",
        _cmdUpdate,
        true,
        "Updates the given Brackets extension (for which install was generally previously attemped). Brackets must be quit after this.",
        [{
            name: "path",
            type: "string",
            description: "absolute filesystem path of the extension package"
        }, {
            name: "destinationDirectory",
            type: "string",
            description: "absolute filesystem path where this extension should be installed"
        }, {
            name: "options",
            type: "{disabledDirectory: !string, apiVersion: !string, nameHint: ?string, systemExtensionDirectory: !string}",
            description: "installation options: disabledDirectory should be set so that extensions can be installed disabled."
        }],
        [{
            name: "errors",
            type: "string|Array.<string>",
            description: "download error, if any; first string is error code (one of Errors.*); subsequent strings are additional info"
        }, {
            name: "metadata",
            type: "{name: string, version: string}",
            description: "all package.json metadata (null if there's no package.json)"
        }, {
            name: "disabledReason",
            type: "string",
            description: "reason this extension was installed disabled (one of Errors.*), none if it was enabled"
        }, {
            name: "installationStatus",
            type: "string",
            description: "Current status of the installation (an extension can be valid but not installed because it's an update"
        }, {
            name: "installedTo",
            type: "string",
            description: "absolute path where the extension was installed to"
        }, {
            name: "commonPrefix",
            type: "string",
            description: "top level directory in the package zip which contains all of the files"
        }]
    );
    domainManager.registerCommand(
        "extensionManager",
        "remove",
        _cmdRemove,
        true,
        "Removes the Brackets extension at the given path.",
        [{
            name: "path",
            type: "string",
            description: "absolute filesystem path of the installed extension folder"
        }],
        {}
    );
    domainManager.registerCommand(
        "extensionManager",
        "downloadFile",
        _cmdDownloadFile,
        true,
        "Downloads the file at the given URL, saving it to a temp location. Callback receives path to the downloaded file.",
        [{
            name: "downloadId",
            type: "string",
            description: "Unique identifier for this download 'session'"
        }, {
            name: "url",
            type: "string",
            description: "URL to download from"
        }, {
            name: "proxy",
            type: "string",
            description: "optional proxy URL"
        }],
        {
            type: "string",
            description: "Local path to the downloaded file"
        }
    );
    domainManager.registerCommand(
        "extensionManager",
        "abortDownload",
        _cmdAbortDownload,
        false,
        "Aborts any pending download with the given id. Ignored if no download pending (may be already complete).",
        [{
            name: "downloadId",
            type: "string",
            description: "Unique identifier for this download 'session', previously pased to downloadFile"
        }],
        {
            type: "boolean",
            description: "True if the download was pending and able to be canceled; false otherwise"
        }
    );
}

// used in unit tests
exports._cmdValidate = validate;
exports._cmdInstall = _cmdInstall;
exports._cmdRemove = _cmdRemove;
exports._cmdUpdate = _cmdUpdate;

// used to load the domain
exports.init = init;