See More

/// import AccountManager = require("code-push"); import * as base64 from "base-64"; import * as chalk from "chalk"; var childProcess = require("child_process"); import debugCommand from "./commands/debug"; import * as fs from "fs"; var g2js = require("gradle-to-js/lib/parser"); import * as moment from "moment"; var opener = require("opener"); import * as os from "os"; import * as path from "path"; var plist = require("plist"); var progress = require("progress"); var prompt = require("prompt"); import * as Q from "q"; import * as recursiveFs from "recursive-fs"; var rimraf = require("rimraf"); import * as semver from "semver"; var simctl = require("simctl"); import slash = require("slash"); var Table = require("cli-table"); import * as yazl from "yazl"; var which = require("which"); import wordwrap = require("wordwrap"); import * as cli from "../definitions/cli"; import { AccessKey, Account, App, CodePushError, CollaboratorMap, CollaboratorProperties, Deployment, DeploymentMetrics, Headers, Package, PackageInfo, Session, UpdateMetrics } from "code-push/script/types"; var configFilePath: string = path.join(process.env.LOCALAPPDATA || process.env.HOME, ".code-push.config"); var emailValidator = require("email-validator"); var packageJson = require("../package.json"); var parseXml = Q.denodeify(require("xml2js").parseString); var progress = require("progress"); import Promise = Q.Promise; var properties = require("properties"); const ACTIVE_METRICS_KEY: string = "Active"; const CLI_HEADERS: Headers = { "X-CodePush-CLI-Version": packageJson.version }; const DOWNLOADED_METRICS_KEY: string = "Downloaded"; interface NameToCountMap { [name: string]: number; } /** Deprecated */ interface ILegacyLoginConnectionInfo { accessKeyName: string; } interface ILoginConnectionInfo { accessKey: string; customServerUrl?: string; // A custom serverUrl for internal debugging purposes preserveAccessKeyOnLogout?: boolean; proxy?: string; // To specify the proxy url explicitly, other than the environment var (HTTP_PROXY) noProxy?: boolean; // To suppress the environment proxy setting, like HTTP_PROXY } interface IPackageFile { isTemporary: boolean; path: string; } export interface UpdateMetricsWithTotalActive extends UpdateMetrics { totalActive: number; } export interface PackageWithMetrics { metrics?: UpdateMetricsWithTotalActive; } export var log = (message: string | Chalk.ChalkChain): void => console.log(message); export var sdk: AccountManager; export var spawn = childProcess.spawn; export var execSync = childProcess.execSync; var connectionInfo: ILoginConnectionInfo; export var confirm = (): Promise => { return Promise((resolve, reject, notify): void => { prompt.message = ""; prompt.delimiter = ""; prompt.start(); prompt.get({ properties: { response: { description: chalk.cyan("Are you sure? (Y/n):") } } }, (err: any, result: any): void => { if (!result.response || result.response === "" || result.response === "Y") { resolve(true); } else { if (result.response !== "n") console.log("Invalid response: \"" + result.response + "\""); resolve(false); } }); }); } function accessKeyAdd(command: cli.IAccessKeyAddCommand): Promise { return sdk.addAccessKey(command.name, command.ttl) .then((accessKey: AccessKey) => { log(`Successfully created the "${command.name}" access key: ${accessKey.key}`); 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: cli.IAccessKeyPatchCommand): Promise { const willUpdateName: boolean = isCommandOptionSpecified(command.newName) && command.oldName !== command.newName; const willUpdateTtl: boolean = isCommandOptionSpecified(command.ttl); if (!willUpdateName && !willUpdateTtl) { throw new Error("A new name and/or TTL must be provided."); } return sdk.patchAccessKey(command.oldName, command.newName, command.ttl) .then((accessKey: AccessKey) => { let logMessage: string = "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}`; } } log(`${logMessage}.`); }); } function accessKeyList(command: cli.IAccessKeyListCommand): Promise { throwForInvalidOutputFormat(command.format); return sdk.getAccessKeys() .then((accessKeys: AccessKey[]): void => { printAccessKeys(command.format, accessKeys); }); } function accessKeyRemove(command: cli.IAccessKeyRemoveCommand): Promise { return confirm() .then((wasConfirmed: boolean): Promise => { if (wasConfirmed) { return sdk.removeAccessKey(command.accessKey) .then((): void => { log(`Successfully removed the "${command.accessKey}" access key.`); }); } log("Access key removal cancelled."); }); } function appAdd(command: cli.IAppAddCommand): Promise { return sdk.addApp(command.appName) .then((app: App): Promise => { log("Successfully added the \"" + command.appName + "\" app, along with the following default deployments:"); var deploymentListCommand: cli.IDeploymentListCommand = { type: cli.CommandType.deploymentList, appName: app.name, format: "table", displayKeys: true }; return deploymentList(deploymentListCommand, /*showPackage=*/ false); }); } function appList(command: cli.IAppListCommand): Promise { throwForInvalidOutputFormat(command.format); var apps: App[]; return sdk.getApps() .then((retrievedApps: App[]): void => { printAppList(command.format, retrievedApps); }); } function appRemove(command: cli.IAppRemoveCommand): Promise { return confirm() .then((wasConfirmed: boolean): Promise => { if (wasConfirmed) { return sdk.removeApp(command.appName) .then((): void => { log("Successfully removed the \"" + command.appName + "\" app."); }); } log("App removal cancelled."); }); } function appRename(command: cli.IAppRenameCommand): Promise { return sdk.renameApp(command.currentAppName, command.newAppName) .then((): void => { log("Successfully renamed the \"" + command.currentAppName + "\" app to \"" + command.newAppName + "\"."); }); } export var createEmptyTempReleaseFolder = (folderPath: string) => { return deleteFolder(folderPath) .then(() => { fs.mkdirSync(folderPath); }); }; function appTransfer(command: cli.IAppTransferCommand): Promise { throwForInvalidEmail(command.email); return confirm() .then((wasConfirmed: boolean): Promise => { if (wasConfirmed) { return sdk.transferApp(command.appName, command.email) .then((): void => { log("Successfully transferred the ownership of app \"" + command.appName + "\" to the account with email \"" + command.email + "\"."); }); } log("App transfer cancelled."); }); } function addCollaborator(command: cli.ICollaboratorAddCommand): Promise { throwForInvalidEmail(command.email); return sdk.addCollaborator(command.appName, command.email) .then((): void => { log("Successfully added \"" + command.email + "\" as a collaborator to the app \"" + command.appName + "\"."); }); } function listCollaborators(command: cli.ICollaboratorListCommand): Promise { throwForInvalidOutputFormat(command.format); return sdk.getCollaborators(command.appName) .then((retrievedCollaborators: CollaboratorMap): void => { printCollaboratorsList(command.format, retrievedCollaborators); }); } function removeCollaborator(command: cli.ICollaboratorRemoveCommand): Promise { throwForInvalidEmail(command.email); return confirm() .then((wasConfirmed: boolean): Promise => { if (wasConfirmed) { return sdk.removeCollaborator(command.appName, command.email) .then((): void => { log("Successfully removed \"" + command.email + "\" as a collaborator from the app \"" + command.appName + "\"."); }); } log("App collaborator removal cancelled."); }); } function deleteConnectionInfoCache(printMessage: boolean = true): void { try { fs.unlinkSync(configFilePath); if (printMessage) { log(`Successfully logged-out. The session file located at ${chalk.cyan(configFilePath)} has been deleted.\r\n`); } } catch (ex) { } } function deleteFolder(folderPath: string): Promise { return Promise((resolve, reject, notify) => { rimraf(folderPath, (err: any) => { if (err) { reject(err); } else { resolve(null); } }); }); } function deploymentAdd(command: cli.IDeploymentAddCommand): Promise { return sdk.addDeployment(command.appName, command.deploymentName) .then((deployment: Deployment): void => { log("Successfully added the \"" + command.deploymentName + "\" deployment with key \"" + deployment.key + "\" to the \"" + command.appName + "\" app."); }); } function deploymentHistoryClear(command: cli.IDeploymentHistoryClearCommand): Promise { return confirm() .then((wasConfirmed: boolean): Promise => { if (wasConfirmed) { return sdk.clearDeploymentHistory(command.appName, command.deploymentName) .then((): void => { log("Successfully cleared the release history associated with the \"" + command.deploymentName + "\" deployment from the \"" + command.appName + "\" app."); }) } log("Clear deployment cancelled."); }); } export var deploymentList = (command: cli.IDeploymentListCommand, showPackage: boolean = true): Promise => { throwForInvalidOutputFormat(command.format); var deployments: Deployment[]; return sdk.getDeployments(command.appName) .then((retrievedDeployments: Deployment[]) => { deployments = retrievedDeployments; if (showPackage) { var metricsPromises: Promise[] = deployments.map((deployment: Deployment) => { if (deployment.package) { return sdk.getDeploymentMetrics(command.appName, deployment.name) .then((metrics: DeploymentMetrics): void => { if (metrics[deployment.package.label]) { var totalActive: number = 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); }); } function deploymentRemove(command: cli.IDeploymentRemoveCommand): Promise { return confirm() .then((wasConfirmed: boolean): Promise => { if (wasConfirmed) { return sdk.removeDeployment(command.appName, command.deploymentName) .then((): void => { log("Successfully removed the \"" + command.deploymentName + "\" deployment from the \"" + command.appName + "\" app."); }) } log("Deployment removal cancelled."); }); } function deploymentRename(command: cli.IDeploymentRenameCommand): Promise { return sdk.renameDeployment(command.appName, command.currentDeploymentName, command.newDeploymentName) .then((): void => { log("Successfully renamed the \"" + command.currentDeploymentName + "\" deployment to \"" + command.newDeploymentName + "\" for the \"" + command.appName + "\" app."); }); } function deploymentHistory(command: cli.IDeploymentHistoryCommand): Promise { throwForInvalidOutputFormat(command.format); return Q.all([ sdk.getAccountInfo(), sdk.getDeploymentHistory(command.appName, command.deploymentName), sdk.getDeploymentMetrics(command.appName, command.deploymentName) ]) .spread((account: Account, deploymentHistory: Package[], metrics: DeploymentMetrics): void => { var totalActive: number = getTotalActiveFromDeploymentMetrics(metrics); deploymentHistory.forEach((packageObject: Package) => { 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(): ILoginConnectionInfo { try { var savedConnection: string = fs.readFileSync(configFilePath, { encoding: "utf8" }); var connectionInfo: ILegacyLoginConnectionInfo | ILoginConnectionInfo = 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 }; } var connInfo = connectionInfo; connInfo.proxy = getProxy(connInfo.proxy, connInfo.noProxy); return connInfo; } catch (ex) { return; } } export function execute(command: cli.ICommand): Promise { 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 (!!sdk) break; // Used by unit tests to skip authentication if (!connectionInfo) { throw new Error("You are not currently logged in. Run the 'code-push login' command to authenticate with the CodePush server."); } sdk = getSdk(connectionInfo.accessKey, CLI_HEADERS, connectionInfo.customServerUrl, connectionInfo.proxy); 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 debugCommand(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 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 release(command); case cli.CommandType.releaseCordova: return releaseCordova(command); case cli.CommandType.releaseReact: return 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)); } }); } function fileDoesNotExistOrIsDirectory(filePath: string): boolean { try { return fs.lstatSync(filePath).isDirectory(); } catch (error) { return true; } } function generateRandomFilename(length: number): string { var filename: string = ""; var validChar: string = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; for (var i = 0; i < length; i++) { filename += validChar.charAt(Math.floor(Math.random() * validChar.length)); } return filename; } function getTotalActiveFromDeploymentMetrics(metrics: DeploymentMetrics): number { var totalActive = 0; Object.keys(metrics).forEach((label: string) => { totalActive += metrics[label].active; }); return totalActive; } function initiateExternalAuthenticationAsync(action: string, serverUrl?: string): void { var message: string = `A browser is being launched to authenticate your account. Follow the instructions ` + `it displays to complete your ${action === "register" ? "registration" : action}.`; log(message); var hostname: string = os.hostname(); var url: string = `${serverUrl || AccountManager.SERVER_URL}/auth/${action}?hostname=${hostname}`; opener(url); } function link(command: cli.ILinkCommand): Promise { initiateExternalAuthenticationAsync("link", command.serverUrl); return Q(null); } function login(command: cli.ILoginCommand): Promise { // Check if one of the flags were provided. if (command.accessKey) { var proxy = getProxy(command.proxy, command.noProxy); sdk = getSdk(command.accessKey, CLI_HEADERS, command.serverUrl, proxy); return sdk.isAuthenticated() .then((isAuthenticated: boolean): void => { if (isAuthenticated) { serializeConnectionInfo(command.accessKey, /*preserveAccessKeyOnLogout*/ true, command.serverUrl, command.proxy, command.noProxy); } else { throw new Error("Invalid access key."); } }); } else { return loginWithExternalAuthentication("login", command.serverUrl, command.proxy, command.noProxy); } } function loginWithExternalAuthentication(action: string, serverUrl?: string, proxy?: string, noProxy?: boolean): Promise { initiateExternalAuthenticationAsync(action, serverUrl); log(""); // Insert newline return requestAccessKey() .then((accessKey: string): Promise => { if (accessKey === null) { // The user has aborted the synchronous prompt (e.g.: via [CTRL]+[C]). return; } sdk = getSdk(accessKey, CLI_HEADERS, serverUrl, getProxy(proxy, noProxy)); return sdk.isAuthenticated() .then((isAuthenticated: boolean): void => { if (isAuthenticated) { serializeConnectionInfo(accessKey, /*preserveAccessKeyOnLogout*/ false, serverUrl, proxy, noProxy); } else { throw new Error("Invalid access key."); } }); }); } function logout(command: cli.ICommand): Promise { return Q(null) .then((): Promise => { if (!connectionInfo.preserveAccessKeyOnLogout) { var machineName: string = os.hostname(); return sdk.removeSession(machineName) .catch((error: CodePushError) => { // 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((): void => { sdk = null; deleteConnectionInfoCache(); }); } function formatDate(unixOffset: number): string { var date: moment.Moment = moment(unixOffset); var now: moment.Moment = 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: string, apps: App[]): void { if (format === "json") { printJson(apps); } else if (format === "table") { var headers = ["Name", "Deployments"]; printTable(headers, (dataSource: any[]): void => { apps.forEach((app: App, index: number): void => { var row = [app.name, wordwrap(50)(app.deployments.join(", "))]; dataSource.push(row); }); }); } } function getCollaboratorDisplayName(email: string, collaboratorProperties: CollaboratorProperties): string { return (collaboratorProperties.permission === AccountManager.AppPermission.OWNER) ? email + chalk.magenta(" (Owner)") : email; } function printCollaboratorsList(format: string, collaborators: CollaboratorMap): void { if (format === "json") { var dataSource = { "collaborators": collaborators }; printJson(dataSource); } else if (format === "table") { var headers = ["E-mail Address"]; printTable(headers, (dataSource: any[]): void => { Object.keys(collaborators).forEach((email: string): void => { var row = [getCollaboratorDisplayName(email, collaborators[email])]; dataSource.push(row); }); }); } } function printDeploymentList(command: cli.IDeploymentListCommand, deployments: Deployment[], showPackage: boolean = true): void { if (command.format === "json") { printJson(deployments); } else if (command.format === "table") { var headers = ["Name"]; if (command.displayKeys) { headers.push("Deployment Key"); } if (showPackage) { headers.push("Update Metadata"); headers.push("Install Metrics"); } printTable(headers, (dataSource: any[]): void => { deployments.forEach((deployment: Deployment): void => { var 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: cli.IDeploymentHistoryCommand, deploymentHistory: PackageWithMetrics[], currentUserEmail: string): void { if (command.format === "json") { printJson(deploymentHistory); } else if (command.format === "table") { var headers = ["Label", "Release Time", "App Version", "Mandatory"]; if (command.displayAuthor) { headers.push("Released By"); } headers.push("Description", "Install Metrics"); printTable(headers, (dataSource: any[]) => { deploymentHistory.forEach((packageObject: Package) => { var releaseTime: string = formatDate(packageObject.uploadTime); var releaseSource: string; if (packageObject.releaseMethod === "Promote") { releaseSource = `Promoted ${packageObject.originalLabel} from "${packageObject.originalDeployment}"`; } else if (packageObject.releaseMethod === "Rollback") { var labelNumber: number = parseInt(packageObject.label.substring(1)); var lastLabel: string = "v" + (labelNumber - 1); releaseSource = `Rolled back ${lastLabel} to ${packageObject.originalLabel}`; } if (releaseSource) { releaseTime += "\n" + chalk.magenta(`(${releaseSource})`).toString(); } var row: string[] = [packageObject.label, releaseTime, packageObject.appVersion, packageObject.isMandatory ? "Yes" : "No"]; if (command.displayAuthor) { var releasedBy: string = 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: string) => applyChalkSkippingLineBreaks(cellContents, (chalk).dim)); } dataSource.push(row); }); }); } } function applyChalkSkippingLineBreaks(applyString: string, chalkMethod: (string: string) => Chalk.ChalkChain): string { // 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: string) => chalkMethod(token)) .join("\n"); } function getPackageString(packageObject: Package): string { if (!packageObject) { return chalk.magenta("No updates released").toString(); } var packageString: string = 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: Package): string { var packageObject = obj; var rolloutString: string = (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 || ""); } var activePercent: number = packageObject.metrics.totalActive ? packageObject.metrics.active / packageObject.metrics.totalActive * 100 : 0.0; var percentString: string; if (activePercent === 100.0) { percentString = "100%"; } else if (activePercent === 0.0) { percentString = "0%"; } else { percentString = activePercent.toPrecision(2) + "%"; } var numPending: number = packageObject.metrics.downloaded - packageObject.metrics.installed - packageObject.metrics.failed; var returnString: string = 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: cli.IReleaseReactCommand, projectName: string): Promise { const fileExists = (file: string): boolean => { try { return fs.statSync(file).isFile() } catch (e) { return false } }; const isValidVersion = (version: string): boolean => !!semver.valid(version) || /^\d+\.\d+$/.test(version); log(chalk.cyan(`Detecting ${command.platform} app version:\n`)); if (command.platform === "ios") { let resolvedPlistFile: string = 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 (!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: string = "ios"; const plistFileName = `${command.plistFilePrefix || ""}Info.plist`; const knownLocations = [ path.join(iOSDirectory, projectName, plistFileName), path.join(iOSDirectory, plistFileName) ]; resolvedPlistFile = (knownLocations).find(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(); try { var 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 (isValidVersion(parsedPlist.CFBundleShortVersionString)) { log(`Using the target binary version value "${parsedPlist.CFBundleShortVersionString}" from "${resolvedPlistFile}".\n`); return Q(parsedPlist.CFBundleShortVersionString); } else { 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).`); } } else { throw new Error(`The "CFBundleShortVersionString" key doesn't exist within the "${resolvedPlistFile}" file.`); } } else if (command.platform === "android") { let buildGradlePath: string = path.join("android", "app"); if (command.gradleFile) { buildGradlePath = command.gradleFile; } if (fs.lstatSync(buildGradlePath).isDirectory()) { buildGradlePath = path.join(buildGradlePath, "build.gradle"); } if (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: any) => { if (!buildGradle.android || !buildGradle.android.defaultConfig || !buildGradle.android.defaultConfig.versionName) { throw new Error(`The "${buildGradlePath}" file doesn't specify a value for the "android.defaultConfig.versionName" property.`); } if (typeof buildGradle.android.defaultConfig.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: string = buildGradle.android.defaultConfig.versionName.replace(/"/g, "").trim(); if (isValidVersion(appVersion)) { // The versionName property is a valid semver string, // so we can safely use that and move on. 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) ]; const propertiesFile: string = (knownLocations).find(fileExists); const propertiesContent: string = fs.readFileSync(propertiesFile).toString(); try { const parsedProperties: any = properties.parse(propertiesContent); appVersion = parsedProperties[propertyName]; } 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 (!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).`); } log(`Using the target binary version value "${appVersion}" from the "${propertyName}" key in the "${propertiesFile}" file.\n`); return appVersion.toString(); }); } else { var appxManifestFileName: string = "Package.appxmanifest"; try { var appxManifestContainingFolder: string = path.join("windows", projectName); var appxManifestContents: string = 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: any) => { throw new Error(`Unable to parse the "${path.join(appxManifestContainingFolder, appxManifestFileName)}" file, it could be malformed.`); }) .then((parsedAppxManifest: any) => { 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 printJson(object: any): void { log(JSON.stringify(object, /*replacer=*/ null, /*spacing=*/ 2)); } function printAccessKeys(format: string, keys: AccessKey[]): void { if (format === "json") { printJson(keys); } else if (format === "table") { printTable(["Name", "Created", "Expires"], (dataSource: any[]): void => { var now = new Date().getTime(); function isExpired(key: AccessKey): boolean { return now >= key.expires; } function keyToTableRow(key: AccessKey, dim: boolean): string[] { var row: string[] = [ key.name, key.createdTime ? formatDate(key.createdTime) : "", formatDate(key.expires) ]; if (dim) { row.forEach((col: string, index: number) => { row[index] = (chalk).dim(col); }); } return row; } keys.forEach((key: AccessKey) => !isExpired(key) && dataSource.push(keyToTableRow(key, /*dim*/ false))); keys.forEach((key: AccessKey) => isExpired(key) && dataSource.push(keyToTableRow(key, /*dim*/ true))); }); } } function printSessions(format: string, sessions: Session[]): void { if (format === "json") { printJson(sessions); } else if (format === "table") { printTable(["Machine", "Logged in"], (dataSource: any[]): void => { sessions.forEach((session: Session) => dataSource.push([session.machineName, formatDate(session.loggedInTime)])); }); } } function printTable(columnNames: string[], readData: (dataSource: any[]) => void): void { var table = new Table({ head: columnNames, style: { head: ["cyan"] } }); readData(table); log(table.toString()); } function register(command: cli.IRegisterCommand): Promise { return loginWithExternalAuthentication("register", command.serverUrl, command.proxy, command.noProxy); } function promote(command: cli.IPromoteCommand): Promise { var packageInfo: PackageInfo = { appVersion: command.appStoreVersion, description: command.description, isDisabled: getYargsBooleanOrNull(command.disabled), isMandatory: getYargsBooleanOrNull(command.mandatory), rollout: command.rollout }; return sdk.promote(command.appName, command.sourceDeploymentName, command.destDeploymentName, packageInfo) .then((): void => { log("Successfully promoted the \"" + command.sourceDeploymentName + "\" deployment of the \"" + command.appName + "\" app to the \"" + command.destDeploymentName + "\" deployment."); }) .catch((err: CodePushError) => releaseErrorHandler(err, command)); } function patch(command: cli.IPatchCommand): Promise { var packageInfo: PackageInfo = { appVersion: command.appStoreVersion, description: command.description, isMandatory: getYargsBooleanOrNull(command.mandatory), isDisabled: getYargsBooleanOrNull(command.disabled), rollout: command.rollout }; for (var updateProperty in packageInfo) { if ((packageInfo)[updateProperty] !== null) { return sdk.patchRelease(command.appName, command.deploymentName, command.label, packageInfo) .then((): void => { 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."); } export var release = (command: cli.IReleaseCommand): Promise => { if (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); var filePath: string = command.package; var getPackageFilePromise: Promise; var isSingleFilePackage: boolean = true; if (fs.lstatSync(filePath).isDirectory()) { isSingleFilePackage = false; getPackageFilePromise = Promise((resolve: (file: IPackageFile) => void, reject: (reason: Error) => void): void => { var directoryPath: string = filePath; recursiveFs.readdirr(directoryPath, (error?: any, directories?: string[], files?: string[]): void => { if (error) { reject(error); return; } var baseDirectoryPath = path.dirname(directoryPath); var fileName: string = generateRandomFilename(15) + ".zip"; var zipFile = new yazl.ZipFile(); var writeStream: fs.WriteStream = fs.createWriteStream(fileName); zipFile.outputStream.pipe(writeStream) .on("error", (error: Error): void => { reject(error); }) .on("close", (): void => { filePath = path.join(process.cwd(), fileName); resolve({ isTemporary: true, path: filePath }); }); for (var i = 0; i < files.length; ++i) { var file: string = files[i]; var relativePath: string = path.relative(baseDirectoryPath, file); // yazl does not like backslash (\) in the metadata path. relativePath = slash(relativePath); zipFile.addFile(file, relativePath); } zipFile.end(); }); }); } else { getPackageFilePromise = Q({ isTemporary: false, path: filePath }); } var lastTotalProgress = 0; var progressBar = new progress("Upload progress:[:bar] :percent :etas", { complete: "=", incomplete: " ", width: 50, total: 100 }); var uploadProgress = (currentProgress: number): void => { progressBar.tick(currentProgress - lastTotalProgress); lastTotalProgress = currentProgress; }; var updateMetadata: PackageInfo = { description: command.description, isDisabled: command.disabled, isMandatory: command.mandatory, rollout: command.rollout }; return getPackageFilePromise .then((file: IPackageFile): Promise => { return sdk.isAuthenticated(true) .then((isAuth: boolean): Promise => { return sdk.release(command.appName, command.deploymentName, file.path, command.appStoreVersion, updateMetadata, uploadProgress); }) .then((): void => { log("Successfully released an update containing the \"" + command.package + "\" " + (isSingleFilePackage ? "file" : "directory") + " to the \"" + command.deploymentName + "\" deployment of the \"" + command.appName + "\" app."); }) .finally((): void => { if (file.isTemporary) { fs.unlinkSync(filePath); } }) .catch((err: CodePushError) => releaseErrorHandler(err, command)); }); } export var releaseCordova = (command: cli.IReleaseCordovaCommand): Promise => { var platform: string = command.platform.toLowerCase(); var projectRoot: string = process.cwd(); var platformFolder: string = path.join(projectRoot, "platforms", platform); var platformCordova: string = path.join(platformFolder, "cordova"); var outputFolder: string; if (platform === "ios") { outputFolder = path.join(platformFolder, "www"); } else if (platform === "android") { outputFolder = path.join(platformFolder, "assets", "www"); } else { throw new Error("Platform must be either \"ios\" or \"android\"."); } var cordovaCommand: string = command.build ? "build" : "prepare"; var cordovaCLI: string = "cordova"; // Check whether the Cordova or PhoneGap CLIs are // installed, and if not, fail early try { which.sync(cordovaCLI); } catch (e) { try { cordovaCLI = "phonegap"; which.sync(cordovaCLI); } catch (e) { throw new Error(`Unable to ${cordovaCommand} project. Please ensure that either the Cordova or PhoneGap CLI is installed.`); } } log(chalk.cyan(`Running "${cordovaCLI} ${cordovaCommand}" command:\n`)); try { execSync([cordovaCLI, cordovaCommand, platform, "--verbose"].join(" "), { stdio: "inherit" }); } catch (error) { throw new Error(`Unable to ${cordovaCommand} project. Please ensure that the CWD represents a Cordova project and that the "${platform}" platform was added by running "${cordovaCLI} platform add ${platform}".`); } try { var configString: string = fs.readFileSync(path.join(projectRoot, "config.xml"), { encoding: "utf8" }); } catch (error) { throw new Error(`Unable to find or read "config.xml" in the CWD. The "release-cordova" command must be executed in a Cordova project folder.`); } var configPromise: Promise = parseXml(configString); var releaseCommand: cli.IReleaseCommand = command; releaseCommand.package = outputFolder; releaseCommand.type = cli.CommandType.release; return configPromise .catch((err: any) => { throw new Error(`Unable to parse "config.xml" in the CWD. Ensure that the contents of "config.xml" is valid XML.`); }) .then((parsedConfig: any) => { var config: any = parsedConfig.widget; var releaseTargetVersion: string; if (command.appStoreVersion) { releaseTargetVersion = command.appStoreVersion; } else { releaseTargetVersion = config["$"].version; } throwForInvalidSemverRange(releaseTargetVersion); releaseCommand.appStoreVersion = releaseTargetVersion; log(chalk.cyan("\nReleasing update contents to CodePush:\n")); return release(releaseCommand); }); } export var releaseReact = (command: cli.IReleaseReactCommand): Promise => { var bundleName: string = command.bundleName; var entryFile: string = command.entryFile; var outputFolder: string = path.join(os.tmpdir(), "CodePush"); var platform: string = command.platform = command.platform.toLowerCase(); var releaseCommand: cli.IReleaseCommand = command; 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\"."); } try { var projectPackageJson: any = require(path.join(process.cwd(), "package.json")); var projectName: string = 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 (fileDoesNotExistOrIsDirectory(entryFile)) { entryFile = "index.js"; } if (fileDoesNotExistOrIsDirectory(entryFile)) { throw new Error(`Entry file "index.${platform}.js" or "index.js" does not exist.`); } } else { if (fileDoesNotExistOrIsDirectory(entryFile)) { throw new Error(`Entry file "${entryFile}" does not exist.`); } } if (command.appStoreVersion) { throwForInvalidSemverRange(command.appStoreVersion); } var appVersionPromise: Promise = command.appStoreVersion ? Q(command.appStoreVersion) : getReactNativeProjectAppVersion(command, projectName); return appVersionPromise .then((appVersion: string) => { releaseCommand.appStoreVersion = appVersion; return 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(() => runReactNativeBundleCommand(bundleName, command.development || false, entryFile, outputFolder, platform, command.sourcemapOutput)) .then(() => { log(chalk.cyan("\nReleasing update contents to CodePush:\n")); return release(releaseCommand); }) .then(() => deleteFolder(outputFolder)) .catch((err: Error) => { deleteFolder(outputFolder); throw err; }); } function rollback(command: cli.IRollbackCommand): Promise { return confirm() .then((wasConfirmed: boolean) => { if (!wasConfirmed) { log("Rollback cancelled.") return; } return sdk.rollback(command.appName, command.deploymentName, command.targetRelease || undefined) .then((): void => { log("Successfully performed a rollback on the \"" + command.deploymentName + "\" deployment of the \"" + command.appName + "\" app."); }); }); } function requestAccessKey(): Promise { return Promise((resolve, reject, notify): void => { prompt.message = ""; prompt.delimiter = ""; prompt.start(); prompt.get({ properties: { response: { description: chalk.cyan("Enter your access key: ") } } }, (err: any, result: any): void => { if (err) { resolve(null); } else { resolve(result.response.trim()); } }); }); } export var runReactNativeBundleCommand = (bundleName: string, development: boolean, entryFile: string, outputFolder: string, platform: string, sourcemapOutput: string): Promise => { var reactNativeBundleArgs = [ path.join("node_modules", "react-native", "local-cli", "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); } log(chalk.cyan("Running \"react-native bundle\" command:\n")); var reactNativeBundleProcess = spawn("node", reactNativeBundleArgs); log(`node ${reactNativeBundleArgs.join(" ")}`); return Promise((resolve, reject, notify) => { reactNativeBundleProcess.stdout.on("data", (data: Buffer) => { log(data.toString().trim()); }); reactNativeBundleProcess.stderr.on("data", (data: Buffer) => { console.error(data.toString().trim()); }); reactNativeBundleProcess.on("close", (exitCode: number) => { if (exitCode) { reject(new Error(`"react-native bundle" command exited with code ${exitCode}.`)); } resolve(null); }); }); } function serializeConnectionInfo(accessKey: string, preserveAccessKeyOnLogout: boolean, customServerUrl?: string, proxy?: string, noProxy?: boolean): void { var connectionInfo: ILoginConnectionInfo = { accessKey: accessKey, preserveAccessKeyOnLogout: preserveAccessKeyOnLogout, proxy: proxy, noProxy: noProxy }; if (customServerUrl) { connectionInfo.customServerUrl = customServerUrl; } var json: string = JSON.stringify(connectionInfo); fs.writeFileSync(configFilePath, json, { encoding: "utf8" }); 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: cli.ISessionListCommand): Promise { throwForInvalidOutputFormat(command.format); return sdk.getSessions() .then((sessions: Session[]): void => { printSessions(command.format, sessions); }); } function sessionRemove(command: cli.ISessionRemoveCommand): Promise { if (os.hostname() === command.machineName) { throw new Error("Cannot remove the current login session via this command. Please run 'code-push logout' instead."); } else { return confirm() .then((wasConfirmed: boolean): Promise => { if (wasConfirmed) { return sdk.removeSession(command.machineName) .then((): void => { log(`Successfully removed the login session for "${command.machineName}".`); }); } log("Session removal cancelled."); }); } } function releaseErrorHandler(error: CodePushError, command: cli.ICommand): void { if ((command).noDuplicateReleaseError && error.statusCode === AccountManager.ERROR_CONFLICT) { console.warn(chalk.yellow("[Warning] " + error.message)); } else { throw error; } } function isBinaryOrZip(path: string): boolean { return path.search(/\.zip$/i) !== -1 || path.search(/\.apk$/i) !== -1 || path.search(/\.ipa$/i) !== -1; } function getYargsBooleanOrNull(value: any): boolean { // Yargs treats a boolean argument as an array of size 2 for null, third is the value of boolean. return value && value.length > 2 ? value[2] : null; } function throwForInvalidEmail(email: string): void { if (!emailValidator.validate(email)) { throw new Error("\"" + email + "\" is an invalid e-mail address."); } } function throwForInvalidSemverRange(semverRange: string): void { 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: string): void { switch (format) { case "json": case "table": break; default: throw new Error("Invalid format: " + format + "."); } } function whoami(command: cli.ICommand): Promise { return sdk.getAccountInfo() .then((account): void => { var accountInfo = `${account.email} (${account.linkedProviders.join(", ")})`; var connectionInfo = deserializeConnectionInfo(); if (connectionInfo.noProxy || connectionInfo.proxy) { log(chalk.green('Account: ') + accountInfo); var proxyInfo = chalk.green('Proxy: ') + (connectionInfo.noProxy ? 'Ignored' : connectionInfo.proxy); log(proxyInfo); } else { log(accountInfo); } }); } function getProxy(proxy?: string, noProxy?: boolean): string { if (noProxy) return null; if (!proxy) return process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy; else return proxy; } function isCommandOptionSpecified(option: any): boolean { return option !== undefined && option !== null; } function getSdk(accessKey: string, headers: Headers, customServerUrl: string, proxy: string): AccountManager { var sdk: any = new AccountManager(accessKey, CLI_HEADERS, customServerUrl, proxy); /* * 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: any) => { if (typeof sdk[functionName] === "function") { var originalFunction = sdk[functionName]; sdk[functionName] = function() { var maybePromise: Promise = originalFunction.apply(sdk, arguments); if (maybePromise && maybePromise.then !== undefined) { maybePromise = maybePromise .catch((error: any) => { if (error.statusCode && error.statusCode === AccountManager.ERROR_UNAUTHORIZED) { deleteConnectionInfoCache(/* printMessage */ false); } throw error; }); } return maybePromise; }; } }); return sdk; }