diff --git a/script/auth/connection.ts b/script/auth/connection.ts new file mode 100644 index 0000000..800772a --- /dev/null +++ b/script/auth/connection.ts @@ -0,0 +1,105 @@ +import * as chalk from "chalk"; +import * as fs from "fs"; +import * as path from "path"; +import { Headers } from "../types"; +import { log } from "../ui/log"; +import AccountManager = require("../management-sdk"); + +const packageJson = require("../../../package.json"); + +const configFilePath: string = path.join(process.env.LOCALAPPDATA || process.env.HOME, ".revopush.config"); + +const CLI_HEADERS: Headers = { + "X-CodePush-CLI-Version": packageJson.version, +}; + +/** Deprecated */ +interface ILegacyLoginConnectionInfo { + accessKeyName: string; +} + +export interface ILoginConnectionInfo { + accessKey: string; + customServerUrl?: string; // A custom serverUrl for internal debugging purposes + preserveAccessKeyOnLogout?: boolean; +} + +export 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 {} +} + +export function deserializeConnectionInfo(): ILoginConnectionInfo { + try { + const savedConnection: string = fs.readFileSync(configFilePath, { + encoding: "utf8", + }); + let connectionInfo: ILegacyLoginConnectionInfo | ILoginConnectionInfo = JSON.parse(savedConnection); + + // If the connection info is in the legacy format, convert it to the modern format + if ((connectionInfo as ILegacyLoginConnectionInfo).accessKeyName) { + connectionInfo = { + accessKey: (connectionInfo as ILegacyLoginConnectionInfo).accessKeyName, + } as ILoginConnectionInfo; + } + + return connectionInfo as ILoginConnectionInfo; + } catch { + return; + } +} + +export function serializeConnectionInfo(accessKey: string, preserveAccessKeyOnLogout: boolean, customServerUrl?: string): void { + const connectionInfo: ILoginConnectionInfo = { + accessKey: accessKey, + preserveAccessKeyOnLogout: preserveAccessKeyOnLogout, + }; + if (customServerUrl) { + connectionInfo.customServerUrl = customServerUrl; + } + + const 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( + "revopush logout" + )} command at any time to delete this file and terminate your session.\r\n` + ); +} + +export function getSdk(accessKey: string, customServerUrl: string): AccountManager { + const sdk: any = 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: any) => { + if (typeof sdk[functionName] === "function") { + const originalFunction = sdk[functionName]; + sdk[functionName] = function () { + let 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; +} diff --git a/script/command-executor.ts b/script/command-executor.ts index 6c11ebc..0b708a1 100644 --- a/script/command-executor.ts +++ b/script/command-executor.ts @@ -9,7 +9,6 @@ import * as fs from "fs"; import * as chalk from "chalk"; import * as os from "os"; import * as path from "path"; -import * as semver from "semver"; import * as cli from "../script/types/cli"; import sign from "./sign"; const ApkReader = require("@devicefarmer/adbkit-apkreader"); @@ -19,20 +18,17 @@ import { App, CodePushError, CollaboratorMap, - CollaboratorProperties, Deployment, DeploymentMetrics, - Headers, Package, PackageInfo, + PackageWithMetrics, Session, - UpdateMetrics, } from "./types"; import { getMinifyParams, getReactNativePackagePath, getSourceMapPaths, - isValidVersion, runHermesEmitBinaryCommand, SourceMapPaths, takeHermesBaseBytecode, @@ -40,6 +36,13 @@ import { import { isHermesEnabled } from "./utils/hermes-utils"; import { fileDoesNotExistOrIsDirectory, fileExists, isBinaryOrZip, extractArchive } from "./utils/file-utils"; import { getAndroidVersionInfo } from "./utils/gradle-utils"; +import { getIosVersionInfo } from "./utils/ios-utils"; +import { + isCommandOptionSpecified, + throwForInvalidEmail, + throwForInvalidOutputFormat, + throwForInvalidSemverRange, +} from "./utils/validation-utils"; import { analyzeReleaseContents, enforceAssetSizeLimit, @@ -50,48 +53,29 @@ import { } from "./utils/release-size-utils"; import AccountManager = require("./management-sdk"); -import wordwrap = require("wordwrap"); import { ReactNativePackageInfo } from "./types/rest-definitions"; import { getExpoCliPath } from "./expo-utils"; +import { + deleteConnectionInfoCache, + deserializeConnectionInfo, + getSdk, + ILoginConnectionInfo, + serializeConnectionInfo, +} from "./auth/connection"; import { log } from "./ui/log"; import { confirm, requestAccessKey } from "./ui/prompt"; import { createUploadProgress } from "./ui/progress"; +import { + printAccessKeys, + printAppList, + printCollaboratorsList, + printDeploymentHistory, + printDeploymentList, + printSessions, +} from "./ui/printers"; const opener = require("opener"); -const plist = require("plist"); - -const Table = require("cli-table"); - -const xcode = require("xcode"); - -const configFilePath: string = path.join(process.env.LOCALAPPDATA || process.env.HOME, ".revopush.config"); -const emailValidator = require("email-validator"); -const packageJson = require("../../package.json"); - -const CLI_HEADERS: Headers = { - "X-CodePush-CLI-Version": packageJson.version, -}; - -/** Deprecated */ -interface ILegacyLoginConnectionInfo { - accessKeyName: string; -} - -interface ILoginConnectionInfo { - accessKey: string; - customServerUrl?: string; // A custom serverUrl for internal debugging purposes - preserveAccessKeyOnLogout?: boolean; -} - -export interface UpdateMetricsWithTotalActive extends UpdateMetrics { - totalActive: number; -} - -export interface PackageWithMetrics { - metrics?: UpdateMetricsWithTotalActive; -} - export let sdk: AccountManager; export const spawn = childProcess.spawn; export const execSync = childProcess.execSync; @@ -223,16 +207,6 @@ async function removeCollaborator(command: cli.ICollaboratorRemoveCommand): Prom 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 {} -} - function deleteFolder(folderPath: string): Promise { return fs.promises.rm(folderPath, { recursive: true, force: true, maxRetries: 3 }); } @@ -356,28 +330,6 @@ async function deploymentHistory(command: cli.IDeploymentHistoryCommand): Promis printDeploymentHistory(command, deploymentHistory as Package[], account.email); } -function deserializeConnectionInfo(): ILoginConnectionInfo { - try { - const savedConnection: string = fs.readFileSync(configFilePath, { - encoding: "utf8", - }); - let connectionInfo: ILegacyLoginConnectionInfo | ILoginConnectionInfo = JSON.parse(savedConnection); - - // If the connection info is in the legacy format, convert it to the modern format - if ((connectionInfo as ILegacyLoginConnectionInfo).accessKeyName) { - connectionInfo = { - accessKey: (connectionInfo as ILegacyLoginConnectionInfo).accessKeyName, - } as ILoginConnectionInfo; - } - - const connInfo = connectionInfo as ILoginConnectionInfo; - - return connInfo; - } catch { - return; - } -} - export async function execute(command: cli.ICommand): Promise { connectionInfo = deserializeConnectionInfo(); @@ -402,7 +354,7 @@ export async function execute(command: cli.ICommand): Promise { throw new Error("You are not currently logged in. Run the 'revopush login' command to authenticate with the CodePush server."); } - sdk = getSdk(connectionInfo.accessKey, CLI_HEADERS, connectionInfo.customServerUrl); + sdk = getSdk(connectionInfo.accessKey, connectionInfo.customServerUrl); break; } @@ -535,7 +487,7 @@ async function link(command: cli.ILinkCommand): Promise { async function login(command: cli.ILoginCommand): Promise { if (command.accessKey) { - sdk = getSdk(command.accessKey, CLI_HEADERS, command.apiServerUrl); + sdk = getSdk(command.accessKey, command.apiServerUrl); if (await sdk.isAuthenticated()) { serializeConnectionInfo(command.accessKey, /*preserveAccessKeyOnLogout*/ true, command.apiServerUrl); } else { @@ -556,7 +508,7 @@ async function loginWithExternalAuthentication(action: string, apiServerUrl?: st return; } - sdk = getSdk(accessKey, CLI_HEADERS, apiServerUrl); + sdk = getSdk(accessKey, apiServerUrl); if (await sdk.isAuthenticated()) { serializeConnectionInfo(accessKey, /*preserveAccessKeyOnLogout*/ false, apiServerUrl); @@ -582,206 +534,6 @@ async function logout(_command: cli.ICommand): Promise { deleteConnectionInfoCache(); } -function printAppList(format: string, apps: App[]): void { - if (format === "json") { - printJson(apps); - } else if (format === "table") { - const headers = ["Name", "Deployments"]; - printTable(headers, (dataSource: any[]): void => { - apps.forEach((app: App): void => { - const 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") { - const dataSource = { collaborators: collaborators }; - printJson(dataSource); - } else if (format === "table") { - const headers = ["E-mail Address"]; - printTable(headers, (dataSource: any[]): void => { - Object.keys(collaborators).forEach((email: string): void => { - const 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") { - const 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 => { - 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: cli.IDeploymentHistoryCommand, deploymentHistory: Package[], currentUserEmail: string): void { - 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: any[]) => { - deploymentHistory.forEach((packageObject: Package) => { - let releaseTime: string = formatDate(packageObject.uploadTime); - let releaseSource: string; - if (packageObject.releaseMethod === "Promote") { - releaseSource = `Promoted ${packageObject.originalLabel} from "${packageObject.originalDeployment}"`; - } else if (packageObject.releaseMethod === "Rollback") { - const labelNumber: number = parseInt(packageObject.label.substring(1)); - const lastLabel: string = "v" + (labelNumber - 1); - releaseSource = `Rolled back ${lastLabel} to ${packageObject.originalLabel}`; - } - - if (releaseSource) { - releaseTime += "\n" + chalk.magenta(`(${releaseSource})`).toString(); - } - - let row: string[] = [packageObject.label, releaseTime, packageObject.appVersion, packageObject.isMandatory ? "Yes" : "No"]; - if (command.displayAuthor) { - let 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) => any): 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(); - } - - let 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 { - const packageObject = obj as PackageWithMetrics; - const 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 || ""); - } - - const activePercent: number = packageObject.metrics.totalActive - ? (packageObject.metrics.active / packageObject.metrics.totalActive) * 100 - : 0.0; - let percentString: string; - if (activePercent === 100.0) { - percentString = "100%"; - } else if (activePercent === 0.0) { - percentString = "0%"; - } else { - percentString = activePercent.toPrecision(2) + "%"; - } - - const numPending: number = packageObject.metrics.downloaded - packageObject.metrics.installed - packageObject.metrics.failed; - let 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; -} - interface ProjectVersionInfo { appVersion?: string; buildNumber?: string; @@ -791,200 +543,12 @@ async function getReactNativeProjectVersionInfo(command: cli.IReleaseReactComman 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 = "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(); - - let parsedPlist: any; - - try { - parsedPlist = plist.parse(plistContents); - } catch { - throw new Error(`Unable to parse "${resolvedPlistFile}". Please ensure it is a well-formed plist file.`); - } - - const rawShortVersion: string | undefined = parsedPlist.CFBundleShortVersionString; - const rawBundleVersion: string | undefined = parsedPlist.CFBundleVersion; - - // Both keys may reference Xcode build settings — delegate to the project file if either does - if (rawShortVersion === "$(MARKETING_VERSION)" || rawBundleVersion === "$(CURRENT_PROJECT_VERSION)") { - return getAppVersionFromXcodeProject(command, projectName); - } - - if (rawShortVersion && !isValidVersion(rawShortVersion)) { - throw new Error( - `The "CFBundleShortVersionString" key in the "${resolvedPlistFile}" file needs to specify a valid semver string (e.g. 1.3.2).` - ); - } - - return { appVersion: rawShortVersion, buildNumber: rawBundleVersion }; + return getIosVersionInfo(command, projectName); } else if (command.platform === "android") { return getAndroidVersionInfo(command.gradleFile); } } -async function getAppVersionFromXcodeProject( - command: cli.IReleaseReactCommand, - projectName: string -): Promise<{ appVersion: string; buildNumber: string }> { - const pbxprojFileName = "project.pbxproj"; - let resolvedPbxprojFile: string = 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 (!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(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 (!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).` - ); - } - - const currentProjectVersion = xcodeProj.getBuildProperty( - "CURRENT_PROJECT_VERSION", - command.buildConfigurationName, - command.xcodeTargetName - ); - if (!currentProjectVersion) { - throw new Error(`The "CURRENT_PROJECT_VERSION" key doesn't exist in the "${resolvedPbxprojFile}" file.`); - } - - return { appVersion: marketingVersion, buildNumber: String(currentProjectVersion) }; -} - -const relativeTimeFormat = new Intl.RelativeTimeFormat("en-US", { numeric: "auto" }); -const RELATIVE_TIME_UNITS: Array<[unit: Intl.RelativeTimeFormatUnit, ms: number]> = [ - ["day", 24 * 60 * 60 * 1000], - ["hour", 60 * 60 * 1000], - ["minute", 60 * 1000], - ["second", 1000], -]; - -// "2 hours ago" within 30 days, otherwise "Nov 6" in the current year and "Nov 6, 2014" before it -function formatDate(unixOffset: number): string { - if (!Number.isFinite(unixOffset)) { - return ""; - } - - const msFromNow: number = unixOffset - Date.now(); - if (Math.abs(msFromNow) < 30 * RELATIVE_TIME_UNITS[0][1]) { - const [unit, ms] = RELATIVE_TIME_UNITS.find(([, ms]) => Math.abs(msFromNow) >= ms) ?? ["second", 1000]; - return relativeTimeFormat.format(Math.round(msFromNow / ms), unit); - } - - const date = new Date(unixOffset); - const year = date.getFullYear() === new Date().getFullYear() ? undefined : "numeric"; - return date.toLocaleDateString("en-US", { month: "short", day: "numeric", year }); -} - -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 => { - const now = new Date().getTime(); - - function isExpired(key: AccessKey): boolean { - return now >= key.expires; - } - - function keyToTableRow(key: AccessKey, dim: boolean): string[] { - const 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 { - const table = new Table({ - head: columnNames, - style: { head: ["cyan"] }, - }); - - readData(table); - - log(table.toString()); -} - async function register(command: cli.IRegisterCommand): Promise { return loginWithExternalAuthentication("register", command.serverUrl); } @@ -1686,25 +1250,6 @@ export const runReactNativeBundleCommand = async ( }); }; -function serializeConnectionInfo(accessKey: string, preserveAccessKeyOnLogout: boolean, customServerUrl?: string): void { - const connectionInfo: ILoginConnectionInfo = { - accessKey: accessKey, - preserveAccessKeyOnLogout: preserveAccessKeyOnLogout, - }; - if (customServerUrl) { - connectionInfo.customServerUrl = customServerUrl; - } - - const 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( - "revopush logout" - )} command at any time to delete this file and terminate your session.\r\n` - ); -} - async function sessionList(command: cli.ISessionListCommand): Promise { throwForInvalidOutputFormat(command.format); @@ -1734,70 +1279,9 @@ function releaseErrorHandler(error: CodePushError, command: cli.ICommand): void } } -function throwForInvalidEmail(email: string): void { - if (!emailValidator.validate(email)) { - throw new Error('"' + email + '" is an invalid e-mail address.'); - } -} - -function throwForInvalidSemverRange(semverRange: string | undefined): void { - if (!semverRange) { - throw new Error(`Unable to determine the app version. Specify it using the --targetBinaryVersion option.`); - } - 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 + "."); - } -} - async function whoami(_command: cli.ICommand): Promise { const account: Account = await sdk.getAccountInfo(); const accountInfo = `${account.email} (${account.linkedProviders.join(", ")})`; log(accountInfo); } - -function isCommandOptionSpecified(option: any): boolean { - return option !== undefined && option !== null; -} - -function getSdk(accessKey: string, headers: Headers, customServerUrl: string): AccountManager { - const sdk: any = 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: any) => { - if (typeof sdk[functionName] === "function") { - const originalFunction = sdk[functionName]; - sdk[functionName] = function () { - let 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; -} diff --git a/script/types/index.ts b/script/types/index.ts index 87094f1..81acb77 100644 --- a/script/types/index.ts +++ b/script/types/index.ts @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +import { UpdateMetrics } from "./rest-definitions"; + export { AccessKeyRequest, Account, @@ -34,3 +36,11 @@ export interface Session { } export type Headers = { [headerName: string]: string }; + +export interface UpdateMetricsWithTotalActive extends UpdateMetrics { + totalActive: number; +} + +export interface PackageWithMetrics { + metrics?: UpdateMetricsWithTotalActive; +} diff --git a/script/ui/printers.ts b/script/ui/printers.ts new file mode 100644 index 0000000..ac5ea7d --- /dev/null +++ b/script/ui/printers.ts @@ -0,0 +1,296 @@ +import * as chalk from "chalk"; +import * as cli from "../types/cli"; +import { AccessKey, App, CollaboratorMap, CollaboratorProperties, Deployment, Package, PackageWithMetrics, Session } from "../types"; +import { log } from "./log"; +import AccountManager = require("../management-sdk"); +import wordwrap = require("wordwrap"); + +const Table = require("cli-table"); + +export function printAppList(format: string, apps: App[]): void { + if (format === "json") { + printJson(apps); + } else if (format === "table") { + const headers = ["Name", "Deployments"]; + printTable(headers, (dataSource: any[]): void => { + apps.forEach((app: App): void => { + const 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; +} + +export function printCollaboratorsList(format: string, collaborators: CollaboratorMap): void { + if (format === "json") { + const dataSource = { collaborators: collaborators }; + printJson(dataSource); + } else if (format === "table") { + const headers = ["E-mail Address"]; + printTable(headers, (dataSource: any[]): void => { + Object.keys(collaborators).forEach((email: string): void => { + const row = [getCollaboratorDisplayName(email, collaborators[email])]; + dataSource.push(row); + }); + }); + } +} + +export function printDeploymentList( + command: cli.IDeploymentListCommand, + deployments: Deployment[], + showPackage: boolean = true +): void { + 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: any[]): void => { + deployments.forEach((deployment: Deployment): void => { + 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); + }); + }); + } +} + +export function printDeploymentHistory( + command: cli.IDeploymentHistoryCommand, + deploymentHistory: Package[], + currentUserEmail: string +): void { + 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: any[]) => { + deploymentHistory.forEach((packageObject: Package) => { + let releaseTime: string = formatDate(packageObject.uploadTime); + let releaseSource: string; + if (packageObject.releaseMethod === "Promote") { + releaseSource = `Promoted ${packageObject.originalLabel} from "${packageObject.originalDeployment}"`; + } else if (packageObject.releaseMethod === "Rollback") { + const labelNumber: number = parseInt(packageObject.label.substring(1)); + const lastLabel: string = "v" + (labelNumber - 1); + releaseSource = `Rolled back ${lastLabel} to ${packageObject.originalLabel}`; + } + + if (releaseSource) { + releaseTime += "\n" + chalk.magenta(`(${releaseSource})`).toString(); + } + + let row: string[] = [packageObject.label, releaseTime, packageObject.appVersion, packageObject.isMandatory ? "Yes" : "No"]; + if (command.displayAuthor) { + let 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) => any): 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(); + } + + let 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 { + const packageObject = obj as PackageWithMetrics; + const 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 || ""); + } + + const activePercent: number = packageObject.metrics.totalActive + ? (packageObject.metrics.active / packageObject.metrics.totalActive) * 100 + : 0.0; + let percentString: string; + if (activePercent === 100.0) { + percentString = "100%"; + } else if (activePercent === 0.0) { + percentString = "0%"; + } else { + percentString = activePercent.toPrecision(2) + "%"; + } + + const numPending: number = packageObject.metrics.downloaded - packageObject.metrics.installed - packageObject.metrics.failed; + let 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; +} + +const relativeTimeFormat = new Intl.RelativeTimeFormat("en-US", { numeric: "auto" }); + +const RELATIVE_TIME_UNITS: Array<[unit: Intl.RelativeTimeFormatUnit, ms: number]> = [ + ["day", 24 * 60 * 60 * 1000], + ["hour", 60 * 60 * 1000], + ["minute", 60 * 1000], + ["second", 1000], +]; + +// "2 hours ago" within 30 days, otherwise "Nov 6" in the current year and "Nov 6, 2014" before it +function formatDate(unixOffset: number): string { + if (!Number.isFinite(unixOffset)) { + return ""; + } + + const msFromNow: number = unixOffset - Date.now(); + if (Math.abs(msFromNow) < 30 * RELATIVE_TIME_UNITS[0][1]) { + const [unit, ms] = RELATIVE_TIME_UNITS.find(([, ms]) => Math.abs(msFromNow) >= ms) ?? ["second", 1000]; + return relativeTimeFormat.format(Math.round(msFromNow / ms), unit); + } + + const date = new Date(unixOffset); + const year = date.getFullYear() === new Date().getFullYear() ? undefined : "numeric"; + return date.toLocaleDateString("en-US", { month: "short", day: "numeric", year }); +} + +function printJson(object: any): void { + log(JSON.stringify(object, /*replacer=*/ null, /*spacing=*/ 2)); +} + +export function printAccessKeys(format: string, keys: AccessKey[]): void { + if (format === "json") { + printJson(keys); + } else if (format === "table") { + printTable(["Name", "Created", "Expires"], (dataSource: any[]): void => { + const now = new Date().getTime(); + + function isExpired(key: AccessKey): boolean { + return now >= key.expires; + } + + function keyToTableRow(key: AccessKey, dim: boolean): string[] { + const 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))); + }); + } +} + +export 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 { + const table = new Table({ + head: columnNames, + style: { head: ["cyan"] }, + }); + + readData(table); + + log(table.toString()); +} diff --git a/script/utils/ios-utils.ts b/script/utils/ios-utils.ts new file mode 100644 index 0000000..6625264 --- /dev/null +++ b/script/utils/ios-utils.ts @@ -0,0 +1,124 @@ +import * as fs from "fs"; +import * as path from "path"; +import * as cli from "../types/cli"; +import { isValidVersion } from "../react-native-utils"; +import { fileExists } from "./file-utils"; + +const plist = require("plist"); +const xcode = require("xcode"); + +export interface IosVersionInfo { + appVersion?: string; + buildNumber?: string; +} + +/** Reads the app version from Info.plist, or from the Xcode project when the plist refers to its build settings */ +export async function getIosVersionInfo(command: cli.IReleaseReactCommand, projectName: string): Promise { + 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 convention 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(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: any; + + try { + parsedPlist = plist.parse(plistContents); + } catch { + throw new Error(`Unable to parse "${resolvedPlistFile}". Please ensure it is a well-formed plist file.`); + } + + const rawShortVersion: string | undefined = parsedPlist.CFBundleShortVersionString; + const rawBundleVersion: string | undefined = parsedPlist.CFBundleVersion; + + // Both keys may reference Xcode build settings — delegate to the project file if either does + if (rawShortVersion === "$(MARKETING_VERSION)" || rawBundleVersion === "$(CURRENT_PROJECT_VERSION)") { + return getAppVersionFromXcodeProject(command, projectName); + } + + if (rawShortVersion && !isValidVersion(rawShortVersion)) { + throw new Error( + `The "CFBundleShortVersionString" key in the "${resolvedPlistFile}" file needs to specify a valid semver string (e.g. 1.3.2).` + ); + } + + return { appVersion: rawShortVersion, buildNumber: rawBundleVersion }; +} + +async function getAppVersionFromXcodeProject(command: cli.IReleaseReactCommand, projectName: string): Promise { + const pbxprojFileName = "project.pbxproj"; + let resolvedPbxprojFile: string = 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 (!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(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 (!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).` + ); + } + + const currentProjectVersion = xcodeProj.getBuildProperty( + "CURRENT_PROJECT_VERSION", + command.buildConfigurationName, + command.xcodeTargetName + ); + if (!currentProjectVersion) { + throw new Error(`The "CURRENT_PROJECT_VERSION" key doesn't exist in the "${resolvedPbxprojFile}" file.`); + } + + return { appVersion: marketingVersion, buildNumber: String(currentProjectVersion) }; +} diff --git a/script/utils/validation-utils.ts b/script/utils/validation-utils.ts new file mode 100644 index 0000000..4508f27 --- /dev/null +++ b/script/utils/validation-utils.ts @@ -0,0 +1,33 @@ +import * as semver from "semver"; + +const emailValidator = require("email-validator"); + +export function throwForInvalidEmail(email: string): void { + if (!emailValidator.validate(email)) { + throw new Error('"' + email + '" is an invalid e-mail address.'); + } +} + +export function throwForInvalidSemverRange(semverRange: string | undefined): void { + if (!semverRange) { + throw new Error(`Unable to determine the app version. Specify it using the --targetBinaryVersion option.`); + } + 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".'); + } +} + +export function throwForInvalidOutputFormat(format: string): void { + switch (format) { + case "json": + case "table": + break; + + default: + throw new Error("Invalid format: " + format + "."); + } +} + +export function isCommandOptionSpecified(option: any): boolean { + return option !== undefined && option !== null; +}