diff --git a/.gitignore b/.gitignore index 1576fba..b3aa41a 100644 --- a/.gitignore +++ b/.gitignore @@ -27,9 +27,6 @@ build/Release # https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git node_modules -# Build specific exclusions -*/bin/ - # Environment variables *.env diff --git a/cli/bin/script/acquisition-sdk.js b/cli/bin/script/acquisition-sdk.js new file mode 100644 index 0000000..94885f3 --- /dev/null +++ b/cli/bin/script/acquisition-sdk.js @@ -0,0 +1,178 @@ +"use strict"; +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +Object.defineProperty(exports, "__esModule", { value: true }); +exports.AcquisitionManager = exports.AcquisitionStatus = void 0; +class AcquisitionStatus { + static DeploymentSucceeded = "DeploymentSucceeded"; + static DeploymentFailed = "DeploymentFailed"; +} +exports.AcquisitionStatus = AcquisitionStatus; +class AcquisitionManager { + _appVersion; + _clientUniqueId; + _deploymentKey; + _httpRequester; + _ignoreAppVersion; + _serverUrl; + constructor(httpRequester, configuration) { + this._httpRequester = httpRequester; + this._serverUrl = configuration.serverUrl; + if (this._serverUrl.slice(-1) !== "/") { + this._serverUrl += "/"; + } + this._appVersion = configuration.appVersion; + this._clientUniqueId = configuration.clientUniqueId; + this._deploymentKey = configuration.deploymentKey; + this._ignoreAppVersion = configuration.ignoreAppVersion; + } + queryUpdateWithCurrentPackage(currentPackage, callback) { + if (!currentPackage || !currentPackage.appVersion) { + throw new Error("Calling common acquisition SDK with incorrect package"); // Unexpected; indicates error in our implementation + } + const updateRequest = { + deploymentKey: this._deploymentKey, + appVersion: currentPackage.appVersion, + packageHash: currentPackage.packageHash, + isCompanion: this._ignoreAppVersion, + label: currentPackage.label, + clientUniqueId: this._clientUniqueId, + }; + const requestUrl = this._serverUrl + "updateCheck?" + queryStringify(updateRequest); + this._httpRequester.request(0 /* Http.Verb.GET */, requestUrl, (error, response) => { + if (error) { + callback(error, /*remotePackage=*/ null); + return; + } + if (response.statusCode !== 200) { + callback(new Error(response.statusCode + ": " + response.body), /*remotePackage=*/ null); + return; + } + let updateInfo; + try { + const responseObject = JSON.parse(response.body); + updateInfo = responseObject.updateInfo; + } + catch (error) { + callback(error, /*remotePackage=*/ null); + return; + } + if (!updateInfo) { + callback(error, /*remotePackage=*/ null); + return; + } + else if (updateInfo.updateAppVersion) { + callback(/*error=*/ null, { + updateAppVersion: true, + appVersion: updateInfo.appVersion, + }); + return; + } + else if (!updateInfo.isAvailable) { + callback(/*error=*/ null, /*remotePackage=*/ null); + return; + } + const remotePackage = { + deploymentKey: this._deploymentKey, + description: updateInfo.description, + label: updateInfo.label, + appVersion: updateInfo.appVersion, + isMandatory: updateInfo.isMandatory, + packageHash: updateInfo.packageHash, + packageSize: updateInfo.packageSize, + downloadUrl: updateInfo.downloadURL, + }; + callback(/*error=*/ null, remotePackage); + }); + } + reportStatusDeploy(deployedPackage, status, previousLabelOrAppVersion, previousDeploymentKey, callback) { + const url = this._serverUrl + "reportStatus/deploy"; + const body = { + appVersion: this._appVersion, + deploymentKey: this._deploymentKey, + }; + if (this._clientUniqueId) { + body.clientUniqueId = this._clientUniqueId; + } + if (deployedPackage) { + body.label = deployedPackage.label; + body.appVersion = deployedPackage.appVersion; + switch (status) { + case AcquisitionStatus.DeploymentSucceeded: + case AcquisitionStatus.DeploymentFailed: + body.status = status; + break; + default: + if (callback) { + if (!status) { + callback(new Error("Missing status argument."), /*not used*/ null); + } + else { + callback(new Error('Unrecognized status "' + status + '".'), /*not used*/ null); + } + } + return; + } + } + if (previousLabelOrAppVersion) { + body.previousLabelOrAppVersion = previousLabelOrAppVersion; + } + if (previousDeploymentKey) { + body.previousDeploymentKey = previousDeploymentKey; + } + callback = typeof arguments[arguments.length - 1] === "function" && arguments[arguments.length - 1]; + this._httpRequester.request(2 /* Http.Verb.POST */, url, JSON.stringify(body), (error, response) => { + if (callback) { + if (error) { + callback(error, /*not used*/ null); + return; + } + if (response.statusCode !== 200) { + callback(new Error(response.statusCode + ": " + response.body), /*not used*/ null); + return; + } + callback(/*error*/ null, /*not used*/ null); + } + }); + } + reportStatusDownload(downloadedPackage, callback) { + const url = this._serverUrl + "reportStatus/download"; + const body = { + clientUniqueId: this._clientUniqueId, + deploymentKey: this._deploymentKey, + label: downloadedPackage.label, + }; + this._httpRequester.request(2 /* Http.Verb.POST */, url, JSON.stringify(body), (error, response) => { + if (callback) { + if (error) { + callback(error, /*not used*/ null); + return; + } + if (response.statusCode !== 200) { + callback(new Error(response.statusCode + ": " + response.body), /*not used*/ null); + return; + } + callback(/*error*/ null, /*not used*/ null); + } + }); + } +} +exports.AcquisitionManager = AcquisitionManager; +function queryStringify(object) { + let queryString = ""; + let isFirst = true; + for (const property in object) { + if (object.hasOwnProperty(property)) { + const value = object[property]; + if (!isFirst) { + queryString += "&"; + } + queryString += encodeURIComponent(property) + "="; + if (value !== null && typeof value !== "undefined") { + queryString += encodeURIComponent(value); + } + isFirst = false; + } + } + return queryString; +} diff --git a/cli/bin/script/cli.js b/cli/bin/script/cli.js new file mode 100644 index 0000000..78f077a --- /dev/null +++ b/cli/bin/script/cli.js @@ -0,0 +1,23 @@ +#!/usr/bin/env node +"use strict"; +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +Object.defineProperty(exports, "__esModule", { value: true }); +const parser = require("./command-parser"); +const execute = require("./command-executor"); +const chalk = require("chalk"); +function run() { + const command = parser.createCommand(); + if (!command) { + parser.showHelp(/*showRootDescription*/ false); + return; + } + execute + .execute(command) + .catch((error) => { + console.error(chalk.red(`[Error] ${error.message}`)); + process.exit(1); + }) + .done(); +} +run(); diff --git a/cli/bin/script/command-executor.js b/cli/bin/script/command-executor.js new file mode 100644 index 0000000..08a63ae --- /dev/null +++ b/cli/bin/script/command-executor.js @@ -0,0 +1,1290 @@ +"use strict"; +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +Object.defineProperty(exports, "__esModule", { value: true }); +exports.runReactNativeBundleCommand = exports.releaseReact = exports.release = exports.execute = exports.deploymentList = exports.createEmptyTempReleaseFolder = exports.confirm = exports.execSync = exports.spawn = exports.sdk = exports.log = void 0; +const AccountManager = require("./management-sdk"); +const childProcess = require("child_process"); +const debug_1 = require("./commands/debug"); +const fs = require("fs"); +const chalk = require("chalk"); +const g2js = require("gradle-to-js/lib/parser"); +const moment = require("moment"); +const opener = require("opener"); +const os = require("os"); +const path = require("path"); +const plist = require("plist"); +const progress = require("progress"); +const prompt = require("prompt"); +const Q = require("q"); +const rimraf = require("rimraf"); +const semver = require("semver"); +const Table = require("cli-table"); +const which = require("which"); +const wordwrap = require("wordwrap"); +const cli = require("../script/types/cli"); +const sign_1 = require("./sign"); +const xcode = require("xcode"); +const react_native_utils_1 = require("./react-native-utils"); +const file_utils_1 = require("./utils/file-utils"); +const configFilePath = path.join(process.env.LOCALAPPDATA || process.env.HOME, ".code-push.config"); +const emailValidator = require("email-validator"); +const packageJson = require("../../package.json"); +const parseXml = Q.denodeify(require("xml2js").parseString); +var Promise = Q.Promise; +const properties = require("properties"); +const CLI_HEADERS = { + "X-CodePush-CLI-Version": packageJson.version, +}; +const log = (message) => console.log(message); +exports.log = log; +exports.spawn = childProcess.spawn; +exports.execSync = childProcess.execSync; +let connectionInfo; +const confirm = (message = "Are you sure?") => { + message += " (y/N):"; + return Promise((resolve, reject, notify) => { + prompt.message = ""; + prompt.delimiter = ""; + prompt.start(); + prompt.get({ + properties: { + response: { + description: chalk.cyan(message), + }, + }, + }, (err, result) => { + const accepted = result.response && result.response.toLowerCase() === "y"; + const rejected = !result.response || result.response.toLowerCase() === "n"; + if (accepted) { + resolve(true); + } + else { + if (!rejected) { + console.log('Invalid response: "' + result.response + '"'); + } + resolve(false); + } + }); + }); +}; +exports.confirm = confirm; +function accessKeyAdd(command) { + return exports.sdk.addAccessKey(command.name, command.ttl).then((accessKey) => { + (0, exports.log)(`Successfully created the "${command.name}" access key: ${accessKey.key}`); + (0, exports.log)("Make sure to save this key value somewhere safe, since you won't be able to view it from the CLI again!"); + }); +} +function accessKeyPatch(command) { + const willUpdateName = isCommandOptionSpecified(command.newName) && command.oldName !== command.newName; + const willUpdateTtl = isCommandOptionSpecified(command.ttl); + if (!willUpdateName && !willUpdateTtl) { + throw new Error("A new name and/or TTL must be provided."); + } + return exports.sdk.patchAccessKey(command.oldName, command.newName, command.ttl).then((accessKey) => { + let logMessage = "Successfully "; + if (willUpdateName) { + logMessage += `renamed the access key "${command.oldName}" to "${command.newName}"`; + } + if (willUpdateTtl) { + const expirationDate = moment(accessKey.expires).format("LLLL"); + if (willUpdateName) { + logMessage += ` and changed its expiration date to ${expirationDate}`; + } + else { + logMessage += `changed the expiration date of the "${command.oldName}" access key to ${expirationDate}`; + } + } + (0, exports.log)(`${logMessage}.`); + }); +} +function accessKeyList(command) { + throwForInvalidOutputFormat(command.format); + return exports.sdk.getAccessKeys().then((accessKeys) => { + printAccessKeys(command.format, accessKeys); + }); +} +function accessKeyRemove(command) { + return (0, exports.confirm)().then((wasConfirmed) => { + if (wasConfirmed) { + return exports.sdk.removeAccessKey(command.accessKey).then(() => { + (0, exports.log)(`Successfully removed the "${command.accessKey}" access key.`); + }); + } + (0, exports.log)("Access key removal cancelled."); + }); +} +function appAdd(command) { + return exports.sdk.addApp(command.appName).then((app) => { + (0, exports.log)('Successfully added the "' + command.appName + '" app, along with the following default deployments:'); + const deploymentListCommand = { + type: cli.CommandType.deploymentList, + appName: app.name, + format: "table", + displayKeys: true, + }; + return (0, exports.deploymentList)(deploymentListCommand, /*showPackage=*/ false); + }); +} +function appList(command) { + throwForInvalidOutputFormat(command.format); + let apps; + return exports.sdk.getApps().then((retrievedApps) => { + printAppList(command.format, retrievedApps); + }); +} +function appRemove(command) { + return (0, exports.confirm)("Are you sure you want to remove this app? Note that its deployment keys will be PERMANENTLY unrecoverable.").then((wasConfirmed) => { + if (wasConfirmed) { + return exports.sdk.removeApp(command.appName).then(() => { + (0, exports.log)('Successfully removed the "' + command.appName + '" app.'); + }); + } + (0, exports.log)("App removal cancelled."); + }); +} +function appRename(command) { + return exports.sdk.renameApp(command.currentAppName, command.newAppName).then(() => { + (0, exports.log)('Successfully renamed the "' + command.currentAppName + '" app to "' + command.newAppName + '".'); + }); +} +const createEmptyTempReleaseFolder = (folderPath) => { + return deleteFolder(folderPath).then(() => { + fs.mkdirSync(folderPath); + }); +}; +exports.createEmptyTempReleaseFolder = createEmptyTempReleaseFolder; +function appTransfer(command) { + throwForInvalidEmail(command.email); + return (0, exports.confirm)().then((wasConfirmed) => { + if (wasConfirmed) { + return exports.sdk.transferApp(command.appName, command.email).then(() => { + (0, exports.log)('Successfully transferred the ownership of app "' + command.appName + '" to the account with email "' + command.email + '".'); + }); + } + (0, exports.log)("App transfer cancelled."); + }); +} +function addCollaborator(command) { + throwForInvalidEmail(command.email); + return exports.sdk.addCollaborator(command.appName, command.email).then(() => { + (0, exports.log)('Successfully added "' + command.email + '" as a collaborator to the app "' + command.appName + '".'); + }); +} +function listCollaborators(command) { + throwForInvalidOutputFormat(command.format); + return exports.sdk.getCollaborators(command.appName).then((retrievedCollaborators) => { + printCollaboratorsList(command.format, retrievedCollaborators); + }); +} +function removeCollaborator(command) { + throwForInvalidEmail(command.email); + return (0, exports.confirm)().then((wasConfirmed) => { + if (wasConfirmed) { + return exports.sdk.removeCollaborator(command.appName, command.email).then(() => { + (0, exports.log)('Successfully removed "' + command.email + '" as a collaborator from the app "' + command.appName + '".'); + }); + } + (0, exports.log)("App collaborator removal cancelled."); + }); +} +function deleteConnectionInfoCache(printMessage = true) { + try { + fs.unlinkSync(configFilePath); + if (printMessage) { + (0, exports.log)(`Successfully logged-out. The session file located at ${chalk.cyan(configFilePath)} has been deleted.\r\n`); + } + } + catch (ex) { } +} +function deleteFolder(folderPath) { + return Promise((resolve, reject, notify) => { + rimraf(folderPath, (err) => { + if (err) { + reject(err); + } + else { + resolve(null); + } + }); + }); +} +function deploymentAdd(command) { + return exports.sdk.addDeployment(command.appName, command.deploymentName, command.key).then((deployment) => { + (0, exports.log)('Successfully added the "' + + command.deploymentName + + '" deployment with key "' + + deployment.key + + '" to the "' + + command.appName + + '" app.'); + }); +} +function deploymentHistoryClear(command) { + return (0, exports.confirm)().then((wasConfirmed) => { + if (wasConfirmed) { + return exports.sdk.clearDeploymentHistory(command.appName, command.deploymentName).then(() => { + (0, exports.log)('Successfully cleared the release history associated with the "' + + command.deploymentName + + '" deployment from the "' + + command.appName + + '" app.'); + }); + } + (0, exports.log)("Clear deployment cancelled."); + }); +} +const deploymentList = (command, showPackage = true) => { + throwForInvalidOutputFormat(command.format); + let deployments; + return exports.sdk + .getDeployments(command.appName) + .then((retrievedDeployments) => { + deployments = retrievedDeployments; + if (showPackage) { + const metricsPromises = deployments.map((deployment) => { + if (deployment.package) { + return exports.sdk.getDeploymentMetrics(command.appName, deployment.name).then((metrics) => { + if (metrics[deployment.package.label]) { + const totalActive = getTotalActiveFromDeploymentMetrics(metrics); + deployment.package.metrics = { + active: metrics[deployment.package.label].active, + downloaded: metrics[deployment.package.label].downloaded, + failed: metrics[deployment.package.label].failed, + installed: metrics[deployment.package.label].installed, + totalActive: totalActive, + }; + } + }); + } + else { + return Q(null); + } + }); + return Q.all(metricsPromises); + } + }) + .then(() => { + printDeploymentList(command, deployments, showPackage); + }); +}; +exports.deploymentList = deploymentList; +function deploymentRemove(command) { + return (0, exports.confirm)("Are you sure you want to remove this deployment? Note that its deployment key will be PERMANENTLY unrecoverable.").then((wasConfirmed) => { + if (wasConfirmed) { + return exports.sdk.removeDeployment(command.appName, command.deploymentName).then(() => { + (0, exports.log)('Successfully removed the "' + command.deploymentName + '" deployment from the "' + command.appName + '" app.'); + }); + } + (0, exports.log)("Deployment removal cancelled."); + }); +} +function deploymentRename(command) { + return exports.sdk.renameDeployment(command.appName, command.currentDeploymentName, command.newDeploymentName).then(() => { + (0, exports.log)('Successfully renamed the "' + + command.currentDeploymentName + + '" deployment to "' + + command.newDeploymentName + + '" for the "' + + command.appName + + '" app.'); + }); +} +function deploymentHistory(command) { + throwForInvalidOutputFormat(command.format); + return Q.all([ + exports.sdk.getAccountInfo(), + exports.sdk.getDeploymentHistory(command.appName, command.deploymentName), + exports.sdk.getDeploymentMetrics(command.appName, command.deploymentName), + ]).spread((account, deploymentHistory, metrics) => { + const totalActive = getTotalActiveFromDeploymentMetrics(metrics); + deploymentHistory.forEach((packageObject) => { + if (metrics[packageObject.label]) { + packageObject.metrics = { + active: metrics[packageObject.label].active, + downloaded: metrics[packageObject.label].downloaded, + failed: metrics[packageObject.label].failed, + installed: metrics[packageObject.label].installed, + totalActive: totalActive, + }; + } + }); + printDeploymentHistory(command, deploymentHistory, account.email); + }); +} +function deserializeConnectionInfo() { + try { + const savedConnection = fs.readFileSync(configFilePath, { + encoding: "utf8", + }); + let connectionInfo = JSON.parse(savedConnection); + // If the connection info is in the legacy format, convert it to the modern format + if (connectionInfo.accessKeyName) { + connectionInfo = { + accessKey: connectionInfo.accessKeyName, + }; + } + const connInfo = connectionInfo; + return connInfo; + } + catch (ex) { + return; + } +} +function execute(command) { + connectionInfo = deserializeConnectionInfo(); + return Q(null).then(() => { + switch (command.type) { + // Must not be logged in + case cli.CommandType.login: + case cli.CommandType.register: + if (connectionInfo) { + throw new Error("You are already logged in from this machine."); + } + break; + // It does not matter whether you are logged in or not + case cli.CommandType.link: + break; + // Must be logged in + default: + if (!!exports.sdk) + break; // Used by unit tests to skip authentication + if (!connectionInfo) { + throw new Error("You are not currently logged in. Run the 'code-push-standalone login' command to authenticate with the CodePush server."); + } + exports.sdk = getSdk(connectionInfo.accessKey, CLI_HEADERS, connectionInfo.customServerUrl); + break; + } + switch (command.type) { + case cli.CommandType.accessKeyAdd: + return accessKeyAdd(command); + case cli.CommandType.accessKeyPatch: + return accessKeyPatch(command); + case cli.CommandType.accessKeyList: + return accessKeyList(command); + case cli.CommandType.accessKeyRemove: + return accessKeyRemove(command); + case cli.CommandType.appAdd: + return appAdd(command); + case cli.CommandType.appList: + return appList(command); + case cli.CommandType.appRemove: + return appRemove(command); + case cli.CommandType.appRename: + return appRename(command); + case cli.CommandType.appTransfer: + return appTransfer(command); + case cli.CommandType.collaboratorAdd: + return addCollaborator(command); + case cli.CommandType.collaboratorList: + return listCollaborators(command); + case cli.CommandType.collaboratorRemove: + return removeCollaborator(command); + case cli.CommandType.debug: + return (0, debug_1.default)(command); + case cli.CommandType.deploymentAdd: + return deploymentAdd(command); + case cli.CommandType.deploymentHistoryClear: + return deploymentHistoryClear(command); + case cli.CommandType.deploymentHistory: + return deploymentHistory(command); + case cli.CommandType.deploymentList: + return (0, exports.deploymentList)(command); + case cli.CommandType.deploymentRemove: + return deploymentRemove(command); + case cli.CommandType.deploymentRename: + return deploymentRename(command); + case cli.CommandType.link: + return link(command); + case cli.CommandType.login: + return login(command); + case cli.CommandType.logout: + return logout(command); + case cli.CommandType.patch: + return patch(command); + case cli.CommandType.promote: + return promote(command); + case cli.CommandType.register: + return register(command); + case cli.CommandType.release: + return (0, exports.release)(command); + case cli.CommandType.releaseReact: + return (0, exports.releaseReact)(command); + case cli.CommandType.rollback: + return rollback(command); + case cli.CommandType.sessionList: + return sessionList(command); + case cli.CommandType.sessionRemove: + return sessionRemove(command); + case cli.CommandType.whoami: + return whoami(command); + default: + // We should never see this message as invalid commands should be caught by the argument parser. + throw new Error("Invalid command: " + JSON.stringify(command)); + } + }); +} +exports.execute = execute; +function getTotalActiveFromDeploymentMetrics(metrics) { + let totalActive = 0; + Object.keys(metrics).forEach((label) => { + totalActive += metrics[label].active; + }); + return totalActive; +} +function initiateExternalAuthenticationAsync(action, serverUrl) { + const message = `A browser is being launched to authenticate your account. Follow the instructions ` + + `it displays to complete your ${action === "register" ? "registration" : action}.`; + (0, exports.log)(message); + const hostname = os.hostname(); + const url = `${serverUrl || AccountManager.SERVER_URL}/auth/${action}?hostname=${hostname}`; + opener(url); +} +function link(command) { + initiateExternalAuthenticationAsync("link", command.serverUrl); + return Q(null); +} +function login(command) { + // Check if one of the flags were provided. + if (command.accessKey) { + exports.sdk = getSdk(command.accessKey, CLI_HEADERS, command.serverUrl); + return exports.sdk.isAuthenticated().then((isAuthenticated) => { + if (isAuthenticated) { + serializeConnectionInfo(command.accessKey, /*preserveAccessKeyOnLogout*/ true, command.serverUrl); + } + else { + throw new Error("Invalid access key."); + } + }); + } + else { + return loginWithExternalAuthentication("login", command.serverUrl); + } +} +function loginWithExternalAuthentication(action, serverUrl) { + initiateExternalAuthenticationAsync(action, serverUrl); + (0, exports.log)(""); // Insert newline + return requestAccessKey().then((accessKey) => { + if (accessKey === null) { + // The user has aborted the synchronous prompt (e.g.: via [CTRL]+[C]). + return; + } + exports.sdk = getSdk(accessKey, CLI_HEADERS, serverUrl); + return exports.sdk.isAuthenticated().then((isAuthenticated) => { + if (isAuthenticated) { + serializeConnectionInfo(accessKey, /*preserveAccessKeyOnLogout*/ false, serverUrl); + } + else { + throw new Error("Invalid access key."); + } + }); + }); +} +function logout(command) { + return Q(null) + .then(() => { + if (!connectionInfo.preserveAccessKeyOnLogout) { + const machineName = os.hostname(); + return exports.sdk.removeSession(machineName).catch((error) => { + // If we are not authenticated or the session doesn't exist anymore, just swallow the error instead of displaying it + if (error.statusCode !== AccountManager.ERROR_UNAUTHORIZED && error.statusCode !== AccountManager.ERROR_NOT_FOUND) { + throw error; + } + }); + } + }) + .then(() => { + exports.sdk = null; + deleteConnectionInfoCache(); + }); +} +function formatDate(unixOffset) { + const date = moment(unixOffset); + const now = moment(); + if (Math.abs(now.diff(date, "days")) < 30) { + return date.fromNow(); // "2 hours ago" + } + else if (now.year() === date.year()) { + return date.format("MMM D"); // "Nov 6" + } + else { + return date.format("MMM D, YYYY"); // "Nov 6, 2014" + } +} +function printAppList(format, apps) { + if (format === "json") { + printJson(apps); + } + else if (format === "table") { + const headers = ["Name", "Deployments"]; + printTable(headers, (dataSource) => { + apps.forEach((app, index) => { + const row = [app.name, wordwrap(50)(app.deployments.join(", "))]; + dataSource.push(row); + }); + }); + } +} +function getCollaboratorDisplayName(email, collaboratorProperties) { + return collaboratorProperties.permission === AccountManager.AppPermission.OWNER ? email + chalk.magenta(" (Owner)") : email; +} +function printCollaboratorsList(format, collaborators) { + if (format === "json") { + const dataSource = { collaborators: collaborators }; + printJson(dataSource); + } + else if (format === "table") { + const headers = ["E-mail Address"]; + printTable(headers, (dataSource) => { + Object.keys(collaborators).forEach((email) => { + const row = [getCollaboratorDisplayName(email, collaborators[email])]; + dataSource.push(row); + }); + }); + } +} +function printDeploymentList(command, deployments, showPackage = true) { + if (command.format === "json") { + printJson(deployments); + } + else if (command.format === "table") { + const headers = ["Name"]; + if (command.displayKeys) { + headers.push("Deployment Key"); + } + if (showPackage) { + headers.push("Update Metadata"); + headers.push("Install Metrics"); + } + printTable(headers, (dataSource) => { + deployments.forEach((deployment) => { + const row = [deployment.name]; + if (command.displayKeys) { + row.push(deployment.key); + } + if (showPackage) { + row.push(getPackageString(deployment.package)); + row.push(getPackageMetricsString(deployment.package)); + } + dataSource.push(row); + }); + }); + } +} +function printDeploymentHistory(command, deploymentHistory, currentUserEmail) { + if (command.format === "json") { + printJson(deploymentHistory); + } + else if (command.format === "table") { + const headers = ["Label", "Release Time", "App Version", "Mandatory"]; + if (command.displayAuthor) { + headers.push("Released By"); + } + headers.push("Description", "Install Metrics"); + printTable(headers, (dataSource) => { + deploymentHistory.forEach((packageObject) => { + let releaseTime = formatDate(packageObject.uploadTime); + let releaseSource; + if (packageObject.releaseMethod === "Promote") { + releaseSource = `Promoted ${packageObject.originalLabel} from "${packageObject.originalDeployment}"`; + } + else if (packageObject.releaseMethod === "Rollback") { + const labelNumber = parseInt(packageObject.label.substring(1)); + const lastLabel = "v" + (labelNumber - 1); + releaseSource = `Rolled back ${lastLabel} to ${packageObject.originalLabel}`; + } + if (releaseSource) { + releaseTime += "\n" + chalk.magenta(`(${releaseSource})`).toString(); + } + let row = [packageObject.label, releaseTime, packageObject.appVersion, packageObject.isMandatory ? "Yes" : "No"]; + if (command.displayAuthor) { + let releasedBy = packageObject.releasedBy ? packageObject.releasedBy : ""; + if (currentUserEmail && releasedBy === currentUserEmail) { + releasedBy = "You"; + } + row.push(releasedBy); + } + row.push(packageObject.description ? wordwrap(30)(packageObject.description) : ""); + row.push(getPackageMetricsString(packageObject) + (packageObject.isDisabled ? `\n${chalk.green("Disabled:")} Yes` : "")); + if (packageObject.isDisabled) { + row = row.map((cellContents) => applyChalkSkippingLineBreaks(cellContents, chalk.dim)); + } + dataSource.push(row); + }); + }); + } +} +function applyChalkSkippingLineBreaks(applyString, chalkMethod) { + // Used to prevent "chalk" from applying styles to linebreaks which + // causes table border chars to have the style applied as well. + return applyString + .split("\n") + .map((token) => chalkMethod(token)) + .join("\n"); +} +function getPackageString(packageObject) { + if (!packageObject) { + return chalk.magenta("No updates released").toString(); + } + let packageString = chalk.green("Label: ") + + packageObject.label + + "\n" + + chalk.green("App Version: ") + + packageObject.appVersion + + "\n" + + chalk.green("Mandatory: ") + + (packageObject.isMandatory ? "Yes" : "No") + + "\n" + + chalk.green("Release Time: ") + + formatDate(packageObject.uploadTime) + + "\n" + + chalk.green("Released By: ") + + (packageObject.releasedBy ? packageObject.releasedBy : "") + + (packageObject.description ? wordwrap(70)("\n" + chalk.green("Description: ") + packageObject.description) : ""); + if (packageObject.isDisabled) { + packageString += `\n${chalk.green("Disabled:")} Yes`; + } + return packageString; +} +function getPackageMetricsString(obj) { + const packageObject = obj; + const rolloutString = obj && obj.rollout && obj.rollout !== 100 ? `\n${chalk.green("Rollout:")} ${obj.rollout.toLocaleString()}%` : ""; + if (!packageObject || !packageObject.metrics) { + return chalk.magenta("No installs recorded").toString() + (rolloutString || ""); + } + const activePercent = packageObject.metrics.totalActive + ? (packageObject.metrics.active / packageObject.metrics.totalActive) * 100 + : 0.0; + let percentString; + if (activePercent === 100.0) { + percentString = "100%"; + } + else if (activePercent === 0.0) { + percentString = "0%"; + } + else { + percentString = activePercent.toPrecision(2) + "%"; + } + const numPending = packageObject.metrics.downloaded - packageObject.metrics.installed - packageObject.metrics.failed; + let returnString = chalk.green("Active: ") + + percentString + + " (" + + packageObject.metrics.active.toLocaleString() + + " of " + + packageObject.metrics.totalActive.toLocaleString() + + ")\n" + + chalk.green("Total: ") + + packageObject.metrics.installed.toLocaleString(); + if (numPending > 0) { + returnString += " (" + numPending.toLocaleString() + " pending)"; + } + if (packageObject.metrics.failed) { + returnString += "\n" + chalk.green("Rollbacks: ") + chalk.red(packageObject.metrics.failed.toLocaleString() + ""); + } + if (rolloutString) { + returnString += rolloutString; + } + return returnString; +} +function getReactNativeProjectAppVersion(command, projectName) { + (0, exports.log)(chalk.cyan(`Detecting ${command.platform} app version:\n`)); + if (command.platform === "ios") { + let resolvedPlistFile = command.plistFile; + if (resolvedPlistFile) { + // If a plist file path is explicitly provided, then we don't + // need to attempt to "resolve" it within the well-known locations. + if (!(0, file_utils_1.fileExists)(resolvedPlistFile)) { + throw new Error("The specified plist file doesn't exist. Please check that the provided path is correct."); + } + } + else { + // Allow the plist prefix to be specified with or without a trailing + // separator character, but prescribe the use of a hyphen when omitted, + // since this is the most commonly used convetion for plist files. + if (command.plistFilePrefix && /.+[^-.]$/.test(command.plistFilePrefix)) { + command.plistFilePrefix += "-"; + } + const iOSDirectory = "ios"; + const plistFileName = `${command.plistFilePrefix || ""}Info.plist`; + const knownLocations = [path.join(iOSDirectory, projectName, plistFileName), path.join(iOSDirectory, plistFileName)]; + resolvedPlistFile = knownLocations.find(file_utils_1.fileExists); + if (!resolvedPlistFile) { + throw new Error(`Unable to find either of the following plist files in order to infer your app's binary version: "${knownLocations.join('", "')}". If your plist has a different name, or is located in a different directory, consider using either the "--plistFile" or "--plistFilePrefix" parameters to help inform the CLI how to find it.`); + } + } + const plistContents = fs.readFileSync(resolvedPlistFile).toString(); + let parsedPlist; + try { + parsedPlist = plist.parse(plistContents); + } + catch (e) { + throw new Error(`Unable to parse "${resolvedPlistFile}". Please ensure it is a well-formed plist file.`); + } + if (parsedPlist && parsedPlist.CFBundleShortVersionString) { + if ((0, react_native_utils_1.isValidVersion)(parsedPlist.CFBundleShortVersionString)) { + (0, exports.log)(`Using the target binary version value "${parsedPlist.CFBundleShortVersionString}" from "${resolvedPlistFile}".\n`); + return Q(parsedPlist.CFBundleShortVersionString); + } + else { + if (parsedPlist.CFBundleShortVersionString !== "$(MARKETING_VERSION)") { + throw new Error(`The "CFBundleShortVersionString" key in the "${resolvedPlistFile}" file needs to specify a valid semver string, containing both a major and minor version (e.g. 1.3.2, 1.1).`); + } + return getAppVersionFromXcodeProject(command, projectName); + } + } + else { + throw new Error(`The "CFBundleShortVersionString" key doesn't exist within the "${resolvedPlistFile}" file.`); + } + } + else if (command.platform === "android") { + let buildGradlePath = path.join("android", "app"); + if (command.gradleFile) { + buildGradlePath = command.gradleFile; + } + if (fs.lstatSync(buildGradlePath).isDirectory()) { + buildGradlePath = path.join(buildGradlePath, "build.gradle"); + } + if ((0, file_utils_1.fileDoesNotExistOrIsDirectory)(buildGradlePath)) { + throw new Error(`Unable to find gradle file "${buildGradlePath}".`); + } + return g2js + .parseFile(buildGradlePath) + .catch(() => { + throw new Error(`Unable to parse the "${buildGradlePath}" file. Please ensure it is a well-formed Gradle file.`); + }) + .then((buildGradle) => { + let versionName = null; + // First 'if' statement was implemented as workaround for case + // when 'build.gradle' file contains several 'android' nodes. + // In this case 'buildGradle.android' prop represents array instead of object + // due to parsing issue in 'g2js.parseFile' method. + if (buildGradle.android instanceof Array) { + for (let i = 0; i < buildGradle.android.length; i++) { + const gradlePart = buildGradle.android[i]; + if (gradlePart.defaultConfig && gradlePart.defaultConfig.versionName) { + versionName = gradlePart.defaultConfig.versionName; + break; + } + } + } + else if (buildGradle.android && buildGradle.android.defaultConfig && buildGradle.android.defaultConfig.versionName) { + versionName = buildGradle.android.defaultConfig.versionName; + } + else { + throw new Error(`The "${buildGradlePath}" file doesn't specify a value for the "android.defaultConfig.versionName" property.`); + } + if (typeof versionName !== "string") { + throw new Error(`The "android.defaultConfig.versionName" property value in "${buildGradlePath}" is not a valid string. If this is expected, consider using the --targetBinaryVersion option to specify the value manually.`); + } + let appVersion = versionName.replace(/"/g, "").trim(); + if ((0, react_native_utils_1.isValidVersion)(appVersion)) { + // The versionName property is a valid semver string, + // so we can safely use that and move on. + (0, exports.log)(`Using the target binary version value "${appVersion}" from "${buildGradlePath}".\n`); + return appVersion; + } + else if (/^\d.*/.test(appVersion)) { + // The versionName property isn't a valid semver string, + // but it starts with a number, and therefore, it can't + // be a valid Gradle property reference. + throw new Error(`The "android.defaultConfig.versionName" property in the "${buildGradlePath}" file needs to specify a valid semver string, containing both a major and minor version (e.g. 1.3.2, 1.1).`); + } + // The version property isn't a valid semver string + // so we assume it is a reference to a property variable. + const propertyName = appVersion.replace("project.", ""); + const propertiesFileName = "gradle.properties"; + const knownLocations = [path.join("android", "app", propertiesFileName), path.join("android", propertiesFileName)]; + // Search for gradle properties across all `gradle.properties` files + let propertiesFile = null; + for (let i = 0; i < knownLocations.length; i++) { + propertiesFile = knownLocations[i]; + if ((0, file_utils_1.fileExists)(propertiesFile)) { + const propertiesContent = fs.readFileSync(propertiesFile).toString(); + try { + const parsedProperties = properties.parse(propertiesContent); + appVersion = parsedProperties[propertyName]; + if (appVersion) { + break; + } + } + catch (e) { + throw new Error(`Unable to parse "${propertiesFile}". Please ensure it is a well-formed properties file.`); + } + } + } + if (!appVersion) { + throw new Error(`No property named "${propertyName}" exists in the "${propertiesFile}" file.`); + } + if (!(0, react_native_utils_1.isValidVersion)(appVersion)) { + throw new Error(`The "${propertyName}" property in the "${propertiesFile}" file needs to specify a valid semver string, containing both a major and minor version (e.g. 1.3.2, 1.1).`); + } + (0, exports.log)(`Using the target binary version value "${appVersion}" from the "${propertyName}" key in the "${propertiesFile}" file.\n`); + return appVersion.toString(); + }); + } + else { + const appxManifestFileName = "Package.appxmanifest"; + let appxManifestContainingFolder; + let appxManifestContents; + try { + appxManifestContainingFolder = path.join("windows", projectName); + appxManifestContents = fs.readFileSync(path.join(appxManifestContainingFolder, "Package.appxmanifest")).toString(); + } + catch (err) { + throw new Error(`Unable to find or read "${appxManifestFileName}" in the "${path.join("windows", projectName)}" folder.`); + } + return parseXml(appxManifestContents) + .catch((err) => { + throw new Error(`Unable to parse the "${path.join(appxManifestContainingFolder, appxManifestFileName)}" file, it could be malformed.`); + }) + .then((parsedAppxManifest) => { + try { + return parsedAppxManifest.Package.Identity[0]["$"].Version.match(/^\d+\.\d+\.\d+/)[0]; + } + catch (e) { + throw new Error(`Unable to parse the package version from the "${path.join(appxManifestContainingFolder, appxManifestFileName)}" file.`); + } + }); + } +} +function getAppVersionFromXcodeProject(command, projectName) { + const pbxprojFileName = "project.pbxproj"; + let resolvedPbxprojFile = command.xcodeProjectFile; + if (resolvedPbxprojFile) { + // If the xcode project file path is explicitly provided, then we don't + // need to attempt to "resolve" it within the well-known locations. + if (!resolvedPbxprojFile.endsWith(pbxprojFileName)) { + // Specify path to pbxproj file if the provided file path is an Xcode project file. + resolvedPbxprojFile = path.join(resolvedPbxprojFile, pbxprojFileName); + } + if (!(0, file_utils_1.fileExists)(resolvedPbxprojFile)) { + throw new Error("The specified pbx project file doesn't exist. Please check that the provided path is correct."); + } + } + else { + const iOSDirectory = "ios"; + const xcodeprojDirectory = `${projectName}.xcodeproj`; + const pbxprojKnownLocations = [ + path.join(iOSDirectory, xcodeprojDirectory, pbxprojFileName), + path.join(iOSDirectory, pbxprojFileName), + ]; + resolvedPbxprojFile = pbxprojKnownLocations.find(file_utils_1.fileExists); + if (!resolvedPbxprojFile) { + throw new Error(`Unable to find either of the following pbxproj files in order to infer your app's binary version: "${pbxprojKnownLocations.join('", "')}".`); + } + } + const xcodeProj = xcode.project(resolvedPbxprojFile).parseSync(); + const marketingVersion = xcodeProj.getBuildProperty("MARKETING_VERSION", command.buildConfigurationName, command.xcodeTargetName); + if (!(0, react_native_utils_1.isValidVersion)(marketingVersion)) { + throw new Error(`The "MARKETING_VERSION" key in the "${resolvedPbxprojFile}" file needs to specify a valid semver string, containing both a major and minor version (e.g. 1.3.2, 1.1).`); + } + console.log(`Using the target binary version value "${marketingVersion}" from "${resolvedPbxprojFile}".\n`); + return marketingVersion; +} +function printJson(object) { + (0, exports.log)(JSON.stringify(object, /*replacer=*/ null, /*spacing=*/ 2)); +} +function printAccessKeys(format, keys) { + if (format === "json") { + printJson(keys); + } + else if (format === "table") { + printTable(["Name", "Created", "Expires"], (dataSource) => { + const now = new Date().getTime(); + function isExpired(key) { + return now >= key.expires; + } + function keyToTableRow(key, dim) { + const row = [key.name, key.createdTime ? formatDate(key.createdTime) : "", formatDate(key.expires)]; + if (dim) { + row.forEach((col, index) => { + row[index] = chalk.dim(col); + }); + } + return row; + } + keys.forEach((key) => !isExpired(key) && dataSource.push(keyToTableRow(key, /*dim*/ false))); + keys.forEach((key) => isExpired(key) && dataSource.push(keyToTableRow(key, /*dim*/ true))); + }); + } +} +function printSessions(format, sessions) { + if (format === "json") { + printJson(sessions); + } + else if (format === "table") { + printTable(["Machine", "Logged in"], (dataSource) => { + sessions.forEach((session) => dataSource.push([session.machineName, formatDate(session.loggedInTime)])); + }); + } +} +function printTable(columnNames, readData) { + const table = new Table({ + head: columnNames, + style: { head: ["cyan"] }, + }); + readData(table); + (0, exports.log)(table.toString()); +} +function register(command) { + return loginWithExternalAuthentication("register", command.serverUrl); +} +function promote(command) { + const packageInfo = { + appVersion: command.appStoreVersion, + description: command.description, + label: command.label, + isDisabled: command.disabled, + isMandatory: command.mandatory, + rollout: command.rollout, + }; + return exports.sdk + .promote(command.appName, command.sourceDeploymentName, command.destDeploymentName, packageInfo) + .then(() => { + (0, exports.log)("Successfully promoted " + + (command.label !== null ? '"' + command.label + '" of ' : "") + + 'the "' + + command.sourceDeploymentName + + '" deployment of the "' + + command.appName + + '" app to the "' + + command.destDeploymentName + + '" deployment.'); + }) + .catch((err) => releaseErrorHandler(err, command)); +} +function patch(command) { + const packageInfo = { + appVersion: command.appStoreVersion, + description: command.description, + isMandatory: command.mandatory, + isDisabled: command.disabled, + rollout: command.rollout, + }; + for (const updateProperty in packageInfo) { + if (packageInfo[updateProperty] !== null) { + return exports.sdk.patchRelease(command.appName, command.deploymentName, command.label, packageInfo).then(() => { + (0, exports.log)(`Successfully updated the "${command.label ? command.label : `latest`}" release of "${command.appName}" app's "${command.deploymentName}" deployment.`); + }); + } + } + throw new Error("At least one property must be specified to patch a release."); +} +const release = (command) => { + if ((0, file_utils_1.isBinaryOrZip)(command.package)) { + throw new Error("It is unnecessary to package releases in a .zip or binary file. Please specify the direct path to the update content's directory (e.g. /platforms/ios/www) or file (e.g. main.jsbundle)."); + } + throwForInvalidSemverRange(command.appStoreVersion); + const filePath = command.package; + let isSingleFilePackage = true; + if (fs.lstatSync(filePath).isDirectory()) { + isSingleFilePackage = false; + } + let lastTotalProgress = 0; + const progressBar = new progress("Upload progress:[:bar] :percent :etas", { + complete: "=", + incomplete: " ", + width: 50, + total: 100, + }); + const uploadProgress = (currentProgress) => { + progressBar.tick(currentProgress - lastTotalProgress); + lastTotalProgress = currentProgress; + }; + const updateMetadata = { + description: command.description, + isDisabled: command.disabled, + isMandatory: command.mandatory, + rollout: command.rollout, + }; + return exports.sdk + .isAuthenticated(true) + .then((isAuth) => { + return exports.sdk.release(command.appName, command.deploymentName, filePath, command.appStoreVersion, updateMetadata, uploadProgress); + }) + .then(() => { + (0, exports.log)('Successfully released an update containing the "' + + command.package + + '" ' + + (isSingleFilePackage ? "file" : "directory") + + ' to the "' + + command.deploymentName + + '" deployment of the "' + + command.appName + + '" app.'); + }) + .catch((err) => releaseErrorHandler(err, command)); +}; +exports.release = release; +const releaseReact = (command) => { + let bundleName = command.bundleName; + let entryFile = command.entryFile; + const outputFolder = command.outputDir || path.join(os.tmpdir(), "CodePush"); + const platform = (command.platform = command.platform.toLowerCase()); + const releaseCommand = command; + // Check for app and deployment exist before releasing an update. + // This validation helps to save about 1 minute or more in case user has typed wrong app or deployment name. + return (exports.sdk + .getDeployment(command.appName, command.deploymentName) + .then(() => { + releaseCommand.package = outputFolder; + switch (platform) { + case "android": + case "ios": + case "windows": + if (!bundleName) { + bundleName = platform === "ios" ? "main.jsbundle" : `index.${platform}.bundle`; + } + break; + default: + throw new Error('Platform must be either "android", "ios" or "windows".'); + } + let projectName; + try { + const projectPackageJson = require(path.join(process.cwd(), "package.json")); + projectName = projectPackageJson.name; + if (!projectName) { + throw new Error('The "package.json" file in the CWD does not have the "name" field set.'); + } + if (!projectPackageJson.dependencies["react-native"]) { + throw new Error("The project in the CWD is not a React Native project."); + } + } + catch (error) { + throw new Error('Unable to find or read "package.json" in the CWD. The "release-react" command must be executed in a React Native project folder.'); + } + if (!entryFile) { + entryFile = `index.${platform}.js`; + if ((0, file_utils_1.fileDoesNotExistOrIsDirectory)(entryFile)) { + entryFile = "index.js"; + } + if ((0, file_utils_1.fileDoesNotExistOrIsDirectory)(entryFile)) { + throw new Error(`Entry file "index.${platform}.js" or "index.js" does not exist.`); + } + } + else { + if ((0, file_utils_1.fileDoesNotExistOrIsDirectory)(entryFile)) { + throw new Error(`Entry file "${entryFile}" does not exist.`); + } + } + const appVersionPromise = command.appStoreVersion + ? Q(command.appStoreVersion) + : getReactNativeProjectAppVersion(command, projectName); + if (command.sourcemapOutput && !command.sourcemapOutput.endsWith(".map")) { + command.sourcemapOutput = path.join(command.sourcemapOutput, bundleName + ".map"); + } + return appVersionPromise; + }) + .then((appVersion) => { + throwForInvalidSemverRange(appVersion); + releaseCommand.appStoreVersion = appVersion; + return (0, exports.createEmptyTempReleaseFolder)(outputFolder); + }) + // This is needed to clear the react native bundler cache: + // https://github.com/facebook/react-native/issues/4289 + .then(() => deleteFolder(`${os.tmpdir()}/react-*`)) + .then(() => (0, exports.runReactNativeBundleCommand)(bundleName, command.development || false, entryFile, outputFolder, platform, command.sourcemapOutput)) + .then(async () => { + const isHermesEnabled = command.useHermes || + (platform === "android" && (await (0, react_native_utils_1.getAndroidHermesEnabled)(command.gradleFile))) || // Check if we have to run hermes to compile JS to Byte Code if Hermes is enabled in build.gradle and we're releasing an Android build + (platform === "ios" && (await (0, react_native_utils_1.getiOSHermesEnabled)(command.podFile))); // Check if we have to run hermes to compile JS to Byte Code if Hermes is enabled in Podfile and we're releasing an iOS build + if (isHermesEnabled) { + (0, exports.log)(chalk.cyan("\nRunning hermes compiler...\n")); + await (0, react_native_utils_1.runHermesEmitBinaryCommand)(bundleName, outputFolder, command.sourcemapOutput, command.extraHermesFlags, command.gradleFile); + } + }) + .then(async () => { + if (command.privateKeyPath) { + (0, exports.log)(chalk.cyan("\nSigning the bundle:\n")); + await (0, sign_1.default)(command.privateKeyPath, outputFolder); + } + else { + console.log("private key was not provided"); + } + }) + .then(() => { + (0, exports.log)(chalk.cyan("\nReleasing update contents to CodePush:\n")); + return (0, exports.release)(releaseCommand); + }) + .then(() => { + if (!command.outputDir) { + deleteFolder(outputFolder); + } + }) + .catch((err) => { + deleteFolder(outputFolder); + throw err; + })); +}; +exports.releaseReact = releaseReact; +function rollback(command) { + return (0, exports.confirm)().then((wasConfirmed) => { + if (!wasConfirmed) { + (0, exports.log)("Rollback cancelled."); + return; + } + return exports.sdk.rollback(command.appName, command.deploymentName, command.targetRelease || undefined).then(() => { + (0, exports.log)('Successfully performed a rollback on the "' + command.deploymentName + '" deployment of the "' + command.appName + '" app.'); + }); + }); +} +function requestAccessKey() { + return Promise((resolve, reject, notify) => { + prompt.message = ""; + prompt.delimiter = ""; + prompt.start(); + prompt.get({ + properties: { + response: { + description: chalk.cyan("Enter your access key: "), + }, + }, + }, (err, result) => { + if (err) { + resolve(null); + } + else { + resolve(result.response.trim()); + } + }); + }); +} +const runReactNativeBundleCommand = (bundleName, development, entryFile, outputFolder, platform, sourcemapOutput) => { + const reactNativeBundleArgs = []; + const envNodeArgs = process.env.CODE_PUSH_NODE_ARGS; + if (typeof envNodeArgs !== "undefined") { + Array.prototype.push.apply(reactNativeBundleArgs, envNodeArgs.trim().split(/\s+/)); + } + const isOldCLI = fs.existsSync(path.join("node_modules", "react-native", "local-cli", "cli.js")); + Array.prototype.push.apply(reactNativeBundleArgs, [ + isOldCLI ? path.join("node_modules", "react-native", "local-cli", "cli.js") : path.join("node_modules", "react-native", "cli.js"), + "bundle", + "--assets-dest", + outputFolder, + "--bundle-output", + path.join(outputFolder, bundleName), + "--dev", + development, + "--entry-file", + entryFile, + "--platform", + platform, + ]); + if (sourcemapOutput) { + reactNativeBundleArgs.push("--sourcemap-output", sourcemapOutput); + } + (0, exports.log)(chalk.cyan('Running "react-native bundle" command:\n')); + const reactNativeBundleProcess = (0, exports.spawn)("node", reactNativeBundleArgs); + (0, exports.log)(`node ${reactNativeBundleArgs.join(" ")}`); + return Promise((resolve, reject, notify) => { + reactNativeBundleProcess.stdout.on("data", (data) => { + (0, exports.log)(data.toString().trim()); + }); + reactNativeBundleProcess.stderr.on("data", (data) => { + console.error(data.toString().trim()); + }); + reactNativeBundleProcess.on("close", (exitCode) => { + if (exitCode) { + reject(new Error(`"react-native bundle" command exited with code ${exitCode}.`)); + } + resolve(null); + }); + }); +}; +exports.runReactNativeBundleCommand = runReactNativeBundleCommand; +function serializeConnectionInfo(accessKey, preserveAccessKeyOnLogout, customServerUrl) { + const connectionInfo = { + accessKey: accessKey, + preserveAccessKeyOnLogout: preserveAccessKeyOnLogout, + }; + if (customServerUrl) { + connectionInfo.customServerUrl = customServerUrl; + } + const json = JSON.stringify(connectionInfo); + fs.writeFileSync(configFilePath, json, { encoding: "utf8" }); + (0, exports.log)(`\r\nSuccessfully logged-in. Your session file was written to ${chalk.cyan(configFilePath)}. You can run the ${chalk.cyan("code-push logout")} command at any time to delete this file and terminate your session.\r\n`); +} +function sessionList(command) { + throwForInvalidOutputFormat(command.format); + return exports.sdk.getSessions().then((sessions) => { + printSessions(command.format, sessions); + }); +} +function sessionRemove(command) { + if (os.hostname() === command.machineName) { + throw new Error("Cannot remove the current login session via this command. Please run 'code-push-standalone logout' instead."); + } + else { + return (0, exports.confirm)().then((wasConfirmed) => { + if (wasConfirmed) { + return exports.sdk.removeSession(command.machineName).then(() => { + (0, exports.log)(`Successfully removed the login session for "${command.machineName}".`); + }); + } + (0, exports.log)("Session removal cancelled."); + }); + } +} +function releaseErrorHandler(error, command) { + if (command.noDuplicateReleaseError && error.statusCode === AccountManager.ERROR_CONFLICT) { + console.warn(chalk.yellow("[Warning] " + error.message)); + } + else { + throw error; + } +} +function throwForInvalidEmail(email) { + if (!emailValidator.validate(email)) { + throw new Error('"' + email + '" is an invalid e-mail address.'); + } +} +function throwForInvalidSemverRange(semverRange) { + if (semver.validRange(semverRange) === null) { + throw new Error('Please use a semver-compliant target binary version range, for example "1.0.0", "*" or "^1.2.3".'); + } +} +function throwForInvalidOutputFormat(format) { + switch (format) { + case "json": + case "table": + break; + default: + throw new Error("Invalid format: " + format + "."); + } +} +function whoami(command) { + return exports.sdk.getAccountInfo().then((account) => { + const accountInfo = `${account.email} (${account.linkedProviders.join(", ")})`; + (0, exports.log)(accountInfo); + }); +} +function isCommandOptionSpecified(option) { + return option !== undefined && option !== null; +} +function getSdk(accessKey, headers, customServerUrl) { + const sdk = new AccountManager(accessKey, CLI_HEADERS, customServerUrl); + /* + * If the server returns `Unauthorized`, it must be due to an invalid + * (or expired) access key. For convenience, we patch every SDK call + * to delete the cached connection so the user can simply + * login again instead of having to log out first. + */ + Object.getOwnPropertyNames(AccountManager.prototype).forEach((functionName) => { + if (typeof sdk[functionName] === "function") { + const originalFunction = sdk[functionName]; + sdk[functionName] = function () { + let maybePromise = originalFunction.apply(sdk, arguments); + if (maybePromise && maybePromise.then !== undefined) { + maybePromise = maybePromise.catch((error) => { + if (error.statusCode && error.statusCode === AccountManager.ERROR_UNAUTHORIZED) { + deleteConnectionInfoCache(/* printMessage */ false); + } + throw error; + }); + } + return maybePromise; + }; + } + }); + return sdk; +} diff --git a/cli/bin/script/command-parser.js b/cli/bin/script/command-parser.js new file mode 100644 index 0000000..57f0c55 --- /dev/null +++ b/cli/bin/script/command-parser.js @@ -0,0 +1,1108 @@ +"use strict"; +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +Object.defineProperty(exports, "__esModule", { value: true }); +exports.createCommand = exports.showHelp = void 0; +const yargs = require("yargs"); +const cli = require("../script/types/cli"); +const chalk = require("chalk"); +const backslash = require("backslash"); +const parseDuration = require("parse-duration"); +const packageJson = require("../../package.json"); +const ROLLOUT_PERCENTAGE_REGEX = /^(100|[1-9][0-9]|[1-9])%?$/; +const USAGE_PREFIX = "Usage: code-push-standalone"; +// Command categories are: access-key, app, release, deployment, deployment-key, login, logout, register +let isValidCommandCategory = false; +// Commands are the verb following the command category (e.g.: "add" in "app add"). +let isValidCommand = false; +let wasHelpShown = false; +function showHelp(showRootDescription) { + if (!wasHelpShown) { + if (showRootDescription) { + console.log(chalk.cyan(" _____ __ " + chalk.green(" ___ __ "))); + console.log(chalk.cyan(" / ___/__ ___/ /__" + chalk.green(" / _ \\__ _____ / / "))); + console.log(chalk.cyan("/ /__/ _ \\/ _ / -_)" + chalk.green(" ___/ // (_-") + .demand(/*count*/ 1, /*max*/ 1) // Require exactly one non-option arguments + .example("access-key " + commandName + ' "VSTS Integration"', 'Creates a new access key with the name "VSTS Integration", which expires in 60 days') + .example("access-key " + commandName + ' "One time key" --ttl 5m', 'Creates a new access key with the name "One time key", which expires in 5 minutes') + .option("ttl", { + default: "60d", + demand: false, + description: "Duration string which specifies the amount of time that the access key should remain valid for (e.g 5m, 60d, 1y)", + type: "string", + }); + addCommonConfiguration(yargs); +} +function accessKeyPatch(commandName, yargs) { + isValidCommand = true; + yargs + .usage(USAGE_PREFIX + " access-key " + commandName + " ") + .demand(/*count*/ 1, /*max*/ 1) // Require exactly one non-option arguments + .example("access-key " + commandName + ' "Key for build server" --name "Key for CI machine"', 'Renames the access key named "Key for build server" to "Key for CI machine"') + .example("access-key " + commandName + ' "Key for build server" --ttl 7d', 'Updates the access key named "Key for build server" to expire in 7 days') + .option("name", { + default: null, + demand: false, + description: "Display name for the access key", + type: "string", + }) + .option("ttl", { + default: null, + demand: false, + description: "Duration string which specifies the amount of time that the access key should remain valid for (e.g 5m, 60d, 1y)", + type: "string", + }); + addCommonConfiguration(yargs); +} +function accessKeyList(commandName, yargs) { + isValidCommand = true; + yargs + .usage(USAGE_PREFIX + " access-key " + commandName + " [options]") + .demand(/*count*/ 0, /*max*/ 0) + .example("access-key " + commandName, "Lists your access keys in tabular format") + .example("access-key " + commandName + " --format json", "Lists your access keys in JSON format") + .option("format", { + default: "table", + demand: false, + description: 'Output format to display your access keys with ("json" or "table")', + type: "string", + }); + addCommonConfiguration(yargs); +} +function accessKeyRemove(commandName, yargs) { + isValidCommand = true; + yargs + .usage(USAGE_PREFIX + " access-key " + commandName + " ") + .demand(/*count*/ 1, /*max*/ 1) // Require exactly one non-option arguments + .example("access-key " + commandName + ' "VSTS Integration"', 'Removes the "VSTS Integration" access key'); + addCommonConfiguration(yargs); +} +function addCommonConfiguration(yargs) { + yargs + .wrap(/*columnLimit*/ null) + .string("_") // Interpret non-hyphenated arguments as strings (e.g. an app version of '1.10'). + .fail((msg) => showHelp()); // Suppress the default error message. +} +function appList(commandName, yargs) { + isValidCommand = true; + yargs + .usage(USAGE_PREFIX + " app " + commandName + " [options]") + .demand(/*count*/ 0, /*max*/ 0) + .example("app " + commandName, "List your apps in tabular format") + .example("app " + commandName + " --format json", "List your apps in JSON format") + .option("format", { + default: "table", + demand: false, + description: 'Output format to display your apps with ("json" or "table")', + type: "string", + }); + addCommonConfiguration(yargs); +} +function appRemove(commandName, yargs) { + isValidCommand = true; + yargs + .usage(USAGE_PREFIX + " app " + commandName + " ") + .demand(/*count*/ 1, /*max*/ 1) // Require exactly one non-option arguments + .example("app " + commandName + " MyApp", 'Removes app "MyApp"'); + addCommonConfiguration(yargs); +} +function listCollaborators(commandName, yargs) { + isValidCommand = true; + yargs + .usage(USAGE_PREFIX + " collaborator " + commandName + " [options]") + .demand(/*count*/ 1, /*max*/ 1) // Require exactly one non-option arguments + .example("collaborator " + commandName + " MyApp", 'Lists the collaborators for app "MyApp" in tabular format') + .example("collaborator " + commandName + " MyApp --format json", 'Lists the collaborators for app "MyApp" in JSON format') + .option("format", { + default: "table", + demand: false, + description: 'Output format to display collaborators with ("json" or "table")', + type: "string", + }); + addCommonConfiguration(yargs); +} +function removeCollaborator(commandName, yargs) { + isValidCommand = true; + yargs + .usage(USAGE_PREFIX + " collaborator " + commandName + " ") + .demand(/*count*/ 2, /*max*/ 2) // Require exactly two non-option arguments + .example("collaborator " + commandName + " MyApp foo@bar.com", 'Removes foo@bar.com as a collaborator from app "MyApp"'); + addCommonConfiguration(yargs); +} +function sessionList(commandName, yargs) { + isValidCommand = true; + yargs + .usage(USAGE_PREFIX + " session " + commandName + " [options]") + .demand(/*count*/ 0, /*max*/ 0) + .example("session " + commandName, "Lists your sessions in tabular format") + .example("session " + commandName + " --format json", "Lists your login sessions in JSON format") + .option("format", { + default: "table", + demand: false, + description: 'Output format to display your login sessions with ("json" or "table")', + type: "string", + }); + addCommonConfiguration(yargs); +} +function sessionRemove(commandName, yargs) { + isValidCommand = true; + yargs + .usage(USAGE_PREFIX + " session " + commandName + " ") + .demand(/*count*/ 1, /*max*/ 1) // Require exactly one non-option arguments + .example("session " + commandName + ' "John\'s PC"', 'Removes the existing login session from "John\'s PC"'); + addCommonConfiguration(yargs); +} +function deploymentHistoryClear(commandName, yargs) { + isValidCommand = true; + yargs + .usage(USAGE_PREFIX + " deployment " + commandName + " ") + .demand(/*count*/ 2, /*max*/ 2) // Require exactly two non-option arguments + .example("deployment " + commandName + " MyApp MyDeployment", 'Clears the release history associated with deployment "MyDeployment" from app "MyApp"'); + addCommonConfiguration(yargs); +} +function deploymentList(commandName, yargs) { + isValidCommand = true; + yargs + .usage(USAGE_PREFIX + " deployment " + commandName + " [options]") + .demand(/*count*/ 1, /*max*/ 1) // Require exactly one non-option arguments + .example("deployment " + commandName + " MyApp", 'Lists the deployments for app "MyApp" in tabular format') + .example("deployment " + commandName + " MyApp --format json", 'Lists the deployments for app "MyApp" in JSON format') + .option("format", { + default: "table", + demand: false, + description: 'Output format to display your deployments with ("json" or "table")', + type: "string", + }) + .option("displayKeys", { + alias: "k", + default: false, + demand: false, + description: "Specifies whether to display the deployment keys", + type: "boolean", + }); + addCommonConfiguration(yargs); +} +function deploymentRemove(commandName, yargs) { + isValidCommand = true; + yargs + .usage(USAGE_PREFIX + " deployment " + commandName + " ") + .demand(/*count*/ 2, /*max*/ 2) // Require exactly two non-option arguments + .example("deployment " + commandName + " MyApp MyDeployment", 'Removes deployment "MyDeployment" from app "MyApp"'); + addCommonConfiguration(yargs); +} +function deploymentHistory(commandName, yargs) { + isValidCommand = true; + yargs + .usage(USAGE_PREFIX + " deployment " + commandName + " [options]") + .demand(/*count*/ 2, /*max*/ 2) // Require exactly two non-option arguments + .example("deployment " + commandName + " MyApp MyDeployment", 'Displays the release history for deployment "MyDeployment" from app "MyApp" in tabular format') + .example("deployment " + commandName + " MyApp MyDeployment --format json", 'Displays the release history for deployment "MyDeployment" from app "MyApp" in JSON format') + .option("format", { + default: "table", + demand: false, + description: 'Output format to display the release history with ("json" or "table")', + type: "string", + }) + .option("displayAuthor", { + alias: "a", + default: false, + demand: false, + description: "Specifies whether to display the release author", + type: "boolean", + }); + addCommonConfiguration(yargs); +} +yargs + .usage(USAGE_PREFIX + " ") + .demand(/*count*/ 1, /*max*/ 1) // Require exactly one non-option argument. + .command("access-key", "View and manage the access keys associated with your account", (yargs) => { + isValidCommandCategory = true; + yargs + .usage(USAGE_PREFIX + " access-key ") + .demand(/*count*/ 2, /*max*/ 2) // Require exactly two non-option arguments. + .command("add", "Create a new access key associated with your account", (yargs) => accessKeyAdd("add", yargs)) + .command("patch", "Update the name and/or TTL of an existing access key", (yargs) => accessKeyPatch("patch", yargs)) + .command("remove", "Remove an existing access key", (yargs) => accessKeyRemove("remove", yargs)) + .command("rm", "Remove an existing access key", (yargs) => accessKeyRemove("rm", yargs)) + .command("list", "List the access keys associated with your account", (yargs) => accessKeyList("list", yargs)) + .command("ls", "List the access keys associated with your account", (yargs) => accessKeyList("ls", yargs)) + .check((argv, aliases) => isValidCommand); // Report unrecognized, non-hyphenated command category. + addCommonConfiguration(yargs); +}) + .command("app", "View and manage your CodePush apps", (yargs) => { + isValidCommandCategory = true; + yargs + .usage(USAGE_PREFIX + " app ") + .demand(/*count*/ 2, /*max*/ 2) // Require exactly two non-option arguments. + .command("add", "Add a new app to your account", (yargs) => { + isValidCommand = true; + yargs + .usage(USAGE_PREFIX + " app add ") + .demand(/*count*/ 1, /*max*/ 1) // Require exactly one non-option arguments + .example("app add MyApp", 'Adds app "MyApp"'); + addCommonConfiguration(yargs); + }) + .command("remove", "Remove an app from your account", (yargs) => appRemove("remove", yargs)) + .command("rm", "Remove an app from your account", (yargs) => appRemove("rm", yargs)) + .command("rename", "Rename an existing app", (yargs) => { + isValidCommand = true; + yargs + .usage(USAGE_PREFIX + " app rename ") + .demand(/*count*/ 2, /*max*/ 2) // Require exactly two non-option arguments + .example("app rename CurrentName NewName", 'Renames app "CurrentName" to "NewName"'); + addCommonConfiguration(yargs); + }) + .command("list", "Lists the apps associated with your account", (yargs) => appList("list", yargs)) + .command("ls", "Lists the apps associated with your account", (yargs) => appList("ls", yargs)) + .command("transfer", "Transfer the ownership of an app to another account", (yargs) => { + isValidCommand = true; + yargs + .usage(USAGE_PREFIX + " app transfer ") + .demand(/*count*/ 2, /*max*/ 2) // Require exactly two non-option arguments + .example("app transfer MyApp foo@bar.com", 'Transfers the ownership of app "MyApp" to an account with email "foo@bar.com"'); + addCommonConfiguration(yargs); + }) + .check((argv, aliases) => isValidCommand); // Report unrecognized, non-hyphenated command category. + addCommonConfiguration(yargs); +}) + .command("collaborator", "View and manage app collaborators", (yargs) => { + isValidCommandCategory = true; + yargs + .usage(USAGE_PREFIX + " collaborator ") + .demand(/*count*/ 2, /*max*/ 2) // Require exactly two non-option arguments. + .command("add", "Add a new collaborator to an app", (yargs) => { + isValidCommand = true; + yargs + .usage(USAGE_PREFIX + " collaborator add ") + .demand(/*count*/ 2, /*max*/ 2) // Require exactly two non-option arguments + .example("collaborator add MyApp foo@bar.com", 'Adds foo@bar.com as a collaborator to app "MyApp"'); + addCommonConfiguration(yargs); + }) + .command("remove", "Remove a collaborator from an app", (yargs) => removeCollaborator("remove", yargs)) + .command("rm", "Remove a collaborator from an app", (yargs) => removeCollaborator("rm", yargs)) + .command("list", "List the collaborators for an app", (yargs) => listCollaborators("list", yargs)) + .command("ls", "List the collaborators for an app", (yargs) => listCollaborators("ls", yargs)) + .check((argv, aliases) => isValidCommand); // Report unrecognized, non-hyphenated command category. + addCommonConfiguration(yargs); +}) + .command("debug", "View the CodePush debug logs for a running app", (yargs) => { + isValidCommandCategory = true; + isValidCommand = true; + yargs + .usage(USAGE_PREFIX + " debug ") + .demand(/*count*/ 1, /*max*/ 1) // Require exactly one non-option arguments + .example("debug android", "View the CodePush debug logs for an Android emulator or device") + .example("debug ios", "View the CodePush debug logs for the iOS simulator"); + addCommonConfiguration(yargs); +}) + .command("deployment", "View and manage your app deployments", (yargs) => { + isValidCommandCategory = true; + yargs + .usage(USAGE_PREFIX + " deployment ") + .demand(/*count*/ 2, /*max*/ 2) // Require exactly two non-option arguments. + .command("add", "Add a new deployment to an app", (yargs) => { + isValidCommand = true; + yargs + .usage(USAGE_PREFIX + " deployment add ") + .demand(/*count*/ 2, /*max*/ 2) // Require exactly two non-option arguments + .example("deployment add MyApp MyDeployment", 'Adds deployment "MyDeployment" to app "MyApp"') + .example("deployment add MyApp MyDeployment -k abc123", 'Adds deployment key "abc123"') + .option("key", { + alias: "k", + demand: false, + description: "Specify deployment key", + type: "string", + }); + addCommonConfiguration(yargs); + }) + .command("clear", "Clear the release history associated with a deployment", (yargs) => deploymentHistoryClear("clear", yargs)) + .command("remove", "Remove a deployment from an app", (yargs) => deploymentRemove("remove", yargs)) + .command("rm", "Remove a deployment from an app", (yargs) => deploymentRemove("rm", yargs)) + .command("rename", "Rename an existing deployment", (yargs) => { + isValidCommand = true; + yargs + .usage(USAGE_PREFIX + " deployment rename ") + .demand(/*count*/ 3, /*max*/ 3) // Require exactly three non-option arguments + .example("deployment rename MyApp CurrentDeploymentName NewDeploymentName", 'Renames deployment "CurrentDeploymentName" to "NewDeploymentName"'); + addCommonConfiguration(yargs); + }) + .command("list", "List the deployments associated with an app", (yargs) => deploymentList("list", yargs)) + .command("ls", "List the deployments associated with an app", (yargs) => deploymentList("ls", yargs)) + .command("history", "Display the release history for a deployment", (yargs) => deploymentHistory("history", yargs)) + .command("h", "Display the release history for a deployment", (yargs) => deploymentHistory("h", yargs)) + .check((argv, aliases) => isValidCommand); // Report unrecognized, non-hyphenated command category. + addCommonConfiguration(yargs); +}) + .command("link", "Link an additional authentication provider (e.g. GitHub) to an existing CodePush account", (yargs) => { + isValidCommandCategory = true; + isValidCommand = true; + yargs + .usage(USAGE_PREFIX + " link") + .demand(/*count*/ 0, /*max*/ 1) //set 'max' to one to allow usage of serverUrl undocument parameter for testing + .example("link", "Links an account on the CodePush server") + .check((argv, aliases) => isValidCommand); // Report unrecognized, non-hyphenated command category. + addCommonConfiguration(yargs); +}) + .command("login", "Authenticate with the CodePush server in order to begin managing your apps", (yargs) => { + isValidCommandCategory = true; + isValidCommand = true; + yargs + .usage(USAGE_PREFIX + " login [options]") + .demand(/*count*/ 0, /*max*/ 1) //set 'max' to one to allow usage of serverUrl undocument parameter for testing + .example("login", "Logs in to the CodePush server") + .example("login --accessKey mykey", 'Logs in on behalf of the user who owns and created the access key "mykey"') + .option("accessKey", { + alias: "key", + default: null, + demand: false, + description: "Access key to authenticate against the CodePush server with, instead of providing your username and password credentials", + type: "string", + }) + .check((argv, aliases) => isValidCommand); // Report unrecognized, non-hyphenated command category. + addCommonConfiguration(yargs); +}) + .command("logout", "Log out of the current session", (yargs) => { + isValidCommandCategory = true; + isValidCommand = true; + yargs + .usage(USAGE_PREFIX + " logout") + .demand(/*count*/ 0, /*max*/ 0) + .example("logout", "Logs out and ends your current session"); + addCommonConfiguration(yargs); +}) + .command("patch", "Update the metadata for an existing release", (yargs) => { + yargs + .usage(USAGE_PREFIX + " patch [options]") + .demand(/*count*/ 2, /*max*/ 2) // Require exactly two non-option arguments + .example('patch MyApp Production --des "Updated description" -r 50%', 'Updates the description of the latest release for "MyApp" app\'s "Production" deployment and updates the rollout value to 50%') + .example('patch MyApp Production -l v3 --des "Updated description for v3"', 'Updates the description of the release with label v3 for "MyApp" app\'s "Production" deployment') + .option("label", { + alias: "l", + default: null, + demand: false, + description: "Label of the release to update. Defaults to the latest release within the specified deployment", + type: "string", + }) + .option("description", { + alias: "des", + default: null, + demand: false, + description: "Description of the changes made to the app with this release", + type: "string", + }) + .option("disabled", { + alias: "x", + default: null, + demand: false, + description: "Specifies whether this release should be immediately downloadable", + type: "boolean", + }) + .option("mandatory", { + alias: "m", + default: null, + demand: false, + description: "Specifies whether this release should be considered mandatory", + type: "boolean", + }) + .option("rollout", { + alias: "r", + default: null, + demand: false, + description: "Percentage of users this release should be immediately available to. This attribute can only be increased from the current value.", + type: "string", + }) + .option("targetBinaryVersion", { + alias: "t", + default: null, + demand: false, + description: "Semver expression that specifies the binary app version(s) this release is targeting (e.g. 1.1.0, ~1.2.3).", + type: "string", + }) + .check((argv, aliases) => { + return isValidRollout(argv); + }); + addCommonConfiguration(yargs); +}) + .command("promote", "Promote the latest release from one app deployment to another", (yargs) => { + yargs + .usage(USAGE_PREFIX + " promote [options]") + .demand(/*count*/ 3, /*max*/ 3) // Require exactly three non-option arguments + .example("promote MyApp Staging Production", 'Promotes the latest release within the "Staging" deployment of "MyApp" to "Production"') + .example('promote MyApp Staging Production --des "Production rollout" -r 25', 'Promotes the latest release within the "Staging" deployment of "MyApp" to "Production", with an updated description, and targeting only 25% of the users') + .option("description", { + alias: "des", + default: null, + demand: false, + description: "Description of the changes made to the app with this release. If omitted, the description from the release being promoted will be used.", + type: "string", + }) + .option("label", { + alias: "l", + default: null, + demand: false, + description: "Label of the source release that will be taken. If omitted, the latest release being promoted will be used.", + type: "string", + }) + .option("disabled", { + alias: "x", + default: null, + demand: false, + description: "Specifies whether this release should be immediately downloadable. If omitted, the disabled attribute from the release being promoted will be used.", + type: "boolean", + }) + .option("mandatory", { + alias: "m", + default: null, + demand: false, + description: "Specifies whether this release should be considered mandatory. If omitted, the mandatory property from the release being promoted will be used.", + type: "boolean", + }) + .option("noDuplicateReleaseError", { + default: false, + demand: false, + description: "When this flag is set, promoting a package that is identical to the latest release on the target deployment will produce a warning instead of an error", + type: "boolean", + }) + .option("rollout", { + alias: "r", + default: "100%", + demand: false, + description: "Percentage of users this update should be immediately available to", + type: "string", + }) + .option("targetBinaryVersion", { + alias: "t", + default: null, + demand: false, + description: "Semver expression that specifies the binary app version(s) this release is targeting (e.g. 1.1.0, ~1.2.3). If omitted, the target binary version property from the release being promoted will be used.", + type: "string", + }) + .check((argv, aliases) => { + return isValidRollout(argv); + }); + addCommonConfiguration(yargs); +}) + .command("register", "Register a new CodePush account", (yargs) => { + isValidCommandCategory = true; + isValidCommand = true; + yargs + .usage(USAGE_PREFIX + " register") + .demand(/*count*/ 0, /*max*/ 1) //set 'max' to one to allow usage of serverUrl undocument parameter for testing + .example("register", "Registers a new CodePush account") + .check((argv, aliases) => isValidCommand); // Report unrecognized, non-hyphenated command category. + addCommonConfiguration(yargs); +}) + .command("release", "Release an update to an app deployment", (yargs) => { + yargs + .usage(USAGE_PREFIX + " release [options]") + .demand(/*count*/ 3, /*max*/ 3) // Require exactly three non-option arguments. + .example('release MyApp app.js "*"', 'Releases the "app.js" file to the "MyApp" app\'s "Staging" deployment, targeting any binary version using the "*" wildcard range syntax.') + .example("release MyApp ./platforms/ios/www 1.0.3 -d Production", 'Releases the "./platforms/ios/www" folder and all its contents to the "MyApp" app\'s "Production" deployment, targeting only the 1.0.3 binary version') + .example("release MyApp ./platforms/ios/www 1.0.3 -d Production -r 20", 'Releases the "./platforms/ios/www" folder and all its contents to the "MyApp" app\'s "Production" deployment, targeting the 1.0.3 binary version and rolling out to about 20% of the users') + .option("deploymentName", { + alias: "d", + default: "Staging", + demand: false, + description: "Deployment to release the update to", + type: "string", + }) + .option("description", { + alias: "des", + default: null, + demand: false, + description: "Description of the changes made to the app in this release", + type: "string", + }) + .option("disabled", { + alias: "x", + default: false, + demand: false, + description: "Specifies whether this release should be immediately downloadable", + type: "boolean", + }) + .option("mandatory", { + alias: "m", + default: false, + demand: false, + description: "Specifies whether this release should be considered mandatory", + type: "boolean", + }) + .option("noDuplicateReleaseError", { + default: false, + demand: false, + description: "When this flag is set, releasing a package that is identical to the latest release will produce a warning instead of an error", + type: "boolean", + }) + .option("rollout", { + alias: "r", + default: "100%", + demand: false, + description: "Percentage of users this release should be available to", + type: "string", + }) + .check((argv, aliases) => { + return checkValidReleaseOptions(argv); + }); + addCommonConfiguration(yargs); +}) + .command("release-react", "Release a React Native update to an app deployment", (yargs) => { + yargs + .usage(USAGE_PREFIX + " release-react [options]") + .demand(/*count*/ 2, /*max*/ 2) // Require exactly two non-option arguments + .example("release-react MyApp ios", 'Releases the React Native iOS project in the current working directory to the "MyApp" app\'s "Staging" deployment') + .example("release-react MyApp android -d Production", 'Releases the React Native Android project in the current working directory to the "MyApp" app\'s "Production" deployment') + .example("release-react MyApp windows --dev", 'Releases the development bundle of the React Native Windows project in the current working directory to the "MyApp" app\'s "Staging" deployment') + .option("bundleName", { + alias: "b", + default: null, + demand: false, + description: 'Name of the generated JS bundle file. If unspecified, the standard bundle name will be used, depending on the specified platform: "main.jsbundle" (iOS), "index.android.bundle" (Android) or "index.windows.bundle" (Windows)', + type: "string", + }) + .option("deploymentName", { + alias: "d", + default: "Staging", + demand: false, + description: "Deployment to release the update to", + type: "string", + }) + .option("description", { + alias: "des", + default: null, + demand: false, + description: "Description of the changes made to the app with this release", + type: "string", + }) + .option("development", { + alias: "dev", + default: false, + demand: false, + description: "Specifies whether to generate a dev or release build", + type: "boolean", + }) + .option("disabled", { + alias: "x", + default: false, + demand: false, + description: "Specifies whether this release should be immediately downloadable", + type: "boolean", + }) + .option("entryFile", { + alias: "e", + default: null, + demand: false, + description: 'Path to the app\'s entry Javascript file. If omitted, "index..js" and then "index.js" will be used (if they exist)', + type: "string", + }) + .option("gradleFile", { + alias: "g", + default: null, + demand: false, + description: "Path to the gradle file which specifies the binary version you want to target this release at (android only).", + }) + .option("mandatory", { + alias: "m", + default: false, + demand: false, + description: "Specifies whether this release should be considered mandatory", + type: "boolean", + }) + .option("noDuplicateReleaseError", { + default: false, + demand: false, + description: "When this flag is set, releasing a package that is identical to the latest release will produce a warning instead of an error", + type: "boolean", + }) + .option("plistFile", { + alias: "p", + default: null, + demand: false, + description: "Path to the plist file which specifies the binary version you want to target this release at (iOS only).", + }) + .option("plistFilePrefix", { + alias: "pre", + default: null, + demand: false, + description: "Prefix to append to the file name when attempting to find your app's Info.plist file (iOS only).", + }) + .option("rollout", { + alias: "r", + default: "100%", + demand: false, + description: "Percentage of users this release should be immediately available to", + type: "string", + }) + .option("sourcemapOutput", { + alias: "s", + default: null, + demand: false, + description: "Path to where the sourcemap for the resulting bundle should be written. If omitted, a sourcemap will not be generated.", + type: "string", + }) + .option("targetBinaryVersion", { + alias: "t", + default: null, + demand: false, + description: 'Semver expression that specifies the binary app version(s) this release is targeting (e.g. 1.1.0, ~1.2.3). If omitted, the release will target the exact version specified in the "Info.plist" (iOS), "build.gradle" (Android) or "Package.appxmanifest" (Windows) files.', + type: "string", + }) + .option("outputDir", { + alias: "o", + default: null, + demand: false, + description: "Path to where the bundle and sourcemap should be written. If omitted, a bundle and sourcemap will not be written.", + type: "string", + }) + .option("useHermes", { + alias: "h", + default: false, + demand: false, + description: "Enable hermes and bypass automatic checks", + type: "boolean", + }) + .option("podFile", { + alias: "pod", + default: null, + demand: false, + description: "Path to the cocopods config file (iOS only).", + type: "string", + }) + .option("extraHermesFlags", { + alias: "hf", + default: [], + demand: false, + description: "Flags that get passed to Hermes, JavaScript to bytecode compiler. Can be specified multiple times.", + type: "array", + }) + .option("privateKeyPath", { + alias: "k", + default: null, + demand: false, + description: "Path to private key used for code signing.", + type: "string", + }) + .option("xcodeProjectFile", { + alias: "xp", + default: null, + demand: false, + description: "Path to the Xcode project or project.pbxproj file", + type: "string", + }) + .option("xcodeTargetName", { + alias: "xt", + default: undefined, + demand: false, + description: "Name of target (PBXNativeTarget) which specifies the binary version you want to target this release at (iOS only)", + type: "string", + }) + .option("buildConfigurationName", { + alias: "c", + default: undefined, + demand: false, + description: "Name of build configuration which specifies the binary version you want to target this release at. For example, 'Debug' or 'Release' (iOS only)", + type: "string", + }) + .check((argv, aliases) => { + return checkValidReleaseOptions(argv); + }); + addCommonConfiguration(yargs); +}) + .command("rollback", "Rollback the latest release for an app deployment", (yargs) => { + yargs + .usage(USAGE_PREFIX + " rollback [options]") + .demand(/*count*/ 2, /*max*/ 2) // Require exactly two non-option arguments + .example("rollback MyApp Production", 'Performs a rollback on the "Production" deployment of "MyApp"') + .example("rollback MyApp Production --targetRelease v4", 'Performs a rollback on the "Production" deployment of "MyApp" to the v4 release') + .option("targetRelease", { + alias: "r", + default: null, + demand: false, + description: "Label of the release to roll the specified deployment back to (e.g. v4). If omitted, the deployment will roll back to the previous release.", + type: "string", + }); + addCommonConfiguration(yargs); +}) + .command("session", "View and manage the current login sessions associated with your account", (yargs) => { + isValidCommandCategory = true; + yargs + .usage(USAGE_PREFIX + " session ") + .demand(/*count*/ 2, /*max*/ 2) // Require exactly two non-option arguments. + .command("remove", "Remove an existing login session", (yargs) => sessionRemove("remove", yargs)) + .command("rm", "Remove an existing login session", (yargs) => sessionRemove("rm", yargs)) + .command("list", "List the current login sessions associated with your account", (yargs) => sessionList("list", yargs)) + .command("ls", "List the current login sessions associated with your account", (yargs) => sessionList("ls", yargs)) + .check((argv, aliases) => isValidCommand); // Report unrecognized, non-hyphenated command category. + addCommonConfiguration(yargs); +}) + .command("whoami", "Display the account info for the current login session", (yargs) => { + isValidCommandCategory = true; + isValidCommand = true; + yargs + .usage(USAGE_PREFIX + " whoami") + .demand(/*count*/ 0, /*max*/ 0) + .example("whoami", "Display the account info for the current login session"); + addCommonConfiguration(yargs); +}) + .alias("v", "version") + .version(packageJson.version) + .wrap(/*columnLimit*/ null) + .fail((msg) => showHelp(/*showRootDescription*/ true)).argv; // Suppress the default error message. +function createCommand() { + let cmd; + const argv = yargs.parseSync(); + if (!wasHelpShown && argv._ && argv._.length > 0) { + // Create a command object + const arg0 = argv._[0]; + const arg1 = argv._[1]; + const arg2 = argv._[2]; + const arg3 = argv._[3]; + const arg4 = argv._[4]; + switch (arg0) { + case "access-key": + switch (arg1) { + case "add": + if (arg2) { + cmd = { type: cli.CommandType.accessKeyAdd }; + const accessKeyAddCmd = cmd; + accessKeyAddCmd.name = arg2; + const ttlOption = argv["ttl"]; + if (isDefined(ttlOption)) { + accessKeyAddCmd.ttl = parseDurationMilliseconds(ttlOption); + } + } + break; + case "patch": + if (arg2) { + cmd = { type: cli.CommandType.accessKeyPatch }; + const accessKeyPatchCmd = cmd; + accessKeyPatchCmd.oldName = arg2; + const newNameOption = argv["name"]; + const ttlOption = argv["ttl"]; + if (isDefined(newNameOption)) { + accessKeyPatchCmd.newName = newNameOption; + } + if (isDefined(ttlOption)) { + accessKeyPatchCmd.ttl = parseDurationMilliseconds(ttlOption); + } + } + break; + case "list": + case "ls": + cmd = { type: cli.CommandType.accessKeyList }; + cmd.format = argv["format"]; + break; + case "remove": + case "rm": + if (arg2) { + cmd = { type: cli.CommandType.accessKeyRemove }; + cmd.accessKey = arg2; + } + break; + } + break; + case "app": + switch (arg1) { + case "add": + if (arg2) { + cmd = { type: cli.CommandType.appAdd }; + cmd.appName = arg2; + } + break; + case "list": + case "ls": + cmd = { type: cli.CommandType.appList }; + cmd.format = argv["format"]; + break; + case "remove": + case "rm": + if (arg2) { + cmd = { type: cli.CommandType.appRemove }; + cmd.appName = arg2; + } + break; + case "rename": + if (arg2 && arg3) { + cmd = { type: cli.CommandType.appRename }; + const appRenameCommand = cmd; + appRenameCommand.currentAppName = arg2; + appRenameCommand.newAppName = arg3; + } + break; + case "transfer": + if (arg2 && arg3) { + cmd = { type: cli.CommandType.appTransfer }; + const appTransferCommand = cmd; + appTransferCommand.appName = arg2; + appTransferCommand.email = arg3; + } + break; + } + break; + case "collaborator": + switch (arg1) { + case "add": + if (arg2 && arg3) { + cmd = { type: cli.CommandType.collaboratorAdd }; + cmd.appName = arg2; + cmd.email = arg3; + } + break; + case "list": + case "ls": + if (arg2) { + cmd = { type: cli.CommandType.collaboratorList }; + cmd.appName = arg2; + cmd.format = argv["format"]; + } + break; + case "remove": + case "rm": + if (arg2 && arg3) { + cmd = { type: cli.CommandType.collaboratorRemove }; + cmd.appName = arg2; + cmd.email = arg3; + } + break; + } + break; + case "debug": + cmd = { + type: cli.CommandType.debug, + platform: arg1, + }; + break; + case "deployment": + switch (arg1) { + case "add": + if (arg2 && arg3) { + cmd = { type: cli.CommandType.deploymentAdd }; + const deploymentAddCommand = cmd; + deploymentAddCommand.appName = arg2; + deploymentAddCommand.deploymentName = arg3; + if (argv["key"]) { + deploymentAddCommand.key = argv["key"]; + } + } + break; + case "clear": + if (arg2 && arg3) { + cmd = { type: cli.CommandType.deploymentHistoryClear }; + const deploymentHistoryClearCommand = cmd; + deploymentHistoryClearCommand.appName = arg2; + deploymentHistoryClearCommand.deploymentName = arg3; + } + break; + case "list": + case "ls": + if (arg2) { + cmd = { type: cli.CommandType.deploymentList }; + const deploymentListCommand = cmd; + deploymentListCommand.appName = arg2; + deploymentListCommand.format = argv["format"]; + deploymentListCommand.displayKeys = argv["displayKeys"]; + } + break; + case "remove": + case "rm": + if (arg2 && arg3) { + cmd = { type: cli.CommandType.deploymentRemove }; + const deploymentRemoveCommand = cmd; + deploymentRemoveCommand.appName = arg2; + deploymentRemoveCommand.deploymentName = arg3; + } + break; + case "rename": + if (arg2 && arg3 && arg4) { + cmd = { type: cli.CommandType.deploymentRename }; + const deploymentRenameCommand = cmd; + deploymentRenameCommand.appName = arg2; + deploymentRenameCommand.currentDeploymentName = arg3; + deploymentRenameCommand.newDeploymentName = arg4; + } + break; + case "history": + case "h": + if (arg2 && arg3) { + cmd = { type: cli.CommandType.deploymentHistory }; + const deploymentHistoryCommand = cmd; + deploymentHistoryCommand.appName = arg2; + deploymentHistoryCommand.deploymentName = arg3; + deploymentHistoryCommand.format = argv["format"]; + deploymentHistoryCommand.displayAuthor = argv["displayAuthor"]; + } + break; + } + break; + case "link": + cmd = { + type: cli.CommandType.link, + serverUrl: getServerUrl(arg1), + }; + break; + case "login": + cmd = { type: cli.CommandType.login }; + const loginCommand = cmd; + loginCommand.serverUrl = getServerUrl(arg1); + loginCommand.accessKey = argv["accessKey"]; + break; + case "logout": + cmd = { type: cli.CommandType.logout }; + break; + case "patch": + if (arg1 && arg2) { + cmd = { type: cli.CommandType.patch }; + const patchCommand = cmd; + patchCommand.appName = arg1; + patchCommand.deploymentName = arg2; + patchCommand.label = argv["label"]; + // Description must be set to null to indicate that it is not being patched. + patchCommand.description = argv["description"] ? backslash(argv["description"]) : null; + patchCommand.disabled = argv["disabled"]; + patchCommand.mandatory = argv["mandatory"]; + patchCommand.rollout = getRolloutValue(argv["rollout"]); + patchCommand.appStoreVersion = argv["targetBinaryVersion"]; + } + break; + case "promote": + if (arg1 && arg2 && arg3) { + cmd = { type: cli.CommandType.promote }; + const deploymentPromoteCommand = cmd; + deploymentPromoteCommand.appName = arg1; + deploymentPromoteCommand.sourceDeploymentName = arg2; + deploymentPromoteCommand.destDeploymentName = arg3; + deploymentPromoteCommand.description = argv["description"] ? backslash(argv["description"]) : ""; + deploymentPromoteCommand.label = argv["label"]; + deploymentPromoteCommand.disabled = argv["disabled"]; + deploymentPromoteCommand.mandatory = argv["mandatory"]; + deploymentPromoteCommand.noDuplicateReleaseError = argv["noDuplicateReleaseError"]; + deploymentPromoteCommand.rollout = getRolloutValue(argv["rollout"]); + deploymentPromoteCommand.appStoreVersion = argv["targetBinaryVersion"]; + } + break; + case "register": + cmd = { type: cli.CommandType.register }; + const registerCommand = cmd; + registerCommand.serverUrl = getServerUrl(arg1); + break; + case "release": + if (arg1 && arg2 && arg3) { + cmd = { type: cli.CommandType.release }; + const releaseCommand = cmd; + releaseCommand.appName = arg1; + releaseCommand.package = arg2; + releaseCommand.appStoreVersion = arg3; + releaseCommand.deploymentName = argv["deploymentName"]; + releaseCommand.description = argv["description"] ? backslash(argv["description"]) : ""; + releaseCommand.disabled = argv["disabled"]; + releaseCommand.mandatory = argv["mandatory"]; + releaseCommand.noDuplicateReleaseError = argv["noDuplicateReleaseError"]; + releaseCommand.rollout = getRolloutValue(argv["rollout"]); + } + break; + case "release-react": + if (arg1 && arg2) { + cmd = { type: cli.CommandType.releaseReact }; + const releaseReactCommand = cmd; + releaseReactCommand.appName = arg1; + releaseReactCommand.platform = arg2; + releaseReactCommand.appStoreVersion = argv["targetBinaryVersion"]; + releaseReactCommand.bundleName = argv["bundleName"]; + releaseReactCommand.deploymentName = argv["deploymentName"]; + releaseReactCommand.disabled = argv["disabled"]; + releaseReactCommand.description = argv["description"] ? backslash(argv["description"]) : ""; + releaseReactCommand.development = argv["development"]; + releaseReactCommand.entryFile = argv["entryFile"]; + releaseReactCommand.gradleFile = argv["gradleFile"]; + releaseReactCommand.mandatory = argv["mandatory"]; + releaseReactCommand.noDuplicateReleaseError = argv["noDuplicateReleaseError"]; + releaseReactCommand.plistFile = argv["plistFile"]; + releaseReactCommand.plistFilePrefix = argv["plistFilePrefix"]; + releaseReactCommand.rollout = getRolloutValue(argv["rollout"]); + releaseReactCommand.sourcemapOutput = argv["sourcemapOutput"]; + releaseReactCommand.outputDir = argv["outputDir"]; + releaseReactCommand.useHermes = argv["useHermes"]; + releaseReactCommand.extraHermesFlags = argv["extraHermesFlags"]; + releaseReactCommand.podFile = argv["podFile"]; + releaseReactCommand.privateKeyPath = argv["privateKeyPath"]; + releaseReactCommand.xcodeProjectFile = argv["xcodeProjectFile"]; + releaseReactCommand.xcodeTargetName = argv["xcodeTargetName"]; + releaseReactCommand.buildConfigurationName = argv["buildConfigurationName"]; + } + break; + case "rollback": + if (arg1 && arg2) { + cmd = { type: cli.CommandType.rollback }; + const rollbackCommand = cmd; + rollbackCommand.appName = arg1; + rollbackCommand.deploymentName = arg2; + rollbackCommand.targetRelease = argv["targetRelease"]; + } + break; + case "session": + switch (arg1) { + case "list": + case "ls": + cmd = { type: cli.CommandType.sessionList }; + cmd.format = argv["format"]; + break; + case "remove": + case "rm": + if (arg2) { + cmd = { type: cli.CommandType.sessionRemove }; + cmd.machineName = arg2; + } + break; + } + break; + case "whoami": + cmd = { type: cli.CommandType.whoami }; + break; + } + return cmd; + } +} +exports.createCommand = createCommand; +function isValidRollout(args) { + const rollout = args["rollout"]; + if (rollout && !ROLLOUT_PERCENTAGE_REGEX.test(rollout)) { + return false; + } + return true; +} +function checkValidReleaseOptions(args) { + return isValidRollout(args) && !!args["deploymentName"]; +} +function getRolloutValue(input) { + return input ? parseInt(input.replace("%", "")) : null; +} +function getServerUrl(url) { + if (!url) + return null; + // Trim whitespace and a trailing slash (/) character. + url = url.trim(); + if (url[url.length - 1] === "/") { + url = url.substring(0, url.length - 1); + } + url = url.replace(/^(https?):\\/, "$1://"); // Replace 'http(s):\' with 'http(s)://' for Windows Git Bash + return url; +} +function isDefined(object) { + return object !== undefined && object !== null; +} +function parseDurationMilliseconds(durationString) { + return Math.floor(parseDuration(durationString)); +} diff --git a/cli/bin/script/commands/debug.js b/cli/bin/script/commands/debug.js new file mode 100644 index 0000000..9c6b0d1 --- /dev/null +++ b/cli/bin/script/commands/debug.js @@ -0,0 +1,125 @@ +"use strict"; +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +Object.defineProperty(exports, "__esModule", { value: true }); +const childProcess = require("child_process"); +const moment = require("moment"); +const path = require("path"); +const Q = require("q"); +const simctl = require("simctl"); +const which = require("which"); +class AndroidDebugPlatform { + getLogProcess() { + try { + which.sync("adb"); + } + catch (e) { + throw new Error("ADB command not found. Please ensure it is installed and available on your path."); + } + const numberOfAvailableDevices = this.getNumberOfAvailableDevices(); + if (numberOfAvailableDevices === 0) { + throw new Error("No Android devices found. Re-run this command after starting one."); + } + // For now there is no ability to specify device for debug like: + // code-push debug android "192.168.121.102:5555" + // So we have to throw an error in case more than 1 android device was attached + // otherwise we will very likely run into an exception while trying to read ‘adb logcat’ from device which codepushified app is not running on. + if (numberOfAvailableDevices > 1) { + throw new Error(`Found "${numberOfAvailableDevices}" android devices. Please leave only one device you need to debug.`); + } + return childProcess.spawn("adb", ["logcat"]); + } + // The following is an example of what the output looks + // like when running the "adb devices" command. + // + // List of devices attached + // emulator-5554 device + // 192.168.121.102:5555 device + getNumberOfAvailableDevices() { + const output = childProcess.execSync("adb devices").toString(); + const matches = output.match(/\b(device)\b/gim); + if (matches != null) { + return matches.length; + } + return 0; + } + normalizeLogMessage(message) { + // Check to see whether the message includes the source URL + // suffix, and if so, strip it. This can occur in Android Cordova apps. + const sourceURLIndex = message.indexOf('", source: file:///'); + if (~sourceURLIndex) { + return message.substring(0, sourceURLIndex); + } + else { + return message; + } + } +} +class iOSDebugPlatform { + getSimulatorID() { + const output = simctl.list({ devices: true, silent: true }); + const simulators = output.json.devices + .map((platform) => platform.devices) + .reduce((prev, next) => prev.concat(next)) + .filter((device) => device.state === "Booted") + .map((device) => device.id); + return simulators[0]; + } + getLogProcess() { + if (process.platform !== "darwin") { + throw new Error("iOS debug logs can only be viewed on OS X."); + } + const simulatorID = this.getSimulatorID(); + if (!simulatorID) { + throw new Error("No iOS simulators found. Re-run this command after starting one."); + } + const logFilePath = path.join(process.env.HOME, "Library/Logs/CoreSimulator", simulatorID, "system.log"); + return childProcess.spawn("tail", ["-f", logFilePath]); + } + normalizeLogMessage(message) { + return message; + } +} +const logMessagePrefix = "[CodePush] "; +function processLogData(logData) { + const content = logData.toString(); + content + .split("\n") + .filter((line) => line.indexOf(logMessagePrefix) > -1) + .map((line) => { + // Allow the current platform + // to normalize the message first. + line = this.normalizeLogMessage(line); + // Strip the CodePush-specific, platform agnostic + // log message prefix that is added to each entry. + const message = line.substring(line.indexOf(logMessagePrefix) + logMessagePrefix.length); + const timeStamp = moment().format("hh:mm:ss"); + return `[${timeStamp}] ${message}`; + }) + .forEach((line) => console.log(line)); +} +const debugPlatforms = { + android: new AndroidDebugPlatform(), + ios: new iOSDebugPlatform(), +}; +function default_1(command) { + return Q.Promise((resolve, reject) => { + const platform = command.platform.toLowerCase(); + const debugPlatform = debugPlatforms[platform]; + if (!debugPlatform) { + const availablePlatforms = Object.getOwnPropertyNames(debugPlatforms); + return reject(new Error(`"${platform}" is an unsupported platform. Available options are ${availablePlatforms.join(", ")}.`)); + } + try { + const logProcess = debugPlatform.getLogProcess(); + console.log(`Listening for ${platform} debug logs (Press CTRL+C to exit)`); + logProcess.stdout.on("data", processLogData.bind(debugPlatform)); + logProcess.stderr.on("data", reject); + logProcess.on("close", resolve); + } + catch (e) { + reject(e); + } + }); +} +exports.default = default_1; diff --git a/cli/bin/script/hash-utils.js b/cli/bin/script/hash-utils.js new file mode 100644 index 0000000..147f32e --- /dev/null +++ b/cli/bin/script/hash-utils.js @@ -0,0 +1,203 @@ +"use strict"; +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +Object.defineProperty(exports, "__esModule", { value: true }); +exports.PackageManifest = exports.hashStream = exports.hashFile = exports.generatePackageManifestFromDirectory = exports.generatePackageManifestFromZip = exports.generatePackageHashFromDirectory = void 0; +/** + * NOTE!!! This utility file is duplicated for use by the CodePush service (for server-driven hashing/ + * integrity checks) and Management SDK (for end-to-end code signing), please keep them in sync. + */ +const crypto = require("crypto"); +const fs = require("fs"); +const path = require("path"); +const q = require("q"); +// Do not throw an exception if either of these modules are missing, as they may not be needed by the +// consumer of this file. +// - recursiveFs: Only required for hashing of directories +// - yauzl: Only required for in-memory hashing of zip files +let recursiveFs, yauzl; +try { + recursiveFs = require("recursive-fs"); +} +catch (e) { } +try { + yauzl = require("yauzl"); +} +catch (e) { } +const HASH_ALGORITHM = "sha256"; +function generatePackageHashFromDirectory(directoryPath, basePath) { + if (!fs.lstatSync(directoryPath).isDirectory()) { + throw new Error("Not a directory. Please either create a directory, or use hashFile()."); + } + return generatePackageManifestFromDirectory(directoryPath, basePath).then((manifest) => { + return manifest.computePackageHash(); + }); +} +exports.generatePackageHashFromDirectory = generatePackageHashFromDirectory; +function generatePackageManifestFromZip(filePath) { + const deferred = q.defer(); + const reject = (error) => { + if (deferred.promise.isPending()) { + deferred.reject(error); + } + }; + const resolve = (manifest) => { + if (deferred.promise.isPending()) { + deferred.resolve(manifest); + } + }; + let zipFile; + yauzl.open(filePath, { lazyEntries: true }, (error, openedZipFile) => { + if (error) { + // This is the first time we try to read the package as a .zip file; + // however, it may not be a .zip file. Handle this gracefully. + resolve(null); + return; + } + zipFile = openedZipFile; + const fileHashesMap = new Map(); + const hashFilePromises = []; + // Read each entry in the archive sequentially and generate a hash for it. + zipFile.readEntry(); + zipFile + .on("error", (error) => { + reject(error); + }) + .on("entry", (entry) => { + const fileName = PackageManifest.normalizePath(entry.fileName); + if (PackageManifest.isIgnored(fileName)) { + zipFile.readEntry(); + return; + } + zipFile.openReadStream(entry, (error, readStream) => { + if (error) { + reject(error); + return; + } + hashFilePromises.push(hashStream(readStream).then((hash) => { + fileHashesMap.set(fileName, hash); + zipFile.readEntry(); + }, reject)); + }); + }) + .on("end", () => { + q.all(hashFilePromises).then(() => resolve(new PackageManifest(fileHashesMap)), reject); + }); + }); + return deferred.promise.finally(() => zipFile && zipFile.close()); +} +exports.generatePackageManifestFromZip = generatePackageManifestFromZip; +function generatePackageManifestFromDirectory(directoryPath, basePath) { + const deferred = q.defer(); + const fileHashesMap = new Map(); + recursiveFs.readdirr(directoryPath, (error, directories, files) => { + if (error) { + deferred.reject(error); + return; + } + if (!files || files.length === 0) { + deferred.reject("Error: Can't sign the release because no files were found."); + return; + } + // Hash the files sequentially, because streaming them in parallel is not necessarily faster + const generateManifestPromise = files.reduce((soFar, filePath) => { + return soFar.then(() => { + const relativePath = PackageManifest.normalizePath(path.relative(basePath, filePath)); + if (!PackageManifest.isIgnored(relativePath)) { + return hashFile(filePath).then((hash) => { + fileHashesMap.set(relativePath, hash); + }); + } + }); + }, q(null)); + generateManifestPromise + .then(() => { + deferred.resolve(new PackageManifest(fileHashesMap)); + }, deferred.reject) + .done(); + }); + return deferred.promise; +} +exports.generatePackageManifestFromDirectory = generatePackageManifestFromDirectory; +function hashFile(filePath) { + const readStream = fs.createReadStream(filePath); + return hashStream(readStream); +} +exports.hashFile = hashFile; +function hashStream(readStream) { + const hashStream = crypto.createHash(HASH_ALGORITHM); + const deferred = q.defer(); + readStream + .on("error", (error) => { + if (deferred.promise.isPending()) { + hashStream.end(); + deferred.reject(error); + } + }) + .on("end", () => { + if (deferred.promise.isPending()) { + hashStream.end(); + const buffer = hashStream.read(); + const hash = buffer.toString("hex"); + deferred.resolve(hash); + } + }); + readStream.pipe(hashStream); + return deferred.promise; +} +exports.hashStream = hashStream; +class PackageManifest { + _map; + constructor(map) { + if (!map) { + map = new Map(); + } + this._map = map; + } + toMap() { + return this._map; + } + computePackageHash() { + let entries = []; + this._map.forEach((hash, name) => { + entries.push(name + ":" + hash); + }); + // Make sure this list is alphabetically ordered so that other clients + // can also compute this hash easily given the update contents. + entries = entries.sort(); + return q(crypto.createHash(HASH_ALGORITHM).update(JSON.stringify(entries)).digest("hex")); + } + serialize() { + const obj = {}; + this._map.forEach(function (value, key) { + obj[key] = value; + }); + return JSON.stringify(obj); + } + static deserialize(serializedContents) { + try { + const obj = JSON.parse(serializedContents); + const map = new Map(); + for (const key of Object.keys(obj)) { + map.set(key, obj[key]); + } + return new PackageManifest(map); + } + catch (e) { } + } + static normalizePath(filePath) { + return filePath.replace(/\\/g, "/"); + } + static isIgnored(relativeFilePath) { + const __MACOSX = "__MACOSX/"; + const DS_STORE = ".DS_Store"; + return startsWith(relativeFilePath, __MACOSX) || relativeFilePath === DS_STORE || endsWith(relativeFilePath, "/" + DS_STORE); + } +} +exports.PackageManifest = PackageManifest; +function startsWith(str, prefix) { + return str && str.substring(0, prefix.length) === prefix; +} +function endsWith(str, suffix) { + return str && str.indexOf(suffix, str.length - suffix.length) !== -1; +} diff --git a/cli/bin/script/index.js b/cli/bin/script/index.js new file mode 100644 index 0000000..2349ec0 --- /dev/null +++ b/cli/bin/script/index.js @@ -0,0 +1,5 @@ +"use strict"; +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +const AccountManager = require("./management-sdk"); +module.exports = AccountManager; diff --git a/cli/bin/script/management-sdk.js b/cli/bin/script/management-sdk.js new file mode 100644 index 0000000..d2b9a7b --- /dev/null +++ b/cli/bin/script/management-sdk.js @@ -0,0 +1,419 @@ +"use strict"; +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const Q = require("q"); +const superagent = require("superagent"); +const recursiveFs = require("recursive-fs"); +const yazl = require("yazl"); +const slash = require("slash"); +var Promise = Q.Promise; +const packageJson = require("../../package.json"); +// A template string tag function that URL encodes the substituted values +function urlEncode(strings, ...values) { + let result = ""; + for (let i = 0; i < strings.length; i++) { + result += strings[i]; + if (i < values.length) { + result += encodeURIComponent(values[i]); + } + } + return result; +} +class AccountManager { + static AppPermission = { + OWNER: "Owner", + COLLABORATOR: "Collaborator", + }; + static SERVER_URL = "http://localhost:3000"; + static API_VERSION = 2; + static ERROR_GATEWAY_TIMEOUT = 504; // Used if there is a network error + static ERROR_INTERNAL_SERVER = 500; + static ERROR_NOT_FOUND = 404; + static ERROR_CONFLICT = 409; // Used if the resource already exists + static ERROR_UNAUTHORIZED = 401; + _accessKey; + _serverUrl; + _customHeaders; + constructor(accessKey, customHeaders, serverUrl) { + if (!accessKey) + throw new Error("An access key must be specified."); + this._accessKey = accessKey; + this._customHeaders = customHeaders; + this._serverUrl = serverUrl || AccountManager.SERVER_URL; + } + get accessKey() { + return this._accessKey; + } + isAuthenticated(throwIfUnauthorized) { + return Promise((resolve, reject, notify) => { + const request = superagent.get(`${this._serverUrl}${urlEncode(["/authenticated"])}`); + this.attachCredentials(request); + request.end((err, res) => { + const status = this.getErrorStatus(err, res); + if (err && status !== AccountManager.ERROR_UNAUTHORIZED) { + reject(this.getCodePushError(err, res)); + return; + } + const authenticated = status === 200; + if (!authenticated && throwIfUnauthorized) { + reject(this.getCodePushError(err, res)); + return; + } + resolve(authenticated); + }); + }); + } + addAccessKey(friendlyName, ttl) { + if (!friendlyName) { + throw new Error("A name must be specified when adding an access key."); + } + const accessKeyRequest = { + createdBy: os.hostname(), + friendlyName, + ttl, + }; + return this.post(urlEncode(["/accessKeys/"]), JSON.stringify(accessKeyRequest), /*expectResponseBody=*/ true).then((response) => { + return { + createdTime: response.body.accessKey.createdTime, + expires: response.body.accessKey.expires, + key: response.body.accessKey.name, + name: response.body.accessKey.friendlyName, + }; + }); + } + getAccessKey(accessKeyName) { + return this.get(urlEncode([`/accessKeys/${accessKeyName}`])).then((res) => { + return { + createdTime: res.body.accessKey.createdTime, + expires: res.body.accessKey.expires, + name: res.body.accessKey.friendlyName, + }; + }); + } + getAccessKeys() { + return this.get(urlEncode(["/accessKeys"])).then((res) => { + const accessKeys = []; + res.body.accessKeys.forEach((serverAccessKey) => { + !serverAccessKey.isSession && + accessKeys.push({ + createdTime: serverAccessKey.createdTime, + expires: serverAccessKey.expires, + name: serverAccessKey.friendlyName, + }); + }); + return accessKeys; + }); + } + getSessions() { + return this.get(urlEncode(["/accessKeys"])).then((res) => { + // A machine name might be associated with multiple session keys, + // but we should only return one per machine name. + const sessionMap = {}; + const now = new Date().getTime(); + res.body.accessKeys.forEach((serverAccessKey) => { + if (serverAccessKey.isSession && serverAccessKey.expires > now) { + sessionMap[serverAccessKey.createdBy] = { + loggedInTime: serverAccessKey.createdTime, + machineName: serverAccessKey.createdBy, + }; + } + }); + const sessions = Object.keys(sessionMap).map((machineName) => sessionMap[machineName]); + return sessions; + }); + } + patchAccessKey(oldName, newName, ttl) { + const accessKeyRequest = { + friendlyName: newName, + ttl, + }; + return this.patch(urlEncode([`/accessKeys/${oldName}`]), JSON.stringify(accessKeyRequest)).then((res) => { + return { + createdTime: res.body.accessKey.createdTime, + expires: res.body.accessKey.expires, + name: res.body.accessKey.friendlyName, + }; + }); + } + removeAccessKey(name) { + return this.del(urlEncode([`/accessKeys/${name}`])).then(() => null); + } + removeSession(machineName) { + return this.del(urlEncode([`/sessions/${machineName}`])).then(() => null); + } + // Account + getAccountInfo() { + return this.get(urlEncode(["/account"])).then((res) => res.body.account); + } + // Apps + getApps() { + return this.get(urlEncode(["/apps"])).then((res) => res.body.apps); + } + getApp(appName) { + return this.get(urlEncode([`/apps/${appName}`])).then((res) => res.body.app); + } + addApp(appName) { + const app = { name: appName }; + return this.post(urlEncode(["/apps/"]), JSON.stringify(app), /*expectResponseBody=*/ false).then(() => app); + } + removeApp(appName) { + return this.del(urlEncode([`/apps/${appName}`])).then(() => null); + } + renameApp(oldAppName, newAppName) { + return this.patch(urlEncode([`/apps/${oldAppName}`]), JSON.stringify({ name: newAppName })).then(() => null); + } + transferApp(appName, email) { + return this.post(urlEncode([`/apps/${appName}/transfer/${email}`]), /*requestBody=*/ null, /*expectResponseBody=*/ false).then(() => null); + } + // Collaborators + getCollaborators(appName) { + return this.get(urlEncode([`/apps/${appName}/collaborators`])).then((res) => res.body.collaborators); + } + addCollaborator(appName, email) { + return this.post(urlEncode([`/apps/${appName}/collaborators/${email}`]), + /*requestBody=*/ null, + /*expectResponseBody=*/ false).then(() => null); + } + removeCollaborator(appName, email) { + return this.del(urlEncode([`/apps/${appName}/collaborators/${email}`])).then(() => null); + } + // Deployments + addDeployment(appName, deploymentName, deploymentKey) { + const deployment = { name: deploymentName, key: deploymentKey }; + return this.post(urlEncode([`/apps/${appName}/deployments/`]), JSON.stringify(deployment), /*expectResponseBody=*/ true).then((res) => res.body.deployment); + } + clearDeploymentHistory(appName, deploymentName) { + return this.del(urlEncode([`/apps/${appName}/deployments/${deploymentName}/history`])).then(() => null); + } + getDeployments(appName) { + return this.get(urlEncode([`/apps/${appName}/deployments/`])).then((res) => res.body.deployments); + } + getDeployment(appName, deploymentName) { + return this.get(urlEncode([`/apps/${appName}/deployments/${deploymentName}`])).then((res) => res.body.deployment); + } + renameDeployment(appName, oldDeploymentName, newDeploymentName) { + return this.patch(urlEncode([`/apps/${appName}/deployments/${oldDeploymentName}`]), JSON.stringify({ name: newDeploymentName })).then(() => null); + } + removeDeployment(appName, deploymentName) { + return this.del(urlEncode([`/apps/${appName}/deployments/${deploymentName}`])).then(() => null); + } + getDeploymentMetrics(appName, deploymentName) { + return this.get(urlEncode([`/apps/${appName}/deployments/${deploymentName}/metrics`])).then((res) => res.body.metrics); + } + getDeploymentHistory(appName, deploymentName) { + return this.get(urlEncode([`/apps/${appName}/deployments/${deploymentName}/history`])).then((res) => res.body.history); + } + release(appName, deploymentName, filePath, targetBinaryVersion, updateMetadata, uploadProgressCallback) { + return Promise((resolve, reject, notify) => { + updateMetadata.appVersion = targetBinaryVersion; + const request = superagent.post(this._serverUrl + urlEncode([`/apps/${appName}/deployments/${deploymentName}/release`])); + this.attachCredentials(request); + const getPackageFilePromise = Q.Promise((resolve, reject) => { + this.packageFileFromPath(filePath) + .then((result) => { + resolve(result); + }) + .catch((error) => { + reject(error); + }); + }); + getPackageFilePromise.then((packageFile) => { + const file = fs.createReadStream(packageFile.path); + request + .attach("package", file) + .field("packageInfo", JSON.stringify(updateMetadata)) + .on("progress", (event) => { + if (uploadProgressCallback && event && event.total > 0) { + const currentProgress = (event.loaded / event.total) * 100; + uploadProgressCallback(currentProgress); + } + }) + .end((err, res) => { + if (packageFile.isTemporary) { + fs.unlinkSync(packageFile.path); + } + if (err) { + reject(this.getCodePushError(err, res)); + return; + } + if (res.ok) { + resolve(null); + } + else { + let body; + try { + body = JSON.parse(res.text); + } + catch (err) { } + if (body) { + reject({ + message: body.message, + statusCode: res && res.status, + }); + } + else { + reject({ + message: res.text, + statusCode: res && res.status, + }); + } + } + }); + }); + }); + } + patchRelease(appName, deploymentName, label, updateMetadata) { + updateMetadata.label = label; + const requestBody = JSON.stringify({ packageInfo: updateMetadata }); + return this.patch(urlEncode([`/apps/${appName}/deployments/${deploymentName}/release`]), requestBody, + /*expectResponseBody=*/ false).then(() => null); + } + promote(appName, sourceDeploymentName, destinationDeploymentName, updateMetadata) { + const requestBody = JSON.stringify({ packageInfo: updateMetadata }); + return this.post(urlEncode([`/apps/${appName}/deployments/${sourceDeploymentName}/promote/${destinationDeploymentName}`]), requestBody, + /*expectResponseBody=*/ false).then(() => null); + } + rollback(appName, deploymentName, targetRelease) { + return this.post(urlEncode([`/apps/${appName}/deployments/${deploymentName}/rollback/${targetRelease || ``}`]), + /*requestBody=*/ null, + /*expectResponseBody=*/ false).then(() => null); + } + packageFileFromPath(filePath) { + let getPackageFilePromise; + if (fs.lstatSync(filePath).isDirectory()) { + getPackageFilePromise = Promise((resolve, reject) => { + const directoryPath = filePath; + recursiveFs.readdirr(directoryPath, (error, directories, files) => { + if (error) { + reject(error); + return; + } + const baseDirectoryPath = path.dirname(directoryPath); + const fileName = this.generateRandomFilename(15) + ".zip"; + const zipFile = new yazl.ZipFile(); + const writeStream = fs.createWriteStream(fileName); + zipFile.outputStream + .pipe(writeStream) + .on("error", (error) => { + reject(error); + }) + .on("close", () => { + filePath = path.join(process.cwd(), fileName); + resolve({ isTemporary: true, path: filePath }); + }); + for (let i = 0; i < files.length; ++i) { + const file = files[i]; + // yazl does not like backslash (\) in the metadata path. + const relativePath = slash(path.relative(baseDirectoryPath, file)); + zipFile.addFile(file, relativePath); + } + zipFile.end(); + }); + }); + } + else { + getPackageFilePromise = Q({ isTemporary: false, path: filePath }); + } + return getPackageFilePromise; + } + generateRandomFilename(length) { + let filename = ""; + const validChar = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + for (let i = 0; i < length; i++) { + filename += validChar.charAt(Math.floor(Math.random() * validChar.length)); + } + return filename; + } + get(endpoint, expectResponseBody = true) { + return this.makeApiRequest("get", endpoint, /*requestBody=*/ null, expectResponseBody, /*contentType=*/ null); + } + post(endpoint, requestBody, expectResponseBody, contentType = "application/json;charset=UTF-8") { + return this.makeApiRequest("post", endpoint, requestBody, expectResponseBody, contentType); + } + patch(endpoint, requestBody, expectResponseBody = false, contentType = "application/json;charset=UTF-8") { + return this.makeApiRequest("patch", endpoint, requestBody, expectResponseBody, contentType); + } + del(endpoint, expectResponseBody = false) { + return this.makeApiRequest("del", endpoint, /*requestBody=*/ null, expectResponseBody, /*contentType=*/ null); + } + makeApiRequest(method, endpoint, requestBody, expectResponseBody, contentType) { + return Promise((resolve, reject, notify) => { + let request = superagent[method](this._serverUrl + endpoint); + this.attachCredentials(request); + if (requestBody) { + if (contentType) { + request = request.set("Content-Type", contentType); + } + request = request.send(requestBody); + } + request.end((err, res) => { + if (err) { + reject(this.getCodePushError(err, res)); + return; + } + let body; + try { + body = JSON.parse(res.text); + } + catch (err) { } + if (res.ok) { + if (expectResponseBody && !body) { + reject({ + message: `Could not parse response: ${res.text}`, + statusCode: AccountManager.ERROR_INTERNAL_SERVER, + }); + } + else { + resolve({ + headers: res.header, + body: body, + }); + } + } + else { + if (body) { + reject({ + message: body.message, + statusCode: this.getErrorStatus(err, res), + }); + } + else { + reject({ + message: res.text, + statusCode: this.getErrorStatus(err, res), + }); + } + } + }); + }); + } + getCodePushError(error, response) { + if (error.syscall === "getaddrinfo") { + error.message = `Unable to connect to the CodePush server. Are you offline, or behind a firewall or proxy?\n(${error.message})`; + } + return { + message: this.getErrorMessage(error, response), + statusCode: this.getErrorStatus(error, response), + }; + } + getErrorStatus(error, response) { + return (error && error.status) || (response && response.status) || AccountManager.ERROR_GATEWAY_TIMEOUT; + } + getErrorMessage(error, response) { + return response && response.text ? response.text : error.message; + } + attachCredentials(request) { + if (this._customHeaders) { + for (const headerName in this._customHeaders) { + request.set(headerName, this._customHeaders[headerName]); + } + } + request.set("Accept", `application/vnd.code-push.v${AccountManager.API_VERSION}+json`); + request.set("Authorization", `Bearer ${this._accessKey}`); + request.set("X-CodePush-SDK-Version", packageJson.version); + } +} +module.exports = AccountManager; diff --git a/cli/bin/script/react-native-utils.js b/cli/bin/script/react-native-utils.js new file mode 100644 index 0000000..d3a6387 --- /dev/null +++ b/cli/bin/script/react-native-utils.js @@ -0,0 +1,249 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.getReactNativeVersion = exports.directoryExistsSync = exports.getiOSHermesEnabled = exports.getAndroidHermesEnabled = exports.runHermesEmitBinaryCommand = exports.isValidVersion = void 0; +const fs = require("fs"); +const chalk = require("chalk"); +const path = require("path"); +const childProcess = require("child_process"); +const semver_1 = require("semver"); +const file_utils_1 = require("./utils/file-utils"); +const g2js = require("gradle-to-js/lib/parser"); +function isValidVersion(version) { + return !!(0, semver_1.valid)(version) || /^\d+\.\d+$/.test(version); +} +exports.isValidVersion = isValidVersion; +async function runHermesEmitBinaryCommand(bundleName, outputFolder, sourcemapOutput, extraHermesFlags, gradleFile) { + const hermesArgs = []; + const envNodeArgs = process.env.CODE_PUSH_NODE_ARGS; + if (typeof envNodeArgs !== "undefined") { + Array.prototype.push.apply(hermesArgs, envNodeArgs.trim().split(/\s+/)); + } + Array.prototype.push.apply(hermesArgs, [ + "-emit-binary", + "-out", + path.join(outputFolder, bundleName + ".hbc"), + path.join(outputFolder, bundleName), + ...extraHermesFlags, + ]); + if (sourcemapOutput) { + hermesArgs.push("-output-source-map"); + } + console.log(chalk.cyan("Converting JS bundle to byte code via Hermes, running command:\n")); + const hermesCommand = await getHermesCommand(gradleFile); + const hermesProcess = childProcess.spawn(hermesCommand, hermesArgs); + console.log(`${hermesCommand} ${hermesArgs.join(" ")}`); + return new Promise((resolve, reject) => { + hermesProcess.stdout.on("data", (data) => { + console.log(data.toString().trim()); + }); + hermesProcess.stderr.on("data", (data) => { + console.error(data.toString().trim()); + }); + hermesProcess.on("close", (exitCode, signal) => { + if (exitCode !== 0) { + reject(new Error(`"hermes" command failed (exitCode=${exitCode}, signal=${signal}).`)); + } + // Copy HBC bundle to overwrite JS bundle + const source = path.join(outputFolder, bundleName + ".hbc"); + const destination = path.join(outputFolder, bundleName); + fs.copyFile(source, destination, (err) => { + if (err) { + console.error(err); + reject(new Error(`Copying file ${source} to ${destination} failed. "hermes" previously exited with code ${exitCode}.`)); + } + fs.unlink(source, (err) => { + if (err) { + console.error(err); + reject(err); + } + resolve(null); + }); + }); + }); + }).then(() => { + if (!sourcemapOutput) { + // skip source map compose if source map is not enabled + return; + } + const composeSourceMapsPath = getComposeSourceMapsPath(); + if (!composeSourceMapsPath) { + throw new Error("react-native compose-source-maps.js scripts is not found"); + } + const jsCompilerSourceMapFile = path.join(outputFolder, bundleName + ".hbc" + ".map"); + if (!fs.existsSync(jsCompilerSourceMapFile)) { + throw new Error(`sourcemap file ${jsCompilerSourceMapFile} is not found`); + } + return new Promise((resolve, reject) => { + const composeSourceMapsArgs = [composeSourceMapsPath, sourcemapOutput, jsCompilerSourceMapFile, "-o", sourcemapOutput]; + // https://github.com/facebook/react-native/blob/master/react.gradle#L211 + // https://github.com/facebook/react-native/blob/master/scripts/react-native-xcode.sh#L178 + // packager.sourcemap.map + hbc.sourcemap.map = sourcemap.map + const composeSourceMapsProcess = childProcess.spawn("node", composeSourceMapsArgs); + console.log(`${composeSourceMapsPath} ${composeSourceMapsArgs.join(" ")}`); + composeSourceMapsProcess.stdout.on("data", (data) => { + console.log(data.toString().trim()); + }); + composeSourceMapsProcess.stderr.on("data", (data) => { + console.error(data.toString().trim()); + }); + composeSourceMapsProcess.on("close", (exitCode, signal) => { + if (exitCode !== 0) { + reject(new Error(`"compose-source-maps" command failed (exitCode=${exitCode}, signal=${signal}).`)); + } + // Delete the HBC sourceMap, otherwise it will be included in 'code-push' bundle as well + fs.unlink(jsCompilerSourceMapFile, (err) => { + if (err) { + console.error(err); + reject(err); + } + resolve(null); + }); + }); + }); + }); +} +exports.runHermesEmitBinaryCommand = runHermesEmitBinaryCommand; +function parseBuildGradleFile(gradleFile) { + let buildGradlePath = path.join("android", "app"); + if (gradleFile) { + buildGradlePath = gradleFile; + } + if (fs.lstatSync(buildGradlePath).isDirectory()) { + buildGradlePath = path.join(buildGradlePath, "build.gradle"); + } + if ((0, file_utils_1.fileDoesNotExistOrIsDirectory)(buildGradlePath)) { + throw new Error(`Unable to find gradle file "${buildGradlePath}".`); + } + return g2js.parseFile(buildGradlePath).catch(() => { + throw new Error(`Unable to parse the "${buildGradlePath}" file. Please ensure it is a well-formed Gradle file.`); + }); +} +async function getHermesCommandFromGradle(gradleFile) { + const buildGradle = await parseBuildGradleFile(gradleFile); + const hermesCommandProperty = Array.from(buildGradle["project.ext.react"] || []).find((prop) => prop.trim().startsWith("hermesCommand:")); + if (hermesCommandProperty) { + return hermesCommandProperty.replace("hermesCommand:", "").trim().slice(1, -1); + } + else { + return ""; + } +} +function getAndroidHermesEnabled(gradleFile) { + return parseBuildGradleFile(gradleFile).then((buildGradle) => { + return Array.from(buildGradle["project.ext.react"] || []).some((line) => /^enableHermes\s{0,}:\s{0,}true/.test(line)); + }); +} +exports.getAndroidHermesEnabled = getAndroidHermesEnabled; +function getiOSHermesEnabled(podFile) { + let podPath = path.join("ios", "Podfile"); + if (podFile) { + podPath = podFile; + } + if ((0, file_utils_1.fileDoesNotExistOrIsDirectory)(podPath)) { + throw new Error(`Unable to find Podfile file "${podPath}".`); + } + try { + const podFileContents = fs.readFileSync(podPath).toString(); + return /([^#\n]*:?hermes_enabled(\s+|\n+)?(=>|:)(\s+|\n+)?true)/.test(podFileContents); + } + catch (error) { + throw error; + } +} +exports.getiOSHermesEnabled = getiOSHermesEnabled; +function getHermesOSBin() { + switch (process.platform) { + case "win32": + return "win64-bin"; + case "darwin": + return "osx-bin"; + case "freebsd": + case "linux": + case "sunos": + default: + return "linux64-bin"; + } +} +function getHermesOSExe() { + const react63orAbove = (0, semver_1.compare)((0, semver_1.coerce)(getReactNativeVersion()).version, "0.63.0") !== -1; + const hermesExecutableName = react63orAbove ? "hermesc" : "hermes"; + switch (process.platform) { + case "win32": + return hermesExecutableName + ".exe"; + default: + return hermesExecutableName; + } +} +async function getHermesCommand(gradleFile) { + const fileExists = (file) => { + try { + return fs.statSync(file).isFile(); + } + catch (e) { + return false; + } + }; + // Hermes is bundled with react-native since 0.69 + const bundledHermesEngine = path.join(getReactNativePackagePath(), "sdks", "hermesc", getHermesOSBin(), getHermesOSExe()); + if (fileExists(bundledHermesEngine)) { + return bundledHermesEngine; + } + const gradleHermesCommand = await getHermesCommandFromGradle(gradleFile); + if (gradleHermesCommand) { + return path.join("android", "app", gradleHermesCommand.replace("%OS-BIN%", getHermesOSBin())); + } + else { + // assume if hermes-engine exists it should be used instead of hermesvm + const hermesEngine = path.join("node_modules", "hermes-engine", getHermesOSBin(), getHermesOSExe()); + if (fileExists(hermesEngine)) { + return hermesEngine; + } + return path.join("node_modules", "hermesvm", getHermesOSBin(), "hermes"); + } +} +function getComposeSourceMapsPath() { + // detect if compose-source-maps.js script exists + const composeSourceMaps = path.join(getReactNativePackagePath(), "scripts", "compose-source-maps.js"); + if (fs.existsSync(composeSourceMaps)) { + return composeSourceMaps; + } + return null; +} +function getReactNativePackagePath() { + const result = childProcess.spawnSync("node", ["--print", "require.resolve('react-native/package.json')"]); + const packagePath = path.dirname(result.stdout.toString()); + if (result.status === 0 && directoryExistsSync(packagePath)) { + return packagePath; + } + return path.join("node_modules", "react-native"); +} +function directoryExistsSync(dirname) { + try { + return fs.statSync(dirname).isDirectory(); + } + catch (err) { + if (err.code !== "ENOENT") { + throw err; + } + } + return false; +} +exports.directoryExistsSync = directoryExistsSync; +function getReactNativeVersion() { + let packageJsonFilename; + let projectPackageJson; + try { + packageJsonFilename = path.join(process.cwd(), "package.json"); + projectPackageJson = JSON.parse(fs.readFileSync(packageJsonFilename, "utf-8")); + } + catch (error) { + throw new Error(`Unable to find or read "package.json" in the CWD. The "release-react" command must be executed in a React Native project folder.`); + } + const projectName = projectPackageJson.name; + if (!projectName) { + throw new Error(`The "package.json" file in the CWD does not have the "name" field set.`); + } + return ((projectPackageJson.dependencies && projectPackageJson.dependencies["react-native"]) || + (projectPackageJson.devDependencies && projectPackageJson.devDependencies["react-native"])); +} +exports.getReactNativeVersion = getReactNativeVersion; diff --git a/cli/bin/script/sign.js b/cli/bin/script/sign.js new file mode 100644 index 0000000..ad574cf --- /dev/null +++ b/cli/bin/script/sign.js @@ -0,0 +1,69 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const fs = require("fs/promises"); +const hashUtils = require("./hash-utils"); +const path = require("path"); +const jwt = require("jsonwebtoken"); +const file_utils_1 = require("./utils/file-utils"); +const CURRENT_CLAIM_VERSION = "1.0.0"; +const METADATA_FILE_NAME = ".codepushrelease"; +async function sign(privateKeyPath, updateContentsPath) { + if (!privateKeyPath) { + return Promise.resolve(null); + } + let privateKey; + try { + privateKey = await fs.readFile(privateKeyPath); + } + catch (err) { + return Promise.reject(new Error(`The path specified for the signing key ("${privateKeyPath}") was not valid.`)); + } + // If releasing a single file, copy the file to a temporary 'CodePush' directory in which to publish the release + try { + if (!(0, file_utils_1.isDirectory)(updateContentsPath)) { + updateContentsPath = (0, file_utils_1.copyFileToTmpDir)(updateContentsPath); + } + } + catch (error) { + Promise.reject(error); + } + const signatureFilePath = path.join(updateContentsPath, METADATA_FILE_NAME); + let prevSignatureExists = true; + try { + await fs.access(signatureFilePath, fs.constants.F_OK); + } + catch (err) { + if (err.code === "ENOENT") { + prevSignatureExists = false; + } + else { + return Promise.reject(new Error(`Could not delete previous release signature at ${signatureFilePath}. + Please, check your access rights.`)); + } + } + if (prevSignatureExists) { + console.log(`Deleting previous release signature at ${signatureFilePath}`); + await fs.rmdir(signatureFilePath); + } + const hash = await hashUtils.generatePackageHashFromDirectory(updateContentsPath, path.join(updateContentsPath, "..")); + const claims = { + claimVersion: CURRENT_CLAIM_VERSION, + contentHash: hash, + }; + return new Promise((resolve, reject) => { + jwt.sign(claims, privateKey, { algorithm: "RS256" }, async (err, signedJwt) => { + if (err) { + reject(new Error("The specified signing key file was not valid")); + } + try { + await fs.writeFile(signatureFilePath, signedJwt); + console.log(`Generated a release signature and wrote it to ${signatureFilePath}`); + resolve(null); + } + catch (error) { + reject(error); + } + }); + }); +} +exports.default = sign; diff --git a/cli/bin/script/types.js b/cli/bin/script/types.js new file mode 100644 index 0000000..7b65627 --- /dev/null +++ b/cli/bin/script/types.js @@ -0,0 +1,4 @@ +"use strict"; +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/cli/bin/script/types/cli.js b/cli/bin/script/types/cli.js new file mode 100644 index 0000000..ddd2a0e --- /dev/null +++ b/cli/bin/script/types/cli.js @@ -0,0 +1,40 @@ +"use strict"; +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +Object.defineProperty(exports, "__esModule", { value: true }); +exports.CommandType = void 0; +var CommandType; +(function (CommandType) { + CommandType[CommandType["accessKeyAdd"] = 0] = "accessKeyAdd"; + CommandType[CommandType["accessKeyPatch"] = 1] = "accessKeyPatch"; + CommandType[CommandType["accessKeyList"] = 2] = "accessKeyList"; + CommandType[CommandType["accessKeyRemove"] = 3] = "accessKeyRemove"; + CommandType[CommandType["appAdd"] = 4] = "appAdd"; + CommandType[CommandType["appList"] = 5] = "appList"; + CommandType[CommandType["appRemove"] = 6] = "appRemove"; + CommandType[CommandType["appRename"] = 7] = "appRename"; + CommandType[CommandType["appTransfer"] = 8] = "appTransfer"; + CommandType[CommandType["collaboratorAdd"] = 9] = "collaboratorAdd"; + CommandType[CommandType["collaboratorList"] = 10] = "collaboratorList"; + CommandType[CommandType["collaboratorRemove"] = 11] = "collaboratorRemove"; + CommandType[CommandType["debug"] = 12] = "debug"; + CommandType[CommandType["deploymentAdd"] = 13] = "deploymentAdd"; + CommandType[CommandType["deploymentHistory"] = 14] = "deploymentHistory"; + CommandType[CommandType["deploymentHistoryClear"] = 15] = "deploymentHistoryClear"; + CommandType[CommandType["deploymentList"] = 16] = "deploymentList"; + CommandType[CommandType["deploymentMetrics"] = 17] = "deploymentMetrics"; + CommandType[CommandType["deploymentRemove"] = 18] = "deploymentRemove"; + CommandType[CommandType["deploymentRename"] = 19] = "deploymentRename"; + CommandType[CommandType["link"] = 20] = "link"; + CommandType[CommandType["login"] = 21] = "login"; + CommandType[CommandType["logout"] = 22] = "logout"; + CommandType[CommandType["patch"] = 23] = "patch"; + CommandType[CommandType["promote"] = 24] = "promote"; + CommandType[CommandType["register"] = 25] = "register"; + CommandType[CommandType["release"] = 26] = "release"; + CommandType[CommandType["releaseReact"] = 27] = "releaseReact"; + CommandType[CommandType["rollback"] = 28] = "rollback"; + CommandType[CommandType["sessionList"] = 29] = "sessionList"; + CommandType[CommandType["sessionRemove"] = 30] = "sessionRemove"; + CommandType[CommandType["whoami"] = 31] = "whoami"; +})(CommandType || (exports.CommandType = CommandType = {})); diff --git a/cli/bin/script/types/rest-definitions.js b/cli/bin/script/types/rest-definitions.js new file mode 100644 index 0000000..314699d --- /dev/null +++ b/cli/bin/script/types/rest-definitions.js @@ -0,0 +1,19 @@ +"use strict"; +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __exportStar = (this && this.__exportStar) || function(m, exports) { + for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +__exportStar(require("./rest-definitions"), exports); diff --git a/cli/bin/script/utils/file-utils.js b/cli/bin/script/utils/file-utils.js new file mode 100644 index 0000000..7d21030 --- /dev/null +++ b/cli/bin/script/utils/file-utils.js @@ -0,0 +1,50 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.normalizePath = exports.fileDoesNotExistOrIsDirectory = exports.copyFileToTmpDir = exports.fileExists = exports.isDirectory = exports.isBinaryOrZip = void 0; +const fs = require("fs"); +const path = require("path"); +const rimraf = require("rimraf"); +const temp = require("temp"); +function isBinaryOrZip(path) { + return path.search(/\.zip$/i) !== -1 || path.search(/\.apk$/i) !== -1 || path.search(/\.ipa$/i) !== -1; +} +exports.isBinaryOrZip = isBinaryOrZip; +function isDirectory(path) { + return fs.statSync(path).isDirectory(); +} +exports.isDirectory = isDirectory; +function fileExists(file) { + try { + return fs.statSync(file).isFile(); + } + catch (e) { + return false; + } +} +exports.fileExists = fileExists; +; +function copyFileToTmpDir(filePath) { + if (!isDirectory(filePath)) { + const outputFolderPath = temp.mkdirSync("code-push"); + rimraf.sync(outputFolderPath); + fs.mkdirSync(outputFolderPath); + const outputFilePath = path.join(outputFolderPath, path.basename(filePath)); + fs.writeFileSync(outputFilePath, fs.readFileSync(filePath)); + return outputFolderPath; + } +} +exports.copyFileToTmpDir = copyFileToTmpDir; +function fileDoesNotExistOrIsDirectory(path) { + try { + return isDirectory(path); + } + catch (error) { + return true; + } +} +exports.fileDoesNotExistOrIsDirectory = fileDoesNotExistOrIsDirectory; +function normalizePath(filePath) { + //replace all backslashes coming from cli running on windows machines by slashes + return filePath.replace(/\\/g, "/"); +} +exports.normalizePath = normalizePath; diff --git a/cli/bin/test/acquisition-rest-mock.js b/cli/bin/test/acquisition-rest-mock.js new file mode 100644 index 0000000..500dabf --- /dev/null +++ b/cli/bin/test/acquisition-rest-mock.js @@ -0,0 +1,108 @@ +"use strict"; +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +Object.defineProperty(exports, "__esModule", { value: true }); +exports.CustomResponseHttpRequester = exports.HttpRequester = exports.serverUrl = exports.latestPackage = exports.validDeploymentKey = void 0; +const querystring = require("querystring"); +exports.validDeploymentKey = "asdfasdfawerqw"; +exports.latestPackage = { + downloadURL: "http://www.windowsazure.com/blobs/awperoiuqpweru", + description: "Angry flappy birds", + appVersion: "1.5.0", + label: "2.4.0", + isMandatory: false, + isAvailable: true, + updateAppVersion: false, + packageHash: "hash240", + packageSize: 1024, +}; +exports.serverUrl = "http://myurl.com"; +var reportStatusDeployUrl = exports.serverUrl + "/reportStatus/deploy"; +var reportStatusDownloadUrl = exports.serverUrl + "/reportStatus/download"; +var updateCheckUrl = exports.serverUrl + "/updateCheck?"; +class HttpRequester { + request(verb, url, requestBodyOrCallback, callback) { + if (!callback && typeof requestBodyOrCallback === "function") { + callback = requestBodyOrCallback; + } + if (verb === 0 /* acquisitionSdk.Http.Verb.GET */ && url.indexOf(updateCheckUrl) === 0) { + var params = querystring.parse(url.substring(updateCheckUrl.length)); + Server.onUpdateCheck(params, callback); + } + else if (verb === 2 /* acquisitionSdk.Http.Verb.POST */ && url === reportStatusDeployUrl) { + Server.onReportStatus(callback); + } + else if (verb === 2 /* acquisitionSdk.Http.Verb.POST */ && url === reportStatusDownloadUrl) { + Server.onReportStatus(callback); + } + else { + throw new Error("Unexpected call"); + } + } +} +exports.HttpRequester = HttpRequester; +class CustomResponseHttpRequester { + response; + constructor(response) { + this.response = response; + } + request(verb, url, requestBodyOrCallback, callback) { + if (typeof requestBodyOrCallback !== "function") { + throw new Error("Unexpected request body"); + } + callback = requestBodyOrCallback; + callback(null, this.response); + } +} +exports.CustomResponseHttpRequester = CustomResponseHttpRequester; +class Server { + static onAcquire(params, callback) { + if (params.deploymentKey !== exports.validDeploymentKey) { + callback(/*error=*/ null, { + statusCode: 200, + body: JSON.stringify({ updateInfo: { isAvailable: false } }), + }); + } + else { + callback(/*error=*/ null, { + statusCode: 200, + body: JSON.stringify({ updateInfo: exports.latestPackage }), + }); + } + } + static onUpdateCheck(params, callback) { + var updateRequest = { + deploymentKey: params.deploymentKey, + appVersion: params.appVersion, + packageHash: params.packageHash, + isCompanion: !!params.isCompanion, + label: params.label, + }; + if (!updateRequest.deploymentKey || !updateRequest.appVersion) { + callback(/*error=*/ null, { statusCode: 400 }); + } + else { + var updateInfo = { isAvailable: false }; + if (updateRequest.deploymentKey === exports.validDeploymentKey) { + if (updateRequest.isCompanion || updateRequest.appVersion === exports.latestPackage.appVersion) { + if (updateRequest.packageHash !== exports.latestPackage.packageHash) { + updateInfo = exports.latestPackage; + } + } + else if (updateRequest.appVersion < exports.latestPackage.appVersion) { + updateInfo = { + updateAppVersion: true, + appVersion: exports.latestPackage.appVersion, + }; + } + } + callback(/*error=*/ null, { + statusCode: 200, + body: JSON.stringify({ updateInfo: updateInfo }), + }); + } + } + static onReportStatus(callback) { + callback(/*error*/ null, /*response*/ { statusCode: 200 }); + } +} diff --git a/cli/bin/test/acquisition-sdk.js b/cli/bin/test/acquisition-sdk.js new file mode 100644 index 0000000..8202c4b --- /dev/null +++ b/cli/bin/test/acquisition-sdk.js @@ -0,0 +1,188 @@ +"use strict"; +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +Object.defineProperty(exports, "__esModule", { value: true }); +const assert = require("assert"); +const acquisitionSdk = require("../script/acquisition-sdk"); +const mockApi = require("./acquisition-rest-mock"); +var latestPackage = clone(mockApi.latestPackage); +var configuration = { + appVersion: "1.5.0", + clientUniqueId: "My iPhone", + deploymentKey: mockApi.validDeploymentKey, + serverUrl: mockApi.serverUrl, +}; +var templateCurrentPackage = { + deploymentKey: mockApi.validDeploymentKey, + description: "sdfsdf", + label: "v1", + appVersion: latestPackage.appVersion, + packageHash: "hash001", + isMandatory: false, + packageSize: 100, +}; +var scriptUpdateResult = { + deploymentKey: mockApi.validDeploymentKey, + description: latestPackage.description, + downloadUrl: latestPackage.downloadURL, + label: latestPackage.label, + appVersion: latestPackage.appVersion, + isMandatory: latestPackage.isMandatory, + packageHash: latestPackage.packageHash, + packageSize: latestPackage.packageSize, +}; +var nativeUpdateResult = { + updateAppVersion: true, + appVersion: latestPackage.appVersion, +}; +describe("Acquisition SDK", () => { + it("Package with lower label and different package hash gives update", (done) => { + var acquisition = new acquisitionSdk.AcquisitionManager(new mockApi.HttpRequester(), configuration); + acquisition.queryUpdateWithCurrentPackage(templateCurrentPackage, (error, returnPackage) => { + assert.equal(null, error); + assert.deepEqual(scriptUpdateResult, returnPackage); + done(); + }); + }); + it("Package with equal package hash gives no update", (done) => { + var equalVersionPackage = clone(templateCurrentPackage); + equalVersionPackage.packageHash = latestPackage.packageHash; + var acquisition = new acquisitionSdk.AcquisitionManager(new mockApi.HttpRequester(), configuration); + acquisition.queryUpdateWithCurrentPackage(equalVersionPackage, (error, returnPackage) => { + assert.equal(null, error); + assert.equal(null, returnPackage); + done(); + }); + }); + it("Package with higher different hash and higher label version gives update", (done) => { + var higherVersionPackage = clone(templateCurrentPackage); + higherVersionPackage.packageHash = "hash990"; + var acquisition = new acquisitionSdk.AcquisitionManager(new mockApi.HttpRequester(), configuration); + acquisition.queryUpdateWithCurrentPackage(higherVersionPackage, (error, returnPackage) => { + assert.equal(null, error); + assert.deepEqual(scriptUpdateResult, returnPackage); + done(); + }); + }); + it("Package with lower native version gives update notification", (done) => { + var lowerAppVersionPackage = clone(templateCurrentPackage); + lowerAppVersionPackage.appVersion = "0.0.1"; + var acquisition = new acquisitionSdk.AcquisitionManager(new mockApi.HttpRequester(), configuration); + acquisition.queryUpdateWithCurrentPackage(lowerAppVersionPackage, (error, returnPackage) => { + assert.equal(null, error); + assert.deepEqual(nativeUpdateResult, returnPackage); + done(); + }); + }); + it("Package with higher native version gives no update", (done) => { + var higherAppVersionPackage = clone(templateCurrentPackage); + higherAppVersionPackage.appVersion = "9.9.0"; + var acquisition = new acquisitionSdk.AcquisitionManager(new mockApi.HttpRequester(), configuration); + acquisition.queryUpdateWithCurrentPackage(higherAppVersionPackage, (error, returnPackage) => { + assert.equal(null, error); + assert.deepEqual(null, returnPackage); + done(); + }); + }); + it("An empty response gives no update", (done) => { + var lowerAppVersionPackage = clone(templateCurrentPackage); + lowerAppVersionPackage.appVersion = "0.0.1"; + var emptyReponse = { + statusCode: 200, + body: JSON.stringify({}), + }; + var acquisition = new acquisitionSdk.AcquisitionManager(new mockApi.CustomResponseHttpRequester(emptyReponse), configuration); + acquisition.queryUpdateWithCurrentPackage(lowerAppVersionPackage, (error, returnPackage) => { + assert.equal(null, error); + done(); + }); + }); + it("An unexpected (but valid) JSON response gives no update", (done) => { + var lowerAppVersionPackage = clone(templateCurrentPackage); + lowerAppVersionPackage.appVersion = "0.0.1"; + var unexpectedResponse = { + statusCode: 200, + body: JSON.stringify({ unexpected: "response" }), + }; + var acquisition = new acquisitionSdk.AcquisitionManager(new mockApi.CustomResponseHttpRequester(unexpectedResponse), configuration); + acquisition.queryUpdateWithCurrentPackage(lowerAppVersionPackage, (error, returnPackage) => { + assert.equal(null, error); + done(); + }); + }); + it("Package for companion app ignores high native version and gives update", (done) => { + var higherAppVersionCompanionPackage = clone(templateCurrentPackage); + higherAppVersionCompanionPackage.appVersion = "9.9.0"; + var companionAppConfiguration = clone(configuration); + configuration.ignoreAppVersion = true; + var acquisition = new acquisitionSdk.AcquisitionManager(new mockApi.HttpRequester(), configuration); + acquisition.queryUpdateWithCurrentPackage(higherAppVersionCompanionPackage, (error, returnPackage) => { + assert.equal(null, error); + assert.deepEqual(scriptUpdateResult, returnPackage); + done(); + }); + }); + it("If latest package is mandatory, returned package is mandatory", (done) => { + mockApi.latestPackage.isMandatory = true; + var acquisition = new acquisitionSdk.AcquisitionManager(new mockApi.HttpRequester(), configuration); + acquisition.queryUpdateWithCurrentPackage(templateCurrentPackage, (error, returnPackage) => { + assert.equal(null, error); + assert.equal(true, returnPackage.isMandatory); + done(); + }); + }); + it("If invalid arguments are provided, an error is raised", (done) => { + var invalidPackage = clone(templateCurrentPackage); + invalidPackage.appVersion = null; + var acquisition = new acquisitionSdk.AcquisitionManager(new mockApi.HttpRequester(), configuration); + try { + acquisition.queryUpdateWithCurrentPackage(invalidPackage, (error, returnPackage) => { + assert.fail("Should throw an error if the native implementation gave an incorrect package"); + done(); + }); + } + catch (error) { + done(); + } + }); + it("If an invalid JSON response is returned by the server, an error is raised", (done) => { + var lowerAppVersionPackage = clone(templateCurrentPackage); + lowerAppVersionPackage.appVersion = "0.0.1"; + var invalidJsonReponse = { + statusCode: 200, + body: "invalid {{ json", + }; + var acquisition = new acquisitionSdk.AcquisitionManager(new mockApi.CustomResponseHttpRequester(invalidJsonReponse), configuration); + acquisition.queryUpdateWithCurrentPackage(lowerAppVersionPackage, (error, returnPackage) => { + assert.notEqual(null, error); + done(); + }); + }); + it("If deploymentKey is not valid...", (done) => { + // TODO: behaviour is not defined + done(); + }); + it("reportStatusDeploy(...) signals completion", (done) => { + var acquisition = new acquisitionSdk.AcquisitionManager(new mockApi.HttpRequester(), configuration); + acquisition.reportStatusDeploy(templateCurrentPackage, acquisitionSdk.AcquisitionStatus.DeploymentFailed, "1.5.0", mockApi.validDeploymentKey, (error, parameter) => { + if (error) { + throw error; + } + assert.equal(parameter, /*expected*/ null); + done(); + }); + }); + it("reportStatusDownload(...) signals completion", (done) => { + var acquisition = new acquisitionSdk.AcquisitionManager(new mockApi.HttpRequester(), configuration); + acquisition.reportStatusDownload(templateCurrentPackage, (error, parameter) => { + if (error) { + throw error; + } + assert.equal(parameter, /*expected*/ null); + done(); + }); + }); +}); +function clone(initialObject) { + return JSON.parse(JSON.stringify(initialObject)); +} diff --git a/cli/bin/test/cli.js b/cli/bin/test/cli.js new file mode 100644 index 0000000..589c6dc --- /dev/null +++ b/cli/bin/test/cli.js @@ -0,0 +1,1342 @@ +"use strict"; +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +Object.defineProperty(exports, "__esModule", { value: true }); +exports.SdkStub = void 0; +const assert = require("assert"); +const sinon = require("sinon"); +const Q = require("q"); +const path = require("path"); +const cli = require("../script/types/cli"); +const cmdexec = require("../script/command-executor"); +const os = require("os"); +function assertJsonDescribesObject(json, object) { + // Make sure JSON is indented correctly + assert.equal(json, JSON.stringify(object, /*replacer=*/ null, /*spacing=*/ 2)); +} +function clone(obj) { + return JSON.parse(JSON.stringify(obj)); +} +function ensureInTestAppDirectory() { + if (!~__dirname.indexOf("/resources/TestApp")) { + process.chdir(__dirname + "/resources/TestApp"); + } +} +function isDefined(object) { + return object !== undefined && object !== null; +} +const NOW = 1471460856191; +const DEFAULT_ACCESS_KEY_MAX_AGE = 1000 * 60 * 60 * 24 * 60; // 60 days +const TEST_MACHINE_NAME = "Test machine"; +class SdkStub { + productionDeployment = { + name: "Production", + key: "6", + }; + stagingDeployment = { + name: "Staging", + key: "6", + package: { + appVersion: "1.0.0", + description: "fgh", + label: "v2", + packageHash: "jkl", + isMandatory: true, + size: 10, + blobUrl: "http://mno.pqr", + uploadTime: 1000, + }, + }; + getAccountInfo() { + return Q({ + email: "a@a.com", + }); + } + addAccessKey(name, ttl) { + return Q({ + key: "key123", + createdTime: new Date().getTime(), + name, + expires: NOW + (isDefined(ttl) ? ttl : DEFAULT_ACCESS_KEY_MAX_AGE), + }); + } + patchAccessKey(newName, newTtl) { + return Q({ + createdTime: new Date().getTime(), + name: newName, + expires: NOW + (isDefined(newTtl) ? newTtl : DEFAULT_ACCESS_KEY_MAX_AGE), + }); + } + addApp(name) { + return Q({ + name: name, + }); + } + addCollaborator() { + return Q(null); + } + addDeployment(deploymentName) { + return Q({ + name: deploymentName, + key: "6", + }); + } + clearDeploymentHistory() { + return Q(null); + } + getAccessKeys() { + return Q([ + { + createdTime: 0, + name: "Test name", + expires: NOW + DEFAULT_ACCESS_KEY_MAX_AGE, + }, + ]); + } + getSessions() { + return Q([ + { + loggedInTime: 0, + machineName: TEST_MACHINE_NAME, + }, + ]); + } + getApps() { + return Q([ + { + name: "a", + collaborators: { + "a@a.com": { permission: "Owner", isCurrentAccount: true }, + }, + deployments: ["Production", "Staging"], + }, + { + name: "b", + collaborators: { + "a@a.com": { permission: "Owner", isCurrentAccount: true }, + }, + deployments: ["Production", "Staging"], + }, + ]); + } + getDeployments(appName) { + if (appName === "a") { + return Q([this.productionDeployment, this.stagingDeployment]); + } + return Q.reject(); + } + getDeployment(appName, deploymentName) { + if (appName === "a") { + if (deploymentName === "Production") { + return Q(this.productionDeployment); + } + else if (deploymentName === "Staging") { + return Q(this.stagingDeployment); + } + } + return Q.reject(); + } + getDeploymentHistory() { + return Q([ + { + description: null, + appVersion: "1.0.0", + isMandatory: false, + packageHash: "463acc7d06adc9c46233481d87d9e8264b3e9ffe60fe98d721e6974209dc71a0", + blobUrl: "https://fakeblobstorage.net/storagev2/blobid1", + uploadTime: 1447113596270, + size: 1, + label: "v1", + }, + { + description: "New update - this update does a whole bunch of things, including testing linewrapping", + appVersion: "1.0.1", + isMandatory: false, + packageHash: "463acc7d06adc9c46233481d87d9e8264b3e9ffe60fe98d721e6974209dc71a0", + blobUrl: "https://fakeblobstorage.net/storagev2/blobid2", + uploadTime: 1447118476669, + size: 2, + label: "v2", + }, + ]); + } + getDeploymentMetrics() { + return Q({ + "1.0.0": { + active: 123, + }, + v1: { + active: 789, + downloaded: 456, + failed: 654, + installed: 987, + }, + v2: { + active: 123, + downloaded: 321, + failed: 789, + installed: 456, + }, + }); + } + getCollaborators() { + return Q({ + "a@a.com": { + permission: "Owner", + isCurrentAccount: true, + }, + "b@b.com": { + permission: "Collaborator", + isCurrentAccount: false, + }, + }); + } + patchRelease() { + return Q(null); + } + promote() { + return Q(null); + } + release() { + return Q("Successfully released"); + } + removeAccessKey() { + return Q(null); + } + removeApp() { + return Q(null); + } + removeCollaborator() { + return Q(null); + } + removeDeployment() { + return Q(null); + } + removeSession() { + return Q(null); + } + renameApp() { + return Q(null); + } + rollback() { + return Q(null); + } + transferApp() { + return Q(null); + } + renameDeployment() { + return Q(null); + } +} +exports.SdkStub = SdkStub; +describe("CLI", () => { + var log; + var sandbox; + var spawn; + var wasConfirmed = true; + const INVALID_RELEASE_FILE_ERROR_MESSAGE = "It is unnecessary to package releases in a .zip or binary file. Please specify the direct path to the update content's directory (e.g. /platforms/ios/www) or file (e.g. main.jsbundle)."; + beforeEach(() => { + wasConfirmed = true; + sandbox = sinon.createSandbox(); + sandbox.stub(cmdexec, "confirm").returns(Q.Promise((resolve) => { + resolve(wasConfirmed); + })); + sandbox.stub(cmdexec, "createEmptyTempReleaseFolder").callsFake(() => Q.Promise((resolve) => resolve())); + log = sandbox.stub(cmdexec, "log").callsFake(() => { }); + spawn = sandbox.stub(cmdexec, "spawn").callsFake(() => { + return { + stdout: { on: () => { } }, + stderr: { on: () => { } }, + on: (event, callback) => { + callback(); + }, + }; + }); + }); + afterEach(() => { + sandbox.restore(); + }); + it("accessKeyAdd creates access key with name and default ttl", (done) => { + var command = { + type: cli.CommandType.accessKeyAdd, + name: "Test name", + }; + cmdexec.execute(command).done(() => { + sinon.assert.calledTwice(log); + assert.equal(log.args[0].length, 1); + var actual = log.args[0][0]; + var expected = `Successfully created the "Test name" access key: key123`; + assert.equal(actual, expected); + actual = log.args[1][0]; + expected = "Make sure to save this key value somewhere safe, since you won't be able to view it from the CLI again!"; + assert.equal(actual, expected); + done(); + }); + }); + it("accessKeyAdd creates access key with name and specified ttl", (done) => { + var ttl = 10000; + var command = { + type: cli.CommandType.accessKeyAdd, + name: "Test name", + ttl, + }; + cmdexec.execute(command).done(() => { + sinon.assert.calledTwice(log); + assert.equal(log.args[0].length, 1); + var actual = log.args[0][0]; + var expected = `Successfully created the "Test name" access key: key123`; + assert.equal(actual, expected); + actual = log.args[1][0]; + expected = "Make sure to save this key value somewhere safe, since you won't be able to view it from the CLI again!"; + assert.equal(actual, expected); + done(); + }); + }); + it("accessKeyPatch updates access key with new name", (done) => { + var command = { + type: cli.CommandType.accessKeyPatch, + oldName: "Test name", + newName: "Updated name", + }; + cmdexec.execute(command).done(() => { + sinon.assert.calledOnce(log); + assert.equal(log.args[0].length, 1); + var actual = log.args[0][0]; + var expected = `Successfully renamed the access key "Test name" to "Updated name".`; + assert.equal(actual, expected); + done(); + }); + }); + it("accessKeyPatch updates access key with new ttl", (done) => { + var ttl = 10000; + var command = { + type: cli.CommandType.accessKeyPatch, + oldName: "Test name", + ttl, + }; + cmdexec.execute(command).done(() => { + sinon.assert.calledOnce(log); + assert.equal(log.args[0].length, 1); + var actual = log.args[0][0]; + var expected = `Successfully changed the expiration date of the "Test name" access key to Wednesday, August 17, 2016 12:07 PM.`; + assert.equal(actual, expected); + done(); + }); + }); + it("accessKeyPatch updates access key with new name and ttl", (done) => { + var ttl = 10000; + var command = { + type: cli.CommandType.accessKeyPatch, + oldName: "Test name", + newName: "Updated name", + ttl, + }; + cmdexec.execute(command).done(() => { + sinon.assert.calledOnce(log); + assert.equal(log.args[0].length, 1); + var actual = log.args[0][0]; + var expected = `Successfully renamed the access key "Test name" to "Updated name" and changed its expiration date to Wednesday, August 17, 2016 12:07 PM.`; + assert.equal(actual, expected); + done(); + }); + }); + it("accessKeyList lists access key name and expires fields", (done) => { + var command = { + type: cli.CommandType.accessKeyList, + format: "json", + }; + cmdexec.execute(command).done(() => { + sinon.assert.calledOnce(log); + assert.equal(log.args[0].length, 1); + var actual = log.args[0][0]; + var expected = [ + { + createdTime: 0, + name: "Test name", + expires: NOW + DEFAULT_ACCESS_KEY_MAX_AGE, + }, + ]; + assertJsonDescribesObject(actual, expected); + done(); + }); + }); + it("accessKeyRemove removes access key", (done) => { + var command = { + type: cli.CommandType.accessKeyRemove, + accessKey: "8", + }; + var removeAccessKey = sandbox.spy(cmdexec.sdk, "removeAccessKey"); + cmdexec.execute(command).done(() => { + sinon.assert.calledOnce(removeAccessKey); + sinon.assert.calledWithExactly(removeAccessKey, "8"); + sinon.assert.calledOnce(log); + sinon.assert.calledWithExactly(log, 'Successfully removed the "8" access key.'); + done(); + }); + }); + it("accessKeyRemove does not remove access key if cancelled", (done) => { + var command = { + type: cli.CommandType.accessKeyRemove, + accessKey: "8", + }; + var removeAccessKey = sandbox.spy(cmdexec.sdk, "removeAccessKey"); + wasConfirmed = false; + cmdexec.execute(command).done(() => { + sinon.assert.notCalled(removeAccessKey); + sinon.assert.calledOnce(log); + sinon.assert.calledWithExactly(log, "Access key removal cancelled."); + done(); + }); + }); + it("appAdd reports new app name and ID", (done) => { + var command = { + type: cli.CommandType.appAdd, + appName: "a", + os: "", + platform: "", + }; + var addApp = sandbox.spy(cmdexec.sdk, "addApp"); + var deploymentList = sandbox.spy(cmdexec, "deploymentList"); + cmdexec.execute(command).done(() => { + sinon.assert.calledOnce(addApp); + sinon.assert.calledTwice(log); + sinon.assert.calledWithExactly(log, 'Successfully added the "a" app, along with the following default deployments:'); + sinon.assert.calledOnce(deploymentList); + done(); + }); + }); + it("appList lists app names and ID's", (done) => { + var command = { + type: cli.CommandType.appList, + format: "json", + }; + cmdexec.execute(command).done(() => { + sinon.assert.calledOnce(log); + assert.equal(log.args[0].length, 1); + var actual = log.args[0][0]; + var expected = [ + { + name: "a", + collaborators: { + "a@a.com": { + permission: "Owner", + isCurrentAccount: true, + }, + }, + deployments: ["Production", "Staging"], + }, + { + name: "b", + collaborators: { + "a@a.com": { + permission: "Owner", + isCurrentAccount: true, + }, + }, + deployments: ["Production", "Staging"], + }, + ]; + assertJsonDescribesObject(actual, expected); + done(); + }); + }); + it("appRemove removes app", (done) => { + var command = { + type: cli.CommandType.appRemove, + appName: "a", + }; + var removeApp = sandbox.spy(cmdexec.sdk, "removeApp"); + cmdexec.execute(command).done(() => { + sinon.assert.calledOnce(removeApp); + sinon.assert.calledWithExactly(removeApp, "a"); + sinon.assert.calledOnce(log); + sinon.assert.calledWithExactly(log, 'Successfully removed the "a" app.'); + done(); + }); + }); + it("appRemove does not remove app if cancelled", (done) => { + var command = { + type: cli.CommandType.appRemove, + appName: "a", + }; + var removeApp = sandbox.spy(cmdexec.sdk, "removeApp"); + wasConfirmed = false; + cmdexec.execute(command).done(() => { + sinon.assert.notCalled(removeApp); + sinon.assert.calledOnce(log); + sinon.assert.calledWithExactly(log, "App removal cancelled."); + done(); + }); + }); + it("appRename renames app", (done) => { + var command = { + type: cli.CommandType.appRename, + currentAppName: "a", + newAppName: "c", + }; + var renameApp = sandbox.spy(cmdexec.sdk, "renameApp"); + cmdexec.execute(command).done(() => { + sinon.assert.calledOnce(renameApp); + sinon.assert.calledOnce(log); + sinon.assert.calledWithExactly(log, 'Successfully renamed the "a" app to "c".'); + done(); + }); + }); + it("appTransfer transfers app", (done) => { + var command = { + type: cli.CommandType.appTransfer, + appName: "a", + email: "b@b.com", + }; + var transferApp = sandbox.spy(cmdexec.sdk, "transferApp"); + cmdexec.execute(command).done(() => { + sinon.assert.calledOnce(transferApp); + sinon.assert.calledOnce(log); + sinon.assert.calledWithExactly(log, 'Successfully transferred the ownership of app "a" to the account with email "b@b.com".'); + done(); + }); + }); + it("collaboratorAdd adds collaborator", (done) => { + var command = { + type: cli.CommandType.collaboratorAdd, + appName: "a", + email: "b@b.com", + }; + var addCollaborator = sandbox.spy(cmdexec.sdk, "addCollaborator"); + cmdexec.execute(command).done(() => { + sinon.assert.calledOnce(addCollaborator); + sinon.assert.calledOnce(log); + sinon.assert.calledWithExactly(log, 'Successfully added "b@b.com" as a collaborator to the app "a".'); + done(); + }); + }); + it("collaboratorList lists collaborators email and properties", (done) => { + var command = { + type: cli.CommandType.collaboratorList, + appName: "a", + format: "json", + }; + cmdexec.execute(command).done(() => { + sinon.assert.calledOnce(log); + assert.equal(log.args[0].length, 1); + var actual = log.args[0][0]; + var expected = { + collaborators: { + "a@a.com": { permission: "Owner", isCurrentAccount: true }, + "b@b.com": { permission: "Collaborator", isCurrentAccount: false }, + }, + }; + assertJsonDescribesObject(actual, expected); + done(); + }); + }); + it("collaboratorRemove removes collaborator", (done) => { + var command = { + type: cli.CommandType.collaboratorRemove, + appName: "a", + email: "b@b.com", + }; + var removeCollaborator = sandbox.spy(cmdexec.sdk, "removeCollaborator"); + cmdexec.execute(command).done(() => { + sinon.assert.calledOnce(removeCollaborator); + sinon.assert.calledOnce(log); + sinon.assert.calledWithExactly(log, 'Successfully removed "b@b.com" as a collaborator from the app "a".'); + done(); + }); + }); + it("deploymentAdd reports new app name and ID", (done) => { + var command = { + type: cli.CommandType.deploymentAdd, + appName: "a", + deploymentName: "b", + default: false, + }; + var addDeployment = sandbox.spy(cmdexec.sdk, "addDeployment"); + cmdexec.execute(command).done(() => { + sinon.assert.calledOnce(addDeployment); + sinon.assert.calledOnce(log); + sinon.assert.calledWithExactly(log, 'Successfully added the "b" deployment with key "6" to the "a" app.'); + done(); + }); + }); + it("deploymentHistoryClear clears deployment", (done) => { + var command = { + type: cli.CommandType.deploymentHistoryClear, + appName: "a", + deploymentName: "Staging", + }; + var clearDeployment = sandbox.spy(cmdexec.sdk, "clearDeploymentHistory"); + cmdexec.execute(command).done(() => { + sinon.assert.calledOnce(clearDeployment); + sinon.assert.calledWithExactly(clearDeployment, "a", "Staging"); + sinon.assert.calledOnce(log); + sinon.assert.calledWithExactly(log, 'Successfully cleared the release history associated with the "Staging" deployment from the "a" app.'); + done(); + }); + }); + it("deploymentHistoryClear does not clear deployment if cancelled", (done) => { + var command = { + type: cli.CommandType.deploymentHistoryClear, + appName: "a", + deploymentName: "Staging", + }; + var clearDeployment = sandbox.spy(cmdexec.sdk, "clearDeploymentHistory"); + wasConfirmed = false; + cmdexec.execute(command).done(() => { + sinon.assert.notCalled(clearDeployment); + sinon.assert.calledOnce(log); + sinon.assert.calledWithExactly(log, "Clear deployment cancelled."); + done(); + }); + }); + it("deploymentList lists deployment names, deployment keys, and package information", (done) => { + var command = { + type: cli.CommandType.deploymentList, + appName: "a", + format: "json", + displayKeys: true, + }; + cmdexec.execute(command).done(() => { + sinon.assert.calledOnce(log); + assert.equal(log.args[0].length, 1); + var actual = log.args[0][0]; + var expected = [ + { + name: "Production", + key: "6", + }, + { + name: "Staging", + key: "6", + package: { + appVersion: "1.0.0", + description: "fgh", + label: "v2", + packageHash: "jkl", + isMandatory: true, + size: 10, + blobUrl: "http://mno.pqr", + uploadTime: 1000, + metrics: { + active: 123, + downloaded: 321, + failed: 789, + installed: 456, + totalActive: 1035, + }, + }, + }, + ]; + assertJsonDescribesObject(actual, expected); + done(); + }); + }); + it("deploymentRemove removes deployment", (done) => { + var command = { + type: cli.CommandType.deploymentRemove, + appName: "a", + deploymentName: "Staging", + }; + var removeDeployment = sandbox.spy(cmdexec.sdk, "removeDeployment"); + cmdexec.execute(command).done(() => { + sinon.assert.calledOnce(removeDeployment); + sinon.assert.calledWithExactly(removeDeployment, "a", "Staging"); + sinon.assert.calledOnce(log); + sinon.assert.calledWithExactly(log, 'Successfully removed the "Staging" deployment from the "a" app.'); + done(); + }); + }); + it("deploymentRemove does not remove deployment if cancelled", (done) => { + var command = { + type: cli.CommandType.deploymentRemove, + appName: "a", + deploymentName: "Staging", + }; + var removeDeployment = sandbox.spy(cmdexec.sdk, "removeDeployment"); + wasConfirmed = false; + cmdexec.execute(command).done(() => { + sinon.assert.notCalled(removeDeployment); + sinon.assert.calledOnce(log); + sinon.assert.calledWithExactly(log, "Deployment removal cancelled."); + done(); + }); + }); + it("deploymentRename renames deployment", (done) => { + var command = { + type: cli.CommandType.deploymentRename, + appName: "a", + currentDeploymentName: "Staging", + newDeploymentName: "c", + }; + var renameDeployment = sandbox.spy(cmdexec.sdk, "renameDeployment"); + cmdexec.execute(command).done(() => { + sinon.assert.calledOnce(renameDeployment); + sinon.assert.calledOnce(log); + sinon.assert.calledWithExactly(log, 'Successfully renamed the "Staging" deployment to "c" for the "a" app.'); + done(); + }); + }); + it("deploymentHistory lists package history information", (done) => { + var command = { + type: cli.CommandType.deploymentHistory, + appName: "a", + deploymentName: "Staging", + format: "json", + displayAuthor: false, + }; + var getDeploymentHistory = sandbox.spy(cmdexec.sdk, "getDeploymentHistory"); + cmdexec.execute(command).done(() => { + sinon.assert.calledOnce(getDeploymentHistory); + sinon.assert.calledOnce(log); + assert.equal(log.args[0].length, 1); + var actual = log.args[0][0]; + var expected = [ + { + description: null, + appVersion: "1.0.0", + isMandatory: false, + packageHash: "463acc7d06adc9c46233481d87d9e8264b3e9ffe60fe98d721e6974209dc71a0", + blobUrl: "https://fakeblobstorage.net/storagev2/blobid1", + uploadTime: 1447113596270, + size: 1, + label: "v1", + }, + { + description: "New update - this update does a whole bunch of things, including testing linewrapping", + appVersion: "1.0.1", + isMandatory: false, + packageHash: "463acc7d06adc9c46233481d87d9e8264b3e9ffe60fe98d721e6974209dc71a0", + blobUrl: "https://fakeblobstorage.net/storagev2/blobid2", + uploadTime: 1447118476669, + size: 2, + label: "v2", + }, + ]; + assertJsonDescribesObject(actual, expected); + done(); + }); + }); + it("patch command successfully updates specific label", (done) => { + var command = { + type: cli.CommandType.patch, + appName: "a", + deploymentName: "Staging", + label: "v1", + disabled: false, + description: "Patched", + mandatory: true, + rollout: 25, + appStoreVersion: "1.0.1", + }; + var patch = sandbox.spy(cmdexec.sdk, "patchRelease"); + cmdexec.execute(command).done(() => { + sinon.assert.calledOnce(patch); + sinon.assert.calledOnce(log); + sinon.assert.calledWithExactly(log, `Successfully updated the "v1" release of "a" app's "Staging" deployment.`); + done(); + }); + }); + it("patch command successfully updates latest release", (done) => { + var command = { + type: cli.CommandType.patch, + appName: "a", + deploymentName: "Staging", + label: null, + disabled: false, + description: "Patched", + mandatory: true, + rollout: 25, + appStoreVersion: "1.0.1", + }; + var patch = sandbox.spy(cmdexec.sdk, "patchRelease"); + cmdexec.execute(command).done(() => { + sinon.assert.calledOnce(patch); + sinon.assert.calledOnce(log); + sinon.assert.calledWithExactly(log, `Successfully updated the "latest" release of "a" app's "Staging" deployment.`); + done(); + }); + }); + it("patch command successfully updates without appStoreVersion", (done) => { + var command = { + type: cli.CommandType.patch, + appName: "a", + deploymentName: "Staging", + label: null, + disabled: false, + description: "Patched", + mandatory: true, + rollout: 25, + appStoreVersion: null, + }; + var patch = sandbox.spy(cmdexec.sdk, "patchRelease"); + cmdexec.execute(command).done(() => { + sinon.assert.calledOnce(patch); + sinon.assert.calledOnce(log); + sinon.assert.calledWithExactly(log, `Successfully updated the "latest" release of "a" app's "Staging" deployment.`); + done(); + }); + }); + it("patch command fails if no properties were specified for update", (done) => { + var command = { + type: cli.CommandType.patch, + appName: "a", + deploymentName: "Staging", + label: null, + disabled: null, + description: null, + mandatory: null, + rollout: null, + appStoreVersion: null, + }; + var patch = sandbox.spy(cmdexec.sdk, "patchRelease"); + cmdexec + .execute(command) + .then(() => { + done(new Error("Did not throw error.")); + }) + .catch((err) => { + assert.equal(err.message, "At least one property must be specified to patch a release."); + sinon.assert.notCalled(patch); + done(); + }) + .done(); + }); + it("promote works successfully", (done) => { + var command = { + type: cli.CommandType.promote, + appName: "a", + sourceDeploymentName: "Staging", + destDeploymentName: "Production", + description: "Promoted", + mandatory: true, + rollout: 25, + appStoreVersion: "1.0.1", + }; + var promote = sandbox.spy(cmdexec.sdk, "promote"); + cmdexec.execute(command).done(() => { + sinon.assert.calledOnce(promote); + sinon.assert.calledOnce(log); + sinon.assert.calledWithExactly(log, `Successfully promoted the "Staging" deployment of the "a" app to the "Production" deployment.`); + done(); + }); + }); + it("promote works successfully without appStoreVersion", (done) => { + var command = { + type: cli.CommandType.promote, + appName: "a", + sourceDeploymentName: "Staging", + destDeploymentName: "Production", + description: "Promoted", + mandatory: true, + rollout: 25, + appStoreVersion: null, + }; + var promote = sandbox.spy(cmdexec.sdk, "promote"); + cmdexec.execute(command).done(() => { + sinon.assert.calledOnce(promote); + sinon.assert.calledOnce(log); + sinon.assert.calledWithExactly(log, `Successfully promoted the "Staging" deployment of the "a" app to the "Production" deployment.`); + done(); + }); + }); + it("rollback works successfully", (done) => { + var command = { + type: cli.CommandType.rollback, + appName: "a", + deploymentName: "Staging", + targetRelease: "v2", + }; + var rollback = sandbox.spy(cmdexec.sdk, "rollback"); + cmdexec.execute(command).done(() => { + sinon.assert.calledOnce(rollback); + sinon.assert.calledOnce(log); + sinon.assert.calledWithExactly(log, `Successfully performed a rollback on the "Staging" deployment of the "a" app.`); + done(); + }); + }); + it("release doesn't allow non valid semver ranges", (done) => { + var command = { + type: cli.CommandType.release, + appName: "a", + deploymentName: "Staging", + description: "test releasing zip file", + mandatory: false, + rollout: null, + appStoreVersion: "not semver", + package: "./resources", + }; + releaseHelperFunction(command, done, 'Please use a semver-compliant target binary version range, for example "1.0.0", "*" or "^1.2.3".'); + }); + it("release doesn't allow releasing .zip file", (done) => { + var command = { + type: cli.CommandType.release, + appName: "a", + deploymentName: "Staging", + description: "test releasing zip file", + mandatory: false, + rollout: null, + appStoreVersion: "1.0.0", + package: "/fake/path/test/file.zip", + }; + releaseHelperFunction(command, done, INVALID_RELEASE_FILE_ERROR_MESSAGE); + }); + it("release doesn't allow releasing .ipa file", (done) => { + var command = { + type: cli.CommandType.release, + appName: "a", + deploymentName: "Staging", + description: "test releasing ipa file", + mandatory: false, + rollout: null, + appStoreVersion: "1.0.0", + package: "/fake/path/test/file.ipa", + }; + releaseHelperFunction(command, done, INVALID_RELEASE_FILE_ERROR_MESSAGE); + }); + it("release doesn't allow releasing .apk file", (done) => { + var command = { + type: cli.CommandType.release, + appName: "a", + deploymentName: "Staging", + description: "test releasing apk file", + mandatory: false, + rollout: null, + appStoreVersion: "1.0.0", + package: "/fake/path/test/file.apk", + }; + releaseHelperFunction(command, done, INVALID_RELEASE_FILE_ERROR_MESSAGE); + }); + it("release-react fails if CWD does not contain package.json", (done) => { + var command = { + type: cli.CommandType.releaseReact, + appName: "a", + appStoreVersion: null, + deploymentName: "Staging", + description: "Test invalid folder", + mandatory: false, + rollout: null, + platform: "ios", + }; + var release = sandbox.spy(cmdexec, "release"); + cmdexec + .execute(command) + .then(() => { + done(new Error("Did not throw error.")); + }) + .catch((err) => { + assert.equal(err.message, 'Unable to find or read "package.json" in the CWD. The "release-react" command must be executed in a React Native project folder.'); + sinon.assert.notCalled(release); + sinon.assert.notCalled(spawn); + done(); + }) + .done(); + }); + it("release-react fails if entryFile does not exist", (done) => { + var command = { + type: cli.CommandType.releaseReact, + appName: "a", + appStoreVersion: null, + deploymentName: "Staging", + description: "Test invalid entryFile", + entryFile: "doesntexist.js", + mandatory: false, + rollout: null, + platform: "ios", + }; + ensureInTestAppDirectory(); + var release = sandbox.spy(cmdexec, "release"); + cmdexec + .execute(command) + .then(() => { + done(new Error("Did not throw error.")); + }) + .catch((err) => { + assert.equal(err.message, 'Entry file "doesntexist.js" does not exist.'); + sinon.assert.notCalled(release); + sinon.assert.notCalled(spawn); + done(); + }) + .done(); + }); + it("release-react fails if platform is invalid", (done) => { + var command = { + type: cli.CommandType.releaseReact, + appName: "a", + appStoreVersion: null, + deploymentName: "Staging", + description: "Test invalid platform", + mandatory: false, + rollout: null, + platform: "blackberry", + }; + ensureInTestAppDirectory(); + var release = sandbox.spy(cmdexec, "release"); + cmdexec + .execute(command) + .then(() => { + done(new Error("Did not throw error.")); + }) + .catch((err) => { + assert.equal(err.message, 'Platform must be either "android", "ios" or "windows".'); + sinon.assert.notCalled(release); + sinon.assert.notCalled(spawn); + done(); + }) + .done(); + }); + it("release-react fails if targetBinaryRange is not a valid semver range expression", (done) => { + var bundleName = "bundle.js"; + var command = { + type: cli.CommandType.releaseReact, + appName: "a", + appStoreVersion: "notsemver", + bundleName: bundleName, + deploymentName: "Staging", + description: "Test uses targetBinaryRange", + mandatory: false, + rollout: null, + platform: "android", + sourcemapOutput: "index.android.js.map", + }; + ensureInTestAppDirectory(); + var release = sandbox.stub(cmdexec, "release"); + cmdexec + .execute(command) + .then(() => { + done(new Error("Did not throw error.")); + }) + .catch((err) => { + assert.equal(err.message, 'Please use a semver-compliant target binary version range, for example "1.0.0", "*" or "^1.2.3".'); + sinon.assert.notCalled(release); + sinon.assert.notCalled(spawn); + done(); + }) + .done(); + }); + it("release-react defaults entry file to index.{platform}.js if not provided", (done) => { + var bundleName = "bundle.js"; + var command = { + type: cli.CommandType.releaseReact, + appName: "a", + appStoreVersion: null, + bundleName: bundleName, + deploymentName: "Staging", + description: "Test default entry file", + mandatory: false, + rollout: null, + platform: "ios", + }; + ensureInTestAppDirectory(); + var release = sandbox.stub(cmdexec, "release"); + cmdexec + .execute(command) + .then(() => { + var releaseCommand = command; + releaseCommand.package = path.join(os.tmpdir(), "CodePush"); + releaseCommand.appStoreVersion = "1.2.3"; + sinon.assert.calledOnce(spawn); + var spawnCommand = spawn.args[0][0]; + var spawnCommandArgs = spawn.args[0][1].join(" "); + assert.equal(spawnCommand, "node"); + assert.equal(spawnCommandArgs, `${path.join("node_modules", "react-native", "local-cli", "cli.js")} bundle --assets-dest ${path.join(os.tmpdir(), "CodePush")} --bundle-output ${path.join(os.tmpdir(), "CodePush", bundleName)} --dev false --entry-file index.ios.js --platform ios`); + assertJsonDescribesObject(JSON.stringify(release.args[0][0], /*replacer=*/ null, /*spacing=*/ 2), releaseCommand); + done(); + }) + .done(); + }); + it('release-react defaults bundle name to "main.jsbundle" if not provided and platform is "ios"', (done) => { + var command = { + type: cli.CommandType.releaseReact, + appName: "a", + appStoreVersion: null, + deploymentName: "Staging", + description: "Test default entry file", + mandatory: false, + rollout: null, + platform: "ios", + }; + ensureInTestAppDirectory(); + var release = sandbox.stub(cmdexec, "release"); + cmdexec + .execute(command) + .then(() => { + var releaseCommand = clone(command); + var packagePath = path.join(os.tmpdir(), "CodePush"); + releaseCommand.package = packagePath; + releaseCommand.appStoreVersion = "1.2.3"; + sinon.assert.calledOnce(spawn); + var spawnCommand = spawn.args[0][0]; + var spawnCommandArgs = spawn.args[0][1].join(" "); + assert.equal(spawnCommand, "node"); + assert.equal(spawnCommandArgs, `${path.join("node_modules", "react-native", "local-cli", "cli.js")} bundle --assets-dest ${packagePath} --bundle-output ${path.join(packagePath, "main.jsbundle")} --dev false --entry-file index.ios.js --platform ios`); + assertJsonDescribesObject(JSON.stringify(release.args[0][0], /*replacer=*/ null, /*spacing=*/ 2), releaseCommand); + done(); + }) + .done(); + }); + it('release-react defaults bundle name to "index.android.bundle" if not provided and platform is "android"', (done) => { + var command = { + type: cli.CommandType.releaseReact, + appName: "a", + appStoreVersion: null, + deploymentName: "Staging", + description: "Test default entry file", + mandatory: false, + rollout: null, + platform: "android", + }; + ensureInTestAppDirectory(); + var release = sandbox.stub(cmdexec, "release"); + cmdexec + .execute(command) + .then(() => { + var releaseCommand = clone(command); + var packagePath = path.join(os.tmpdir(), "CodePush"); + releaseCommand.package = packagePath; + releaseCommand.appStoreVersion = "1.0.0"; + sinon.assert.calledOnce(spawn); + var spawnCommand = spawn.args[0][0]; + var spawnCommandArgs = spawn.args[0][1].join(" "); + assert.equal(spawnCommand, "node"); + assert.equal(spawnCommandArgs, `${path.join("node_modules", "react-native", "local-cli", "cli.js")} bundle --assets-dest ${packagePath} --bundle-output ${path.join(packagePath, "index.android.bundle")} --dev false --entry-file index.android.js --platform android`); + assertJsonDescribesObject(JSON.stringify(release.args[0][0], /*replacer=*/ null, /*spacing=*/ 2), releaseCommand); + done(); + }) + .done(); + }); + it('release-react defaults bundle name to "index.windows.bundle" if not provided and platform is "windows"', (done) => { + var command = { + type: cli.CommandType.releaseReact, + appName: "a", + appStoreVersion: null, + deploymentName: "Staging", + description: "Test default entry file", + mandatory: false, + rollout: null, + platform: "windows", + }; + ensureInTestAppDirectory(); + var release = sandbox.stub(cmdexec, "release"); + cmdexec + .execute(command) + .then(() => { + var releaseCommand = clone(command); + var packagePath = path.join(os.tmpdir(), "CodePush"); + releaseCommand.package = packagePath; + releaseCommand.appStoreVersion = "1.0.0"; + sinon.assert.calledOnce(spawn); + var spawnCommand = spawn.args[0][0]; + var spawnCommandArgs = spawn.args[0][1].join(" "); + assert.equal(spawnCommand, "node"); + assert.equal(spawnCommandArgs, `${path.join("node_modules", "react-native", "local-cli", "cli.js")} bundle --assets-dest ${packagePath} --bundle-output ${path.join(packagePath, "index.windows.bundle")} --dev false --entry-file index.windows.js --platform windows`); + assertJsonDescribesObject(JSON.stringify(release.args[0][0], /*replacer=*/ null, /*spacing=*/ 2), releaseCommand); + done(); + }) + .done(); + }); + it("release-react generates dev bundle", (done) => { + var bundleName = "bundle.js"; + var command = { + type: cli.CommandType.releaseReact, + appName: "a", + appStoreVersion: null, + bundleName: bundleName, + deploymentName: "Staging", + development: true, + description: "Test generates dev bundle", + mandatory: false, + rollout: null, + platform: "android", + sourcemapOutput: "index.android.js.map", + }; + ensureInTestAppDirectory(); + var release = sandbox.stub(cmdexec, "release"); + cmdexec + .execute(command) + .then(() => { + var releaseCommand = command; + releaseCommand.package = path.join(os.tmpdir(), "CodePush"); + releaseCommand.appStoreVersion = "1.2.3"; + sinon.assert.calledOnce(spawn); + var spawnCommand = spawn.args[0][0]; + var spawnCommandArgs = spawn.args[0][1].join(" "); + assert.equal(spawnCommand, "node"); + assert.equal(spawnCommandArgs, `${path.join("node_modules", "react-native", "local-cli", "cli.js")} bundle --assets-dest ${path.join(os.tmpdir(), "CodePush")} --bundle-output ${path.join(os.tmpdir(), "CodePush", bundleName)} --dev true --entry-file index.android.js --platform android --sourcemap-output index.android.js.map`); + assertJsonDescribesObject(JSON.stringify(release.args[0][0], /*replacer=*/ null, /*spacing=*/ 2), releaseCommand); + done(); + }) + .done(); + }); + it("release-react generates sourcemaps", (done) => { + var bundleName = "bundle.js"; + var command = { + type: cli.CommandType.releaseReact, + appName: "a", + appStoreVersion: null, + bundleName: bundleName, + deploymentName: "Staging", + description: "Test generates sourcemaps", + mandatory: false, + rollout: null, + platform: "android", + sourcemapOutput: "index.android.js.map", + }; + ensureInTestAppDirectory(); + var release = sandbox.stub(cmdexec, "release"); + cmdexec + .execute(command) + .then(() => { + var releaseCommand = command; + releaseCommand.package = path.join(os.tmpdir(), "CodePush"); + releaseCommand.appStoreVersion = "1.2.3"; + sinon.assert.calledOnce(spawn); + var spawnCommand = spawn.args[0][0]; + var spawnCommandArgs = spawn.args[0][1].join(" "); + assert.equal(spawnCommand, "node"); + assert.equal(spawnCommandArgs, `${path.join("node_modules", "react-native", "local-cli", "cli.js")} bundle --assets-dest ${path.join(os.tmpdir(), "CodePush")} --bundle-output ${path.join(os.tmpdir(), "CodePush", bundleName)} --dev false --entry-file index.android.js --platform android --sourcemap-output index.android.js.map`); + assertJsonDescribesObject(JSON.stringify(release.args[0][0], /*replacer=*/ null, /*spacing=*/ 2), releaseCommand); + done(); + }) + .done(); + }); + it("release-react uses specified targetBinaryRange option", (done) => { + var bundleName = "bundle.js"; + var command = { + type: cli.CommandType.releaseReact, + appName: "a", + appStoreVersion: ">=1.0.0 <1.0.5", + bundleName: bundleName, + deploymentName: "Staging", + description: "Test uses targetBinaryRange", + mandatory: false, + rollout: null, + platform: "android", + sourcemapOutput: "index.android.js.map", + }; + ensureInTestAppDirectory(); + var release = sandbox.stub(cmdexec, "release"); + cmdexec + .execute(command) + .then(() => { + var releaseCommand = command; + releaseCommand.package = path.join(os.tmpdir(), "CodePush"); + sinon.assert.calledOnce(spawn); + var spawnCommand = spawn.args[0][0]; + var spawnCommandArgs = spawn.args[0][1].join(" "); + assert.equal(spawnCommand, "node"); + assert.equal(spawnCommandArgs, `${path.join("node_modules", "react-native", "local-cli", "cli.js")} bundle --assets-dest ${path.join(os.tmpdir(), "CodePush")} --bundle-output ${path.join(os.tmpdir(), "CodePush", bundleName)} --dev false --entry-file index.android.js --platform android --sourcemap-output index.android.js.map`); + assertJsonDescribesObject(JSON.stringify(release.args[0][0], /*replacer=*/ null, /*spacing=*/ 2), releaseCommand); + done(); + }) + .done(); + }); + it("release-react applies arguments to node binary provided via the CODE_PUSH_NODE_ARGS env var", (done) => { + var bundleName = "bundle.js"; + var command = { + type: cli.CommandType.releaseReact, + appName: "a", + appStoreVersion: null, + bundleName: bundleName, + deploymentName: "Staging", + description: "Test default entry file", + mandatory: false, + rollout: null, + platform: "ios", + }; + ensureInTestAppDirectory(); + var release = sandbox.stub(cmdexec, "release"); + var _CODE_PUSH_NODE_ARGS = process.env.CODE_PUSH_NODE_ARGS; + process.env.CODE_PUSH_NODE_ARGS = " --foo=bar --baz "; + cmdexec + .execute(command) + .then(() => { + var releaseCommand = command; + releaseCommand.package = path.join(os.tmpdir(), "CodePush"); + releaseCommand.appStoreVersion = "1.2.3"; + sinon.assert.calledOnce(spawn); + var spawnCommand = spawn.args[0][0]; + var spawnCommandArgs = spawn.args[0][1].join(" "); + assert.equal(spawnCommand, "node"); + assert.equal(spawnCommandArgs, `--foo=bar --baz ${path.join("node_modules", "react-native", "local-cli", "cli.js")} bundle --assets-dest ${path.join(os.tmpdir(), "CodePush")} --bundle-output ${path.join(os.tmpdir(), "CodePush", bundleName)} --dev false --entry-file index.ios.js --platform ios`); + assertJsonDescribesObject(JSON.stringify(release.args[0][0], /*replacer=*/ null, /*spacing=*/ 2), releaseCommand); + process.env.CODE_PUSH_NODE_ARGS = _CODE_PUSH_NODE_ARGS; + done(); + }) + .done(); + }); + it("sessionList lists session name and expires fields", (done) => { + var command = { + type: cli.CommandType.sessionList, + format: "json", + }; + cmdexec.execute(command).done(() => { + sinon.assert.calledOnce(log); + assert.equal(log.args[0].length, 1); + var actual = log.args[0][0]; + var expected = [ + { + loggedInTime: 0, + machineName: TEST_MACHINE_NAME, + }, + ]; + assertJsonDescribesObject(actual, expected); + done(); + }); + }); + it("sessionRemove removes session", (done) => { + var machineName = TEST_MACHINE_NAME; + var command = { + type: cli.CommandType.sessionRemove, + machineName: machineName, + }; + var removeSession = sandbox.spy(cmdexec.sdk, "removeSession"); + cmdexec.execute(command).done(() => { + sinon.assert.calledOnce(removeSession); + sinon.assert.calledWithExactly(removeSession, machineName); + sinon.assert.calledOnce(log); + sinon.assert.calledWithExactly(log, `Successfully removed the login session for "${machineName}".`); + done(); + }); + }); + it("sessionRemove does not remove session if cancelled", (done) => { + var machineName = TEST_MACHINE_NAME; + var command = { + type: cli.CommandType.sessionRemove, + machineName: machineName, + }; + var removeSession = sandbox.spy(cmdexec.sdk, "removeSession"); + wasConfirmed = false; + cmdexec.execute(command).done(() => { + sinon.assert.notCalled(removeSession); + sinon.assert.calledOnce(log); + sinon.assert.calledWithExactly(log, "Session removal cancelled."); + done(); + }); + }); + it("sessionRemove does not remove current session", (done) => { + var machineName = os.hostname(); + var command = { + type: cli.CommandType.sessionRemove, + machineName: machineName, + }; + wasConfirmed = false; + cmdexec + .execute(command) + .then(() => { + done(new Error("Did not throw error.")); + }) + .catch(() => { + done(); + }) + .done(); + }); + function releaseHelperFunction(command, done, expectedError) { + cmdexec.execute(command).done(() => { + throw "Error Expected"; + }, (error) => { + assert(!!error); + assert.equal(error.message, expectedError); + done(); + }); + } +}); diff --git a/cli/bin/test/hash-utils.js b/cli/bin/test/hash-utils.js new file mode 100644 index 0000000..656f50c --- /dev/null +++ b/cli/bin/test/hash-utils.js @@ -0,0 +1,149 @@ +"use strict"; +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +Object.defineProperty(exports, "__esModule", { value: true }); +const assert = require("assert"); +const crypto = require("crypto"); +const fs = require("fs"); +const hashUtils = require("../script/hash-utils"); +var mkdirp = require("mkdirp"); +const os = require("os"); +const path = require("path"); +const q = require("q"); +var yauzl = require("yauzl"); +function randomString() { + var stringLength = 10; + return crypto + .randomBytes(Math.ceil(stringLength / 2)) + .toString("hex") // convert to hexadecimal format + .slice(0, stringLength); // return required number of characters +} +function unzipToDirectory(zipPath, directoryPath) { + var deferred = q.defer(); + var originalCwd = process.cwd(); + mkdirp(directoryPath, (err) => { + if (err) + throw err; + process.chdir(directoryPath); + yauzl.open(zipPath, { lazyEntries: true }, function (err, zipfile) { + if (err) + throw err; + zipfile.readEntry(); + zipfile.on("entry", function (entry) { + if (/\/$/.test(entry.fileName)) { + // directory file names end with '/' + mkdirp(entry.fileName, function (err) { + if (err) + throw err; + zipfile.readEntry(); + }); + } + else { + // file entry + zipfile.openReadStream(entry, function (err, readStream) { + if (err) + throw err; + // ensure parent directory exists + mkdirp(path.dirname(entry.fileName), function (err) { + if (err) + throw err; + readStream.pipe(fs.createWriteStream(entry.fileName)); + readStream.on("end", function () { + zipfile.readEntry(); + }); + }); + }); + } + }); + zipfile.on("end", function (err) { + if (err) + deferred.reject(err); + else + deferred.resolve(null); + }); + }); + }); + return deferred.promise.finally(() => { + process.chdir(originalCwd); + }); +} +describe("Hashing utility", () => { + const TEST_DIRECTORY = path.join(os.tmpdir(), "codepushtests", randomString()); + const TEST_ARCHIVE_FILE_PATH = path.join(__dirname, "resources", "test.zip"); + const TEST_ZIP_HASH = "540fed8df3553079e81d1353c5cc4e3cac7db9aea647a85d550f646e8620c317"; + const TEST_ZIP_MANIFEST_HASH = "9e0499ce7df5c04cb304c9deed684dc137fc603cb484a5b027478143c595d80b"; + const HASH_B = "3e23e8160039594a33894f6564e1b1348bbd7a0088d42c4acb73eeaed59c009d"; + const HASH_C = "2e7d2c03a9507ae265ecf5b5356885a53393a2029d241394997265a1a25aefc6"; + const HASH_D = "18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4"; + const IGNORED_METADATA_ARCHIVE_FILE_PATH = path.join(__dirname, "resources", "ignoredMetadata.zip"); + const INDEX_HASH = "b0693dc92f76e08bf1485b3dd9b514a2e31dfd6f39422a6b60edb722671dc98f"; + it("generates a package hash from file", (done) => { + hashUtils.hashFile(TEST_ARCHIVE_FILE_PATH).done((packageHash) => { + assert.equal(packageHash, TEST_ZIP_HASH); + done(); + }); + }); + it("generates a package manifest for an archive", (done) => { + hashUtils.generatePackageManifestFromZip(TEST_ARCHIVE_FILE_PATH).done((manifest) => { + var fileHashesMap = manifest.toMap(); + assert.equal(fileHashesMap.size, 3); + var hash = fileHashesMap.get("b.txt"); + assert.equal(hash, HASH_B); + hash = fileHashesMap.get("c.txt"); + assert.equal(hash, HASH_C); + hash = fileHashesMap.get("d.txt"); + assert.equal(hash, HASH_D); + done(); + }); + }); + it("generates a package manifest for a directory", (done) => { + var directory = path.join(TEST_DIRECTORY, "testZip"); + unzipToDirectory(TEST_ARCHIVE_FILE_PATH, directory) + .then(() => { + return hashUtils.generatePackageManifestFromDirectory(/*directoryPath*/ directory, /*basePath*/ directory); + }) + .done((manifest) => { + var fileHashesMap = manifest.toMap(); + assert.equal(fileHashesMap.size, 3); + var hash = fileHashesMap.get("b.txt"); + assert.equal(hash, HASH_B); + hash = fileHashesMap.get("c.txt"); + assert.equal(hash, HASH_C); + hash = fileHashesMap.get("d.txt"); + assert.equal(hash, HASH_D); + done(); + }); + }); + it("generates a package hash from manifest", (done) => { + hashUtils + .generatePackageManifestFromZip(TEST_ARCHIVE_FILE_PATH) + .then((manifest) => { + return manifest.computePackageHash(); + }) + .done((packageHash) => { + assert.equal(packageHash, TEST_ZIP_MANIFEST_HASH); + done(); + }); + }); + it("generates a package manifest for an archive with ignorable metadata", (done) => { + hashUtils.generatePackageManifestFromZip(IGNORED_METADATA_ARCHIVE_FILE_PATH).done((manifest) => { + assert.equal(manifest.toMap().size, 1); + var hash = manifest.toMap().get("www/index.html"); + assert.equal(hash, INDEX_HASH); + done(); + }); + }); + it("generates a package manifest for a directory with ignorable metadata", (done) => { + var directory = path.join(TEST_DIRECTORY, "ignorableMetadata"); + unzipToDirectory(IGNORED_METADATA_ARCHIVE_FILE_PATH, directory) + .then(() => { + return hashUtils.generatePackageManifestFromDirectory(/*directoryPath*/ directory, /*basePath*/ directory); + }) + .done((manifest) => { + assert.equal(manifest.toMap().size, 1); + var hash = manifest.toMap().get("www/index.html"); + assert.equal(hash, INDEX_HASH); + done(); + }); + }); +}); diff --git a/cli/bin/test/management-sdk.js b/cli/bin/test/management-sdk.js new file mode 100644 index 0000000..3ba4184 --- /dev/null +++ b/cli/bin/test/management-sdk.js @@ -0,0 +1,338 @@ +"use strict"; +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +Object.defineProperty(exports, "__esModule", { value: true }); +const assert = require("assert"); +const Q = require("q"); +const AccountManager = require("../script/management-sdk"); +var request = require("superagent"); +var manager; +describe("Management SDK", () => { + beforeEach(() => { + manager = new AccountManager(/*accessKey=*/ "dummyAccessKey", /*customHeaders=*/ null, /*serverUrl=*/ "http://localhost"); + }); + after(() => { + // Prevent an exception that occurs due to how superagent-mock overwrites methods + request.Request.prototype._callback = function () { }; + }); + it("methods reject the promise with status code info when an error occurs", (done) => { + mockReturn("Text", 404); + var methodsWithErrorHandling = [ + manager.addApp.bind(manager, "appName"), + manager.getApp.bind(manager, "appName"), + manager.renameApp.bind(manager, "appName", {}), + manager.removeApp.bind(manager, "appName"), + manager.transferApp.bind(manager, "appName", "email1"), + manager.addDeployment.bind(manager, "appName", "deploymentName"), + manager.getDeployment.bind(manager, "appName", "deploymentName"), + manager.getDeployments.bind(manager, "appName"), + manager.renameDeployment.bind(manager, "appName", "deploymentName", { + name: "newDeploymentName", + }), + manager.removeDeployment.bind(manager, "appName", "deploymentName"), + manager.addCollaborator.bind(manager, "appName", "email1"), + manager.getCollaborators.bind(manager, "appName"), + manager.removeCollaborator.bind(manager, "appName", "email1"), + manager.patchRelease.bind(manager, "appName", "deploymentName", "label", { + description: "newDescription", + }), + manager.promote.bind(manager, "appName", "deploymentName", "newDeploymentName", { description: "newDescription" }), + manager.rollback.bind(manager, "appName", "deploymentName", "targetReleaseLabel"), + ]; + var result = Q(null); + methodsWithErrorHandling.forEach(function (f) { + result = result.then(() => { + return testErrors(f); + }); + }); + result.done(() => { + done(); + }); + // Test that the proper error code and text is passed through on a server error + function testErrors(method) { + return Q.Promise((resolve, reject, notify) => { + method().done(() => { + assert.fail("Should have thrown an error"); + reject(); + }, (error) => { + assert.equal(error.message, "Text"); + assert(error.statusCode); + resolve(); + }); + }); + } + }); + it("isAuthenticated handles successful auth", (done) => { + mockReturn(JSON.stringify({ authenticated: true }), 200, {}); + manager.isAuthenticated().done((authenticated) => { + assert(authenticated, "Should be authenticated"); + done(); + }); + }); + it("isAuthenticated handles unsuccessful auth", (done) => { + mockReturn("Unauthorized", 401, {}); + manager.isAuthenticated().done((authenticated) => { + assert(!authenticated, "Should not be authenticated"); + done(); + }); + }); + it("isAuthenticated handles unsuccessful auth with promise rejection", (done) => { + mockReturn("Unauthorized", 401, {}); + // use optional parameter to ask for rejection of the promise if not authenticated + manager.isAuthenticated(true).done((authenticated) => { + assert.fail("isAuthenticated should have rejected the promise"); + done(); + }, (err) => { + assert.equal(err.message, "Unauthorized", "Error message should be 'Unauthorized'"); + done(); + }); + }); + it("isAuthenticated handles unexpected status codes", (done) => { + mockReturn("Not Found", 404, {}); + manager.isAuthenticated().done((authenticated) => { + assert.fail("isAuthenticated should have rejected the promise"); + done(); + }, (err) => { + assert.equal(err.message, "Not Found", "Error message should be 'Not Found'"); + done(); + }); + }); + it("addApp handles successful response", (done) => { + mockReturn(JSON.stringify({ success: true }), 201, { + location: "/appName", + }); + manager.addApp("appName").done((obj) => { + assert.ok(obj); + done(); + }, rejectHandler); + }); + it("addApp handles error response", (done) => { + mockReturn(JSON.stringify({ success: false }), 404, {}); + manager.addApp("appName").done((obj) => { + throw new Error("Call should not complete successfully"); + }, (error) => done()); + }); + it("getApp handles JSON response", (done) => { + mockReturn(JSON.stringify({ app: {} }), 200, {}); + manager.getApp("appName").done((obj) => { + assert.ok(obj); + done(); + }, rejectHandler); + }); + it("updateApp handles success response", (done) => { + mockReturn(JSON.stringify({ apps: [] }), 200, {}); + manager.renameApp("appName", "newAppName").done((obj) => { + assert.ok(!obj); + done(); + }, rejectHandler); + }); + it("removeApp handles success response", (done) => { + mockReturn("", 200, {}); + manager.removeApp("appName").done((obj) => { + assert.ok(!obj); + done(); + }, rejectHandler); + }); + it("transferApp handles successful response", (done) => { + mockReturn("", 201); + manager.transferApp("appName", "email1").done((obj) => { + assert.ok(!obj); + done(); + }, rejectHandler); + }); + it("addDeployment handles success response", (done) => { + mockReturn(JSON.stringify({ deployment: { name: "name", key: "key" } }), 201, { location: "/deploymentName" }); + manager.addDeployment("appName", "deploymentName").done((obj) => { + assert.ok(obj); + done(); + }, rejectHandler); + }); + it("getDeployment handles JSON response", (done) => { + mockReturn(JSON.stringify({ deployment: {} }), 200, {}); + manager.getDeployment("appName", "deploymentName").done((obj) => { + assert.ok(obj); + done(); + }, rejectHandler); + }); + it("getDeployments handles JSON response", (done) => { + mockReturn(JSON.stringify({ deployments: [] }), 200, {}); + manager.getDeployments("appName").done((obj) => { + assert.ok(obj); + done(); + }, rejectHandler); + }); + it("renameDeployment handles success response", (done) => { + mockReturn(JSON.stringify({ apps: [] }), 200, {}); + manager.renameDeployment("appName", "deploymentName", "newDeploymentName").done((obj) => { + assert.ok(!obj); + done(); + }, rejectHandler); + }); + it("removeDeployment handles success response", (done) => { + mockReturn("", 200, {}); + manager.removeDeployment("appName", "deploymentName").done((obj) => { + assert.ok(!obj); + done(); + }, rejectHandler); + }); + it("getDeploymentHistory handles success response with no packages", (done) => { + mockReturn(JSON.stringify({ history: [] }), 200); + manager.getDeploymentHistory("appName", "deploymentName").done((obj) => { + assert.ok(obj); + assert.equal(obj.length, 0); + done(); + }, rejectHandler); + }); + it("getDeploymentHistory handles success response with two packages", (done) => { + mockReturn(JSON.stringify({ history: [{ label: "v1" }, { label: "v2" }] }), 200); + manager.getDeploymentHistory("appName", "deploymentName").done((obj) => { + assert.ok(obj); + assert.equal(obj.length, 2); + assert.equal(obj[0].label, "v1"); + assert.equal(obj[1].label, "v2"); + done(); + }, rejectHandler); + }); + it("getDeploymentHistory handles error response", (done) => { + mockReturn("", 404); + manager.getDeploymentHistory("appName", "deploymentName").done((obj) => { + throw new Error("Call should not complete successfully"); + }, (error) => done()); + }); + it("clearDeploymentHistory handles success response", (done) => { + mockReturn("", 204); + manager.clearDeploymentHistory("appName", "deploymentName").done((obj) => { + assert.ok(!obj); + done(); + }, rejectHandler); + }); + it("clearDeploymentHistory handles error response", (done) => { + mockReturn("", 404); + manager.clearDeploymentHistory("appName", "deploymentName").done((obj) => { + throw new Error("Call should not complete successfully"); + }, (error) => done()); + }); + it("addCollaborator handles successful response", (done) => { + mockReturn("", 201, { location: "/collaborators" }); + manager.addCollaborator("appName", "email1").done((obj) => { + assert.ok(!obj); + done(); + }, rejectHandler); + }); + it("addCollaborator handles error response", (done) => { + mockReturn("", 404, {}); + manager.addCollaborator("appName", "email1").done((obj) => { + throw new Error("Call should not complete successfully"); + }, (error) => done()); + }); + it("getCollaborators handles success response with no collaborators", (done) => { + mockReturn(JSON.stringify({ collaborators: {} }), 200); + manager.getCollaborators("appName").done((obj) => { + assert.ok(obj); + assert.equal(Object.keys(obj).length, 0); + done(); + }, rejectHandler); + }); + it("getCollaborators handles success response with multiple collaborators", (done) => { + mockReturn(JSON.stringify({ + collaborators: { + email1: { permission: "Owner", isCurrentAccount: true }, + email2: { permission: "Collaborator", isCurrentAccount: false }, + }, + }), 200); + manager.getCollaborators("appName").done((obj) => { + assert.ok(obj); + assert.equal(obj["email1"].permission, "Owner"); + assert.equal(obj["email2"].permission, "Collaborator"); + done(); + }, rejectHandler); + }); + it("removeCollaborator handles success response", (done) => { + mockReturn("", 200, {}); + manager.removeCollaborator("appName", "email1").done((obj) => { + assert.ok(!obj); + done(); + }, rejectHandler); + }); + it("patchRelease handles success response", (done) => { + mockReturn(JSON.stringify({ package: { description: "newDescription" } }), 200); + manager + .patchRelease("appName", "deploymentName", "label", { + description: "newDescription", + }) + .done((obj) => { + assert.ok(!obj); + done(); + }, rejectHandler); + }); + it("patchRelease handles error response", (done) => { + mockReturn("", 400); + manager.patchRelease("appName", "deploymentName", "label", {}).done((obj) => { + throw new Error("Call should not complete successfully"); + }, (error) => done()); + }); + it("promote handles success response", (done) => { + mockReturn(JSON.stringify({ package: { description: "newDescription" } }), 200); + manager + .promote("appName", "deploymentName", "newDeploymentName", { + description: "newDescription", + }) + .done((obj) => { + assert.ok(!obj); + done(); + }, rejectHandler); + }); + it("promote handles error response", (done) => { + mockReturn("", 400); + manager + .promote("appName", "deploymentName", "newDeploymentName", { + rollout: 123, + }) + .done((obj) => { + throw new Error("Call should not complete successfully"); + }, (error) => done()); + }); + it("rollback handles success response", (done) => { + mockReturn(JSON.stringify({ package: { label: "v1" } }), 200); + manager.rollback("appName", "deploymentName", "v1").done((obj) => { + assert.ok(!obj); + done(); + }, rejectHandler); + }); + it("rollback handles error response", (done) => { + mockReturn("", 400); + manager.rollback("appName", "deploymentName", "v1").done((obj) => { + throw new Error("Call should not complete successfully"); + }, (error) => done()); + }); +}); +// Helper method that is used everywhere that an assert.fail() is needed in a promise handler +function rejectHandler(val) { + assert.fail(); +} +// Wrapper for superagent-mock that abstracts away information not needed for SDK tests +function mockReturn(bodyText, statusCode, header = {}) { + require("superagent-mock")(request, [ + { + pattern: "http://localhost/(\\w+)/?", + fixtures: function (match, params) { + var isOk = statusCode >= 200 && statusCode < 300; + if (!isOk) { + var err = new Error(bodyText); + err.status = statusCode; + throw err; + } + return { + text: bodyText, + status: statusCode, + ok: isOk, + header: header, + headers: {}, + }; + }, + callback: function (match, data) { + return data; + }, + }, + ]); +}