diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 46328ad..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "parser": "@typescript-eslint/parser", - "parserOptions": { - "ecmaVersion": "2022", - "sourceType": "module", - "project": "./tsconfig.json" - }, - "extends": [ - ], - "rules": { - "no-var": "error", - "prefer-const": "error", - "no-unused-vars": "error", - "eqeqeq": "error", - "no-eval": "error" - } -} diff --git a/README.md b/README.md index 2338730..5c84a45 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ To build it from source instead: Before upgrading a CI pipeline to 1.2.0, note two changes: an unknown flag is now an error rather than silently ignored, and a rejected command exits 1 rather than 0. A pipeline that passed on a misspelled flag will now fail. +Before upgrading an Expo pipeline to 1.2.2, note two changes. `release-expo` now sends your app config with every release and stops, releasing nothing, when it can't read it, so a job that releases an `--exportDir` from outside the project folder needs `--appConfig ` (see [App config parameters](#app-config-parameters)). And `promote` or `rollback` on an Expo app now exits 1 when any target failed or was busy with another change; it used to exit 0. New in 1.2.2: commands that ask "Are you sure?" take `--yes`, so a pipeline can run `dpctl rollback MyApp Production --yes`. + ## Getting started 1. Create a [DeployPulse account](https://deploypulse.io/register). @@ -278,7 +280,9 @@ dpctl app transfer _NOTE: Just like with the `dpctl collaborator add` command, this expects that the new owner has already registered with DeployPulse using the specified e-mail address._ -Once confirmed, the specified developer becomes the app's owner and immediately receives the permissions associated with that role. Besides the transfer of ownership, nothing else about the app is modified (e.g. deployments, release history, collaborators). This means that you will still be a collaborator of the app, and therefore, if you want to remove yourself, you simply need to run the `dpctl collaborator rm` command after successfully transferring ownership. +This sends a transfer invite to that e-mail address; nothing changes yet. The transfer completes only once the new owner accepts the invite from the e-mail, and the invite expires after 7 days. Until then you remain the owner, and you can send a new invite if it lapses. + +Once accepted, the new owner receives the owner's permissions and you are removed from the app. Deployments, deployment keys, release history and the other collaborators are not modified. If you still need access afterwards, ask the new owner to add you back with `dpctl collaborator add`. ### Deployment Management @@ -674,6 +678,8 @@ dpctl release-expo [--runtimeVersion ] [--exportDir ] [--metadata ] +[--appConfig ] +[--skipAppConfig] ``` `release-expo` is for apps that use `expo-updates` rather than the CodePush SDK. The app must have been created with `--platform expo-v1`. Run it from your project folder: @@ -686,10 +692,13 @@ It does what `eas update` does before uploading: 1. Runs `npx expo export` for iOS and Android, using the Expo CLI installed in your project. 2. Resolves each platform's runtime version from your app config with `npx expo-updates runtimeversion:resolve`, so every runtime version policy works, `fingerprint` included. -3. Uploads each platform as its own release, then removes its temporary files. +3. Reads your public app config with `npx expo config --type public --json` and sends it with the release. Devices read it as `Constants.expoConfig` (and `Constants.easConfig`) once they run the update. +4. Uploads each platform as its own release, then removes its temporary files. Both platforms are exported and packaged before either is uploaded, so a problem with one can't leave the other released on its own. If you run `release-expo` on a CodePush app, dpctl stops before exporting and tells you to use `release-react` instead. +If the server is busy, dpctl prints "Server busy, retrying…" and tries the upload again, waiting as long as the server asks, for up to five minutes. After that it stops with "Server busy, try again later." A busy answer means nothing was released, so running the command again is safe. + #### Deployment name parameter This is the same parameter as the one described in the [above section](#deployment-name-parameter). @@ -723,6 +732,22 @@ Extra metadata to attach to the release, as a JSON string. It is included in the dpctl release-expo MyExpoApp --metadata '{"channel":"beta"}' ``` +#### App config parameters + +Every release carries your app's public config, the same config `eas update` sends. Without it, `Constants.expoConfig` and `Constants.easConfig` are `null` on devices running the update, so anything that reads them breaks: values in `extra`, or the EAS project id `getExpoPushTokenAsync` falls back to. If dpctl can't read the config, it releases nothing. + +`--appConfig ` sends a config saved earlier instead of running `npx expo config`. Use it with `--exportDir` when releasing from outside the project folder: + +```shell +npx expo export --output-dir dist +npx expo config --type public --json > app-config.json +dpctl release-expo MyExpoApp -d Production --exportDir dist --runtimeVersion 1.0.2 --appConfig app-config.json +``` + +`--skipAppConfig` releases without the config, with a warning. Only use it for an app that never reads `Constants.expoConfig` or `Constants.easConfig`. + +Releases made with dpctl 1.2.1 or older have no app config. Release again with 1.2.2 or later to fix devices running them. + ## Debugging DeployPulse Integration Once you've released an update, React Native plugin has been integrated into your app, it can be helpful to diagnose how the plugin is behaving, especially if you run into an issue and want to understand why. In order to debug the DeployPulse update discovery experience, you can run the following command in order to easily view the diagnostic logs produced by the CodePush plugin within your app: @@ -871,6 +896,12 @@ dpctl rollback dpctl rollback MyApp-iOS Production ``` +`rollback` asks "Are you sure?" first. In a script or CI job, where nothing can answer, pass `--yes` (or `-y`); without it the prompt answers no and nothing is rolled back. The same flag works on every command that asks: `app rm`, `app transfer`, `deployment rm`, `deployment clear`, `access-key rm`, `session rm` and `collaborator rm`. + +``` +dpctl rollback MyApp-iOS Production --yes +``` + This has the effect of creating a new release for the deployment that includes the **exact same code and metadata** as the version prior to the latest one. For example, imagine that you released the following updates to your app: | Release | Description | Mandatory | @@ -925,6 +956,11 @@ dpctl rollback MyExpoApp production --toEmbedded That issues the Expo `rollBackToEmbedded` directive. Devices return to the bundle they shipped with until the channel gets a new release, which supersedes the rollback. +A target that is already rolled back is skipped, so running the same rollback again (after a timeout, say) is +safe and exits 0. `rollback` and `promote` exit 1 when any target failed, or was skipped because another +change to the channel was in progress; everything else they did stands, and running the command again retries +the rest. + ## Auto-Rollback Auto-rollback automatically reverts a deployment to its previous release when the error rate on the current release exceeds a configured threshold. This protects your users from bad releases without requiring manual intervention. diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..333da63 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,27 @@ +// ESLint 9 reads only this flat config; it replaces .eslintrc.json, which it ignores. +import tsParser from "@typescript-eslint/parser"; +import tsPlugin from "@typescript-eslint/eslint-plugin"; + +export default [ + { + files: ["script/**/*.ts"], + plugins: { "@typescript-eslint": tsPlugin }, + languageOptions: { + parser: tsParser, + parserOptions: { + ecmaVersion: 2022, + sourceType: "module", + project: "./tsconfig.json", + }, + }, + rules: { + "no-var": "error", + "prefer-const": "error", + // The core rule does not understand TypeScript: it flags enum members and interface fields. + // Unused callback parameters (reject, notify, argv) and catch bindings are this codebase's style. + "@typescript-eslint/no-unused-vars": ["error", { args: "none", caughtErrors: "none", ignoreRestSiblings: true }], + eqeqeq: "error", + "no-eval": "error", + }, + }, +]; diff --git a/package.json b/package.json index 0dd7293..def5443 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@deploypulseio/dpctl", - "version": "1.2.1", + "version": "1.2.2", "description": "Management CLI for the DeployPulse CodePush service", "keywords": [ "deploypulse", diff --git a/script/api-errors.ts b/script/api-errors.ts index 61a8695..3d7f6ff 100644 --- a/script/api-errors.ts +++ b/script/api-errors.ts @@ -1,15 +1,54 @@ /** * The API answers errors with `{"message": "..."}`. Printing the envelope makes every refusal, an * expired key or a read-only key among them, read like a bug in the CLI rather than an answer from - * the server. Anything that is not that shape is passed through untouched. + * the server. Anything that is not that shape is passed through untouched, apart from HTML entities. */ export function messageFromResponseText(text: string): string { if (!text) return text; try { const parsed = JSON.parse(text); - if (parsed && typeof parsed.message === "string" && parsed.message) return parsed.message; + if (parsed && typeof parsed.message === "string" && parsed.message) return decodeHtmlEntities(parsed.message); + const fieldErrors = describeFieldErrors(parsed); + if (fieldErrors) return decodeHtmlEntities(fieldErrors); } catch { /* not JSON */ } - return text; + return decodeHtmlEntities(text); +} + +/** + * The API's request validation answers with a list instead of the envelope: + * `[{"field":"name","message":"Field is invalid"}]`. Read as "The name field is invalid." Null for anything + * that is not such a list, so it is passed through as before. + */ +function describeFieldErrors(parsed: unknown): string | null { + if (!Array.isArray(parsed) || parsed.length === 0) return null; + const sentences: string[] = []; + for (const entry of parsed) { + if (!entry || typeof entry.message !== "string" || !entry.message.trim()) return null; + const message = entry.message.trim(); + const field = typeof entry.field === "string" && entry.field.trim() ? entry.field.trim() : null; + const rest = /^field\s+/i.test(message) ? message.replace(/^field\s+/i, "") : null; + const sentence = field ? (rest ? `The ${field} field ${rest}` : `${field}: ${message}`) : message; + sentences.push(/[.!?]$/.test(sentence) ? sentence : `${sentence}.`); + } + return sentences.join(" "); +} + +const NAMED_ENTITIES: { [name: string]: string } = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" }; + +/** + * The API escapes user-supplied names it echoes into error text (the Expo routes answer in plain text), + * so `a&b` arrives as `a&b`. A terminal is not HTML: print what the user typed. One pass, so + * `&lt;` becomes `<` and not `<`. + */ +export function decodeHtmlEntities(text: string): string { + return text.replace(/&(#x[0-9a-f]+|#[0-9]+|[a-z]+);/gi, (entity: string, body: string): string => { + if (body[0] === "#") { + const code = body[1] === "x" || body[1] === "X" ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10); + return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : entity; + } + const named = NAMED_ENTITIES[body.toLowerCase()]; + return named === undefined ? entity : named; + }); } diff --git a/script/command-executor.ts b/script/command-executor.ts index ce0555b..b1d866b 100644 --- a/script/command-executor.ts +++ b/script/command-executor.ts @@ -27,7 +27,6 @@ import * as Q from "q"; const { rimraf } = require("rimraf"); import * as semver from "semver"; const Table = require("cli-table"); -const which = require("which"); import wordwrap = require("wordwrap"); import * as cli from "../script/types/cli"; import { @@ -95,9 +94,19 @@ export const execSync = childProcess.execSync; let connectionInfo: ILoginConnectionInfo; +// Set from the running command's --yes. Reset for every command, so one command's --yes never answers +// another's prompt. +let assumeYes = false; + export const confirm = (message: string = "Are you sure?"): Promise => { message += " (y/N):"; return Promise((resolve, reject, notify): void => { + if (assumeYes) { + log(chalk.cyan(message) + " yes (--yes)"); + resolve(true); + return; + } + if (!process.stdin.isTTY) { log(chalk.cyan(message) + " no (not a terminal, so nothing was asked). Nothing was changed."); resolve(false); @@ -236,7 +245,6 @@ function appAdd(command: cli.IAppAddCommand): Promise { function appList(command: cli.IAppListCommand): Promise { throwForInvalidOutputFormat(command.format); - let apps: App[]; return sdk.getApps().then((retrievedApps: App[]): void => { printAppList(command.format, retrievedApps); }); @@ -294,6 +302,64 @@ function uploadProgressBar(): (currentProgress: number) => void { const EXPO_PLATFORM_LABELS: { [platform: string]: string } = { ios: "iOS", android: "Android" }; +const EXPO_BUSY_REASON = /another change to this channel is in progress/i; + +function isExpoSkipBusy(entry: { reason: string; busy?: boolean }): boolean { + return entry.busy === true || EXPO_BUSY_REASON.test(entry.reason ?? ""); +} + +/** + * The API answers a rollback where every target was skipped with a plain-text 400: `Nothing was rolled + * back on the "" channel: ios runtime version 1 (); ...`. Only when every reason says + * the target is already where a rollback would put it is that a success. "No earlier release" is a real + * refusal, and failed or busy targets come back as 500 and 409, never here. + */ +function isExpoAlreadyRolledBack(error: CodePushError): boolean { + if (!error || error.statusCode !== 400 || typeof error.message !== "string") return false; + const marker = '" channel: '; + if (!error.message.startsWith("Nothing was rolled back") || error.message.indexOf(marker) < 0) return false; + const reasons = (error.message.slice(error.message.indexOf(marker) + marker.length).match(/\(([^()]*)\)/g) ?? []).map((r: string) => + r.slice(1, -1).trim() + ); + return reasons.length > 0 && reasons.every((reason: string) => /^already (rolled back|serving)\b/i.test(reason)); +} + +/** + * Prints what an Expo rollback or promote skipped, then fails the command when any of it FAILED or was BUSY. + * + * The API answers 201 even when some targets failed, reporting each in `skipped` with `failed: true` + * (it was left unchanged). A plain skip ("nothing to roll back") is not an error, but a failure is, + * and exiting 0 on it let a script carry on as if everything had moved. So is a target skipped because + * another change to the channel was in progress (`busy: true`, or that reason from an API that predates + * the flag): nothing moved, and a retry is what moves it. + */ +function reportExpoSkipped(skipped: Array<{ platform: string; runtimeVersion: string; reason: string; failed?: boolean; busy?: boolean }>, done: string): void { + skipped.forEach((entry) => { + log( + chalk.yellow( + `Skipped ${EXPO_PLATFORM_LABELS[entry.platform] ?? entry.platform} (runtime version ${entry.runtimeVersion}): ${entry.reason}.` + ) + ); + }); + log(done); + const busy = skipped.filter((entry) => entry.failed !== true && isExpoSkipBusy(entry)).length; + const failed = skipped.filter((entry) => entry.failed === true).length; + const targets = (n: number) => `${n} target${n === 1 ? "" : "s"}`; + if (failed && busy) { + throw new Error( + `${targets(failed)} failed and ${targets(busy)} ${busy === 1 ? "was" : "were"} busy with another change; all were left unchanged. Run the command again to retry.` + ); + } + if (failed) { + throw new Error(`${targets(failed)} failed and ${failed === 1 ? "was" : "were"} left unchanged. Run the command again to retry.`); + } + if (busy) { + throw new Error( + `${targets(busy)} ${busy === 1 ? "was" : "were"} left unchanged because another change to the channel was in progress. Run the command again to retry.` + ); + } +} + // Copies what the source channel serves onto the destination. CodePush-only options are refused rather // than silently ignored: an Expo release has no mandatory flag or binary version. function promoteExpo(command: cli.IPromoteCommand): Promise { @@ -328,14 +394,7 @@ function promoteExpo(command: cli.IPromoteCommand): Promise { `"${command.destDeploymentName}" on "${command.appName}".` ); }); - result.skipped.forEach((entry) => { - log( - chalk.yellow( - `Skipped ${EXPO_PLATFORM_LABELS[entry.platform] ?? entry.platform} (runtime version ${entry.runtimeVersion}): ${entry.reason}.` - ) - ); - }); - log("Devices pick this up on their next update check."); + reportExpoSkipped(result.skipped, "Devices pick this up on their next update check."); })() ); } @@ -403,30 +462,66 @@ function rollbackExpo(command: cli.IRollbackCommand): Promise { if (!deployment || !deployment.key) { throw new Error(`Could not read the key of the "${command.deploymentName}" channel.`); } - const result = await sdk.rollbackExpo(deployment.key, { - platform: command.platform, - runtimeVersion: command.runtimeVersion, - toEmbedded: command.toEmbedded, - }); + let result: Awaited>; + try { + result = await sdk.rollbackExpo(deployment.key, { + platform: command.platform, + runtimeVersion: command.runtimeVersion, + toEmbedded: command.toEmbedded, + }); + } catch (error: any) { + // A retry after a rollback that did land (a timeout, a dropped connection) finds every target + // already rolled back. That is the state the user asked for, so it is not a failure. + if (!isExpoAlreadyRolledBack(error)) throw error; + log(chalk.yellow(error.message)); + log("Nothing to do: the channel already serves what this rollback asked for."); + return; + } result.rollbacks.forEach((entry) => { const where = `${EXPO_PLATFORM_LABELS[entry.platform] ?? entry.platform} (runtime version ${entry.runtimeVersion})`; const how = entry.mode === "embedded" ? "back to the embedded bundle" : "back to the previous release"; log(`Rolled ${where} ${how} on the "${command.deploymentName}" channel of "${command.appName}".`); }); - result.skipped.forEach((entry) => { - log( - chalk.yellow( - `Skipped ${EXPO_PLATFORM_LABELS[entry.platform] ?? entry.platform} (runtime version ${entry.runtimeVersion}): ${entry.reason}.` - ) - ); - }); - log("Devices pick this up on their next update check."); + reportExpoSkipped(result.skipped, "Devices pick this up on their next update check."); })() ); }); } +/** + * The app config sent with an Expo release, which devices read as Constants.expoConfig (and + * Constants.easConfig, from its extra.eas) once they run the update. Without it both are null, so an app + * that reads its settings or its EAS project id from there breaks after updating: that is why a config that + * can't be read fails the release unless --skipAppConfig says otherwise. + */ +async function releaseAppConfig(command: cli.IReleaseExpoCommand, projectRoot: string): globalThis.Promise<{ [key: string]: any } | undefined> { + if (command.appConfig) { + return expoUtils.readAppConfigFile(path.resolve(command.appConfig)); + } + if (command.skipAppConfig) { + log( + chalk.yellow( + "[Warning] Releasing without the app config (--skipAppConfig). Devices that run this update get null for " + + "Constants.expoConfig and Constants.easConfig, so anything that reads them (extra values, the EAS project id " + + "for push tokens) breaks until a release with the config replaces it." + ) + ); + return undefined; + } + log(chalk.cyan(`Reading the app config with "npx expo config --type public --json".\n`)); + try { + return await expoUtils.readPublicAppConfig(projectRoot); + } catch (error: any) { + throw new Error( + `Could not read your app config: ${error?.message ?? error}\n` + + "Devices read it as Constants.expoConfig once they run the update, so nothing was released. Run dpctl from your " + + 'Expo project folder, pass --appConfig with a file saved from "npx expo config --type public --json", or pass ' + + "--skipAppConfig to release without it." + ); + } +} + export const releaseExpo = (command: cli.IReleaseExpoCommand): Promise => { const projectRoot = process.cwd(); const platforms: string[] = command.platform ? [command.platform] : ["ios", "android"]; @@ -471,6 +566,8 @@ export const releaseExpo = (command: cli.IReleaseExpoCommand): Promise => prepared.push({ platform, runtimeVersion, zip }); } + const appConfig = await releaseAppConfig(command, projectRoot); + const released: string[] = []; for (const { platform, runtimeVersion, zip } of prepared) { const label = EXPO_PLATFORM_LABELS[platform]; @@ -481,7 +578,23 @@ export const releaseExpo = (command: cli.IReleaseExpoCommand): Promise => ) ); try { - await sdk.releaseExpo(deployment.key, zip.zipPath, platform, runtimeVersion, metadata, command.rollout, command.description, uploadProgressBar()); + // A busy server is retried by the SDK; each try sends the whole upload again, so it gets a new bar. + let progressBar = uploadProgressBar(); + await sdk.releaseExpo( + deployment.key, + zip.zipPath, + platform, + runtimeVersion, + metadata, + command.rollout, + command.description, + (progress: number) => progressBar(progress), + appConfig, + () => { + log(chalk.yellow("\nServer busy, retrying…")); + progressBar = uploadProgressBar(); + } + ); } catch (error: any) { if (released.length && error) { error.message = `${released.join(" and ")} was released, but ${label} failed: ${error.message}`; @@ -553,9 +666,11 @@ function appTransfer(command: cli.IAppTransferCommand): Promise { return confirm().then((wasConfirmed: boolean): Promise => { if (wasConfirmed) { + // The server answers 202: an emailed invite, not a finished transfer. Nothing moves until they accept. 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 + '".' + `Transfer invite for app "${command.appName}" sent to ${command.email}. ` + + "The transfer completes once they accept it from the email (invites expire after 7 days)." ); }); } @@ -901,6 +1016,7 @@ export const deserializeConnectionInfo = (): ILoginConnectionInfo => { export function execute(command: cli.ICommand) { connectionInfo = deserializeConnectionInfo(); + assumeYes = command.yes === true; return Q(null).then(() => { switch (command.type) { @@ -1098,11 +1214,22 @@ function initiateExternalAuthenticationAsync(action: string): void { opener(url); } +// A 401 from /authenticated is usually a bad key, but the API also says why when it knows ("This account +// has been deleted."), and that reason is what the user needs to see. +function loginAuthError(error: CodePushError): never { + if (error && error.statusCode === AccountManager.ERROR_UNAUTHORIZED) { + const message = (error.message || "").trim(); + const generic = !message || message === "Unauthorized" || message.startsWith("{"); + throw new Error(generic ? "Invalid access key." : message); + } + throw error; +} + function login(command: cli.ILoginCommand): Promise { // Check if one of the flags were provided. if (command.accessKey) { sdk = getSdk(command.accessKey, CLI_HEADERS); - return sdk.isAuthenticated().then((isAuthenticated: boolean): Promise => { + return sdk.isAuthenticated(/*throwIfUnauthorized*/ true, /*isLogin*/ true).catch(loginAuthError).then((isAuthenticated: boolean): Promise => { if (!isAuthenticated) { throw new Error("Invalid access key."); } @@ -1127,7 +1254,7 @@ function loginWithExternalAuthentication(action: string, command?: cli.ICommand) sdk = getSdk(accessKey, CLI_HEADERS); - return sdk.isAuthenticated().then((isAuthenticated: boolean): Promise => { + return sdk.isAuthenticated(/*throwIfUnauthorized*/ true, /*isLogin*/ true).catch(loginAuthError).then((isAuthenticated: boolean): Promise => { if (!isAuthenticated) { throw new Error("Invalid access key."); } @@ -1571,11 +1698,20 @@ function printJson(object: any): void { log(JSON.stringify(object, /*replacer=*/ null, /*spacing=*/ 2)); } +// "Never" when the server says the key has not been used (null), blank when it does not report it at all +// (an older server that sends no lastUsedAt), so a missing field is never shown as "Never". +function formatLastUsed(lastUsedAt: string | null | undefined): string { + if (lastUsedAt === null) return "Never"; + if (!lastUsedAt) return ""; + const time = Date.parse(lastUsedAt); + return Number.isNaN(time) ? "" : formatDate(time); +} + function printAccessKeys(format: string, keys: AccessKey[]): void { if (format === "json") { printJson(keys); } else if (format === "table") { - printTable(["Name", "Created", "Expires", "Scope"], (dataSource: any[]): void => { + printTable(["Name", "Created", "Expires", "Last used", "Scope"], (dataSource: any[]): void => { const now = new Date().getTime(); function isExpired(key: AccessKey): boolean { @@ -1583,7 +1719,7 @@ function printAccessKeys(format: string, keys: AccessKey[]): void { } function keyToTableRow(key: AccessKey, dim: boolean): string[] { - const row: string[] = [key.name, key.createdTime ? formatDate(key.createdTime) : "", formatDate(key.expires), formatKeyScope(key.scopes, key.appNames)]; + const row: string[] = [key.name, key.createdTime ? formatDate(key.createdTime) : "", formatDate(key.expires), formatLastUsed(key.lastUsedAt), formatKeyScope(key.scopes, key.appNames)]; if (dim) { row.forEach((col: string, index: number) => { @@ -1604,8 +1740,10 @@ 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)])); + printTable(["Machine", "IP", "Logged in"], (dataSource: any[]): void => { + sessions.forEach((session: Session) => + dataSource.push([session.machineName, session.ips && session.ips.length ? session.ips.join(", ") : "", formatDate(session.loggedInTime)]) + ); }); } } @@ -1965,6 +2103,8 @@ export const bundleReact = (command: cli.IBundleReactCommand): Promise => const baseDir = path.dirname(outputFolder); const zipFile = new yazl.ZipFile(); const writeStream = fs.createWriteStream(outputZipPath); + // yazl reports an unreadable input on the ZipFile, not on its output stream. + zipFile.on("error", reject); zipFile.outputStream.pipe(writeStream).on("error", reject).on("close", resolve); for (const file of files) { zipFile.addFile(file, slash(path.relative(baseDir, file))); diff --git a/script/command-parser.ts b/script/command-parser.ts index d588522..0b8290b 100644 --- a/script/command-parser.ts +++ b/script/command-parser.ts @@ -14,8 +14,6 @@ const packageJson = require( const ROLLOUT_PERCENTAGE_REGEX: RegExp = /^(100|[1-9][0-9]|[1-9])%?$/; const USAGE_PREFIX = "Usage: dpctl"; -// Command categories are: access-key, app, release, deployment, deployment-key, login, logout -let isValidCommandCategory = false; // Commands are the verb following the command category (e.g.: "add" in "app add"). let isValidCommand = false; let lastFailMessage: string | undefined; @@ -138,6 +136,18 @@ function accessKeyList(commandName: string, yargs: yargs.Argv): void { addCommonConfiguration(yargs); } +// Every command that asks "Are you sure?" takes --yes, so a script or CI job can answer it. Without a +// terminal and without --yes, the prompt answers no and nothing changes. +function addYesOption(yargs: yargs.Argv): void { + yargs.option("yes", { + alias: "y", + default: false, + demand: false, + description: 'Answer yes to the "Are you sure?" prompt, for scripts and CI', + type: "boolean", + }); +} + function accessKeyRemove(commandName: string, yargs: yargs.Argv): void { isValidCommand = true; yargs @@ -145,6 +155,7 @@ function accessKeyRemove(commandName: string, yargs: yargs.Argv): void { .demand(/*count*/ 1, /*max*/ 1) // Require exactly one non-option arguments .example("access-key " + commandName + ' "VSTS Integration"', 'Removes the "VSTS Integration" access key'); + addYesOption(yargs); addCommonConfiguration(yargs); } @@ -216,6 +227,7 @@ function appRemove(commandName: string, yargs: yargs.Argv): void { .demand(/*count*/ 1, /*max*/ 1) // Require exactly one non-option arguments .example("app " + commandName + " MyApp", 'Removes app "MyApp"'); + addYesOption(yargs); addCommonConfiguration(yargs); } @@ -243,6 +255,7 @@ function removeCollaborator(commandName: string, yargs: yargs.Argv): void { .demand(/*count*/ 2, /*max*/ 2) // Require exactly two non-option arguments .example("collaborator " + commandName + " MyApp foo@bar.com", 'Removes foo@bar.com as a collaborator from app "MyApp"'); + addYesOption(yargs); addCommonConfiguration(yargs); } @@ -270,6 +283,7 @@ function sessionRemove(commandName: string, yargs: yargs.Argv): void { .demand(/*count*/ 1, /*max*/ 1) // Require exactly one non-option arguments .example("session " + commandName + ' "John\'s PC"', 'Removes the existing login session from "John\'s PC"'); + addYesOption(yargs); addCommonConfiguration(yargs); } @@ -283,6 +297,7 @@ function deploymentHistoryClear(commandName: string, yargs: yargs.Argv): void { 'Clears the release history associated with deployment "MyDeployment" from app "MyApp"' ); + addYesOption(yargs); addCommonConfiguration(yargs); } @@ -316,6 +331,7 @@ function deploymentRemove(commandName: string, yargs: yargs.Argv): void { .demand(/*count*/ 2, /*max*/ 2) // Require exactly two non-option arguments .example("deployment " + commandName + " MyApp MyDeployment", 'Removes deployment "MyDeployment" from app "MyApp"'); + addYesOption(yargs); addCommonConfiguration(yargs); } @@ -407,7 +423,6 @@ yargs .usage(USAGE_PREFIX + " ") .demand(/*count*/ 1, /*max*/ 1) // Require exactly one non-option argument. .command("access-key", "View and manage the access keys associated with your account", (yargs: yargs.Argv) => { - isValidCommandCategory = true; yargs .usage(USAGE_PREFIX + " access-key ") .demand(/*count*/ 2, /*max*/ 2) // Require exactly two non-option arguments. @@ -422,7 +437,6 @@ yargs addCommonConfiguration(yargs); }) .command("app", "View and manage your DeployPulse apps", (yargs: yargs.Argv) => { - isValidCommandCategory = true; yargs .usage(USAGE_PREFIX + " app ") .demand(/*count*/ 2, /*max*/ 2) // Require exactly two non-option arguments. @@ -473,14 +487,15 @@ yargs addCommonConfiguration(yargs); }) - .command("transfer", "Transfer the ownership of an app to another account", (yargs: yargs.Argv) => { + .command("transfer", "Invite another account to take ownership of an app (they accept from the email)", (yargs: yargs.Argv) => { // Required, or the category check below rejects every invocation. isValidCommand = true; yargs .usage(USAGE_PREFIX + " app transfer ") .demand(/*count*/ 2, /*max*/ 2) // Require exactly two non-option arguments - .example("app transfer MyApp foo@bar.com", 'Transfers the ownership of app "MyApp" to an account with email "foo@bar.com"'); + .example("app transfer MyApp foo@bar.com", 'Emails "foo@bar.com" an invite to take ownership of app "MyApp"'); + addYesOption(yargs); addCommonConfiguration(yargs); }) .check((argv: any, aliases: { [aliases: string]: string }): any => isValidCommand); // Report unrecognized, non-hyphenated command category. @@ -488,7 +503,6 @@ yargs addCommonConfiguration(yargs); }) .command("collaborator", "View and manage app collaborators", (yargs: yargs.Argv) => { - isValidCommandCategory = true; yargs .usage(USAGE_PREFIX + " collaborator ") .demand(/*count*/ 2, /*max*/ 2) // Require exactly two non-option arguments. @@ -510,7 +524,6 @@ yargs addCommonConfiguration(yargs); }) .command("debug", "View the DeployPulse debug logs for a running app", (yargs: yargs.Argv) => { - isValidCommandCategory = true; isValidCommand = true; yargs .usage(USAGE_PREFIX + " debug ") @@ -521,7 +534,6 @@ yargs addCommonConfiguration(yargs); }) .command("deployment", "View and manage your app deployments", (yargs: yargs.Argv) => { - isValidCommandCategory = true; yargs .usage(USAGE_PREFIX + " deployment ") .demand(/*count*/ 2, /*max*/ 2) // Require exactly two non-option arguments. @@ -557,7 +569,6 @@ yargs .command("history", "Display the release history for a deployment", (yargs: yargs.Argv) => deploymentHistory("history", yargs)) .command("h", "Display the release history for a deployment", (yargs: yargs.Argv) => deploymentHistory("h", yargs)) .command("auto-rollback", "Manage auto-rollback configuration for a deployment", (yargs: yargs.Argv) => { - isValidCommandCategory = true; yargs .usage(USAGE_PREFIX + " deployment auto-rollback ") .demand(/*count*/ 1, /*max*/ 1) @@ -614,7 +625,6 @@ yargs addCommonConfiguration(yargs); }) .command("login", "Authenticate with the DeployPulse API in order to begin managing your apps", (yargs: yargs.Argv) => { - isValidCommandCategory = true; isValidCommand = true; yargs .usage(USAGE_PREFIX + " login [options]") @@ -634,7 +644,6 @@ yargs addCommonConfiguration(yargs); }) .command("logout", "Log out of the current session", (yargs: yargs.Argv) => { - isValidCommandCategory = true; isValidCommand = true; yargs .usage(USAGE_PREFIX + " logout") @@ -780,6 +789,18 @@ yargs description: "Extra metadata to attach to the release, as a JSON string", type: "string", }) + .option("appConfig", { + demand: false, + description: + 'File holding the output of "npx expo config --type public --json", used instead of running it. For releasing an --exportDir from outside the project', + type: "string", + }) + .option("skipAppConfig", { + default: false, + demand: false, + description: "Release without the app config. Devices running the update then get null for Constants.expoConfig", + type: "boolean", + }) // No -r alias: on this command -r is --runtimeVersion. .option("rollout", { demand: false, @@ -843,10 +864,10 @@ yargs type: "boolean", }); + addYesOption(yargs); addCommonConfiguration(yargs); }) .command("org", "View and switch the organization your commands run against", (yargs: yargs.Argv) => { - isValidCommandCategory = true; yargs .usage(USAGE_PREFIX + " org ") .demand(/*count*/ 2, /*max*/ 3) @@ -1255,7 +1276,6 @@ yargs addCommonConfiguration(yargs); }) .command("webhook", "View and manage webhooks for your account", (yargs: yargs.Argv) => { - isValidCommandCategory = true; yargs .usage(USAGE_PREFIX + " webhook ") .demand(/*count*/ 2, /*max*/ 2) @@ -1361,7 +1381,6 @@ yargs addCommonConfiguration(yargs); }) .command("session", "View and manage the current login sessions associated with your account", (yargs: yargs.Argv) => { - isValidCommandCategory = true; yargs .usage(USAGE_PREFIX + " session ") .demand(/*count*/ 2, /*max*/ 2) // Require exactly two non-option arguments. @@ -1376,7 +1395,6 @@ yargs addCommonConfiguration(yargs); }) .command("whoami", "Display the account info for the current login session", (yargs: yargs.Argv) => { - isValidCommandCategory = true; isValidCommand = true; yargs .usage(USAGE_PREFIX + " whoami") @@ -1834,6 +1852,8 @@ export function createCommand(): cli.ICommand { releaseExpoCommand.description = argv["description"] ? backslash(String(argv["description"])) : undefined; releaseExpoCommand.metadata = argv["metadata"] as any; releaseExpoCommand.rollout = getRolloutValue(argv["rollout"] as any); + releaseExpoCommand.appConfig = argv["appConfig"] ? String(argv["appConfig"]) : undefined; + releaseExpoCommand.skipAppConfig = argv["skipAppConfig"] === true; } break; @@ -1958,6 +1978,9 @@ export function createCommand(): cli.ICommand { } // --org applies to every command, so it is read once here rather than in each case above. + if (cmd && argv["yes"] === true) { + cmd.yes = true; + } if (cmd && argv["org"]) { cmd.org = String(argv["org"]); } @@ -1975,10 +1998,6 @@ function isValidRollout(args: any): boolean { return true; } -function checkValidReleaseOptions(args: any): boolean { - return isValidRollout(args) && !!args["deploymentName"]; -} - function getRolloutValue(input: string): number { return input ? parseInt(input.replace("%", "")) : null; } diff --git a/script/commands/debug.ts b/script/commands/debug.ts index efa5dc3..978663b 100644 --- a/script/commands/debug.ts +++ b/script/commands/debug.ts @@ -48,7 +48,7 @@ class AndroidDebugPlatform implements IDebugPlatform { private getNumberOfAvailableDevices(): number { const output = childProcess.execSync("adb devices").toString(); const matches = output.match(/\b(device)\b/gim); - if (matches != null) { + if (matches !== null) { return matches.length; } return 0; diff --git a/script/expo-utils.ts b/script/expo-utils.ts index e4e18ae..3718162 100644 --- a/script/expo-utils.ts +++ b/script/expo-utils.ts @@ -107,6 +107,82 @@ export function resolveRuntimeVersion(projectRoot: string, platform: string): Pr }); } +/** + * The server's cap on the app config, serialized (MAX_APP_CONFIG_BYTES in the API). Checked here too, so + * an oversized config fails before the bundle is uploaded rather than after. + */ +export const MAX_APP_CONFIG_BYTES = 256 * 1024; + +const APP_CONFIG_COMMAND = "npx expo config --type public --json"; + +/** The app config from `expo config` output or a file: one JSON object, under the server's cap. */ +export function parseAppConfig(text: string, source: string): { [key: string]: any } { + const trimmed = (text || "").trim(); + let config: any; + try { + config = JSON.parse(trimmed); + } catch { + // A config plugin can print a warning before the JSON line; take the last line that looks like it. + const jsonLine = trimmed + .split(/\r?\n/) + .reverse() + .find((line: string) => line.trim().startsWith("{")); + try { + config = jsonLine ? JSON.parse(jsonLine) : undefined; + } catch { + config = undefined; + } + } + if (!config || typeof config !== "object" || Array.isArray(config)) { + throw new Error(`${source} did not give a JSON object: ${trimmed.slice(0, 200) || "(nothing)"}`); + } + if (Buffer.byteLength(JSON.stringify(config), "utf8") > MAX_APP_CONFIG_BYTES) { + throw new Error(`Your app config is larger than ${MAX_APP_CONFIG_BYTES / 1024} KB, the most a release can carry. Move large values out of it.`); + } + return config; +} + +/** + * The app's PUBLIC config, as `npx expo config --type public --json` prints it: the same config expo-constants + * embeds in a build, and what EAS Update and Expo's reference server send with every update. Devices read it as + * Constants.expoConfig once they run the update, so a release without it leaves that null. + * + * One config for both platforms: `expo config` has no platform option, and EAS and the reference server serve + * one config per update. Run with the same environment as the export, so an app.config.js that reads + * environment variables resolves the same values. + */ +export function readPublicAppConfig(projectRoot: string): Promise<{ [key: string]: any }> { + return new Promise((resolve, reject) => { + const child = spawnNpx(["expo", "config", "--type", "public", "--json"], projectRoot, /*captureOutput*/ true); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk: Buffer) => (stdout += chunk.toString())); + child.stderr.on("data", (chunk: Buffer) => (stderr += chunk.toString())); + child.on("error", (err: Error) => reject(new Error(`Could not run "${APP_CONFIG_COMMAND}": ${err.message}`))); + child.on("close", (code: number) => { + if (code !== 0) { + return reject(new Error(`"${APP_CONFIG_COMMAND}" exited with code ${code}.` + (stderr.trim() ? `\n${stderr.trim()}` : ""))); + } + try { + resolve(parseAppConfig(stdout, `"${APP_CONFIG_COMMAND}"`)); + } catch (err) { + reject(err); + } + }); + }); +} + +/** An app config saved earlier with `npx expo config --type public --json > file`, for --appConfig. */ +export function readAppConfigFile(file: string): { [key: string]: any } { + let text: string; + try { + text = fs.readFileSync(file, "utf8"); + } catch (err: any) { + throw new Error(`Could not read the app config file "${file}": ${err?.message ?? err}`); + } + return parseAppConfig(text, `"${file}"`); +} + export function readExportMetadata(exportDir: string): ExpoExportMetadata { const file = path.join(exportDir, "metadata.json"); let metadata: any; @@ -148,6 +224,8 @@ export function zipPlatformExport(exportDir: string, metadata: ExpoExportMetadat // does not forward source errors, so without this an error from yazl itself (a file that changed or // vanished between the existence sweep and the read) is unhandled: the process dies with a raw // stack, this promise never settles, and the caller's cleanup never runs. + // yazl 3 emits a failed stat or read of an input on the ZipFile itself, so that needs a handler too. + zip.on("error", reject); zip.outputStream.on("error", reject); zip.outputStream .pipe(fs.createWriteStream(zipPath)) diff --git a/script/management-sdk.ts b/script/management-sdk.ts index 2512558..d847f10 100644 --- a/script/management-sdk.ts +++ b/script/management-sdk.ts @@ -47,25 +47,67 @@ interface PackageFile { path: string; } -// A template string tag function that URL encodes the substituted values -function urlEncode(strings: string[], ...values: string[]): string { +// Template tag that encodes each interpolated value as a single path segment. A value that is, or +// decodes to, "." or ".." is refused: URL parsing collapses dot segments even when percent-encoded, +// so the request would address the parent resource instead. +function urlEncode(strings: TemplateStringsArray, ...values: unknown[]): string { let result = ""; for (let i = 0; i < strings.length; i++) { result += strings[i]; if (i < values.length) { - result += encodeURIComponent(values[i]); + const value = String(values[i]); + if (isDotSegment(value)) { + throw new Error( + `The name "${value}" cannot be used in a request URL, so dpctl cannot address it. Contact support@deploypulse.io to rename or remove it.` + ); + } + result += encodeURIComponent(value); } } return result; } +// Deletes a temporary package. A file that is already gone, or cannot be removed, must not turn a +// finished upload (or the error being reported) into a crash. +function removeQuietly(filePath: string): void { + try { + fs.unlinkSync(filePath); + } catch (e) {} +} + +/** + * How long `releaseExpo` keeps retrying while the server says it is busy (a 503, or a 429 that says when to + * come back), and how long it waits between tries when the server does not say. A waiting time comes with + * up to a fifth more at random, so many uploads turned away at once do not all return at once. + */ +const EXPO_BUSY_BUDGET_MS = 5 * 60 * 1000; +const EXPO_BUSY_BACKOFF_MS = [2_000, 4_000, 8_000, 15_000, 30_000]; +const EXPO_BUSY_GIVE_UP_MESSAGE = "Server busy, try again later."; + +/** Retry-After in milliseconds: delay-seconds or an HTTP date. Null when absent or unreadable. */ +function retryAfterMs(value: unknown, now: number): number | null { + if (typeof value !== "string" || !value.trim()) return null; + if (/^\d+$/.test(value.trim())) return parseInt(value.trim(), 10) * 1000; + const date = Date.parse(value); + return isNaN(date) ? null : Math.max(0, date - now); +} + +function isDotSegment(value: string): boolean { + const candidates = [value.trim()]; + try { + candidates.push(decodeURIComponent(value).trim()); + } catch (e) {} + return candidates.some((candidate) => candidate === "." || candidate === ".."); +} + class AccountManager { public static AppPermission = { OWNER: "Owner", COLLABORATOR: "Collaborator", }; public static SERVER_URL = "https://api.deploypulse.io"; + public static EXPO_BUSY_GIVE_UP_MESSAGE = EXPO_BUSY_GIVE_UP_MESSAGE; private static API_VERSION: number = 2; @@ -77,6 +119,11 @@ class AccountManager { private _accessKey: string; private _orgId: string | null = null; + // Replaced in tests, so a retry schedule can run without the wait. + private _clock = { + now: (): number => Date.now(), + sleep: (ms: number): Promise => Promise((resolve) => setTimeout(() => resolve(null), ms)), + }; private _serverUrl: string; private _customHeaders: Headers; @@ -98,10 +145,16 @@ class AccountManager { return this._accessKey; } - public isAuthenticated(throwIfUnauthorized?: boolean): Promise { + /** + * `isLogin` marks the call as `dpctl login` (X-DeployPulse-Login: 1); every other caller sends 0. The API + * records a CLI login in Recent Logins only for a 1. A dpctl that sends no marker at all is an older one, + * which the API records at most once a day per key, since it cannot tell a login from a release. + */ + public isAuthenticated(throwIfUnauthorized?: boolean, isLogin?: boolean): Promise { return Promise((resolve, reject, notify) => { - const request: superagent.Request = superagent.get(`${this._serverUrl}${urlEncode(["/authenticated"])}`); + const request: superagent.Request = superagent.get(`${this._serverUrl}${urlEncode`/authenticated`}`); this.attachCredentials(request); + request.set("X-DeployPulse-Login", isLogin ? "1" : "0"); request.end((err: any, res: superagent.Response) => { const status: number = this.getErrorStatus(err, res); @@ -136,7 +189,7 @@ class AccountManager { if (scopes && scopes.length) accessKeyRequest.scopes = scopes; if (appIds && appIds.length) accessKeyRequest.appIds = appIds; - return this.post(urlEncode(["/accessKeys/"]), JSON.stringify(accessKeyRequest), /*expectResponseBody=*/ true).then( + return this.post(urlEncode`/accessKeys/`, JSON.stringify(accessKeyRequest), /*expectResponseBody=*/ true).then( (response: JsonResponse) => { return { createdTime: response.body.accessKey.createdTime, @@ -149,7 +202,7 @@ class AccountManager { } public getAccessKey(accessKeyName: string): Promise { - return this.get(urlEncode([`/accessKeys/${accessKeyName}`])).then((res: JsonResponse) => { + return this.get(urlEncode`/accessKeys/${accessKeyName}`).then((res: JsonResponse) => { return { createdTime: res.body.accessKey.createdTime, expires: res.body.accessKey.expires, @@ -164,11 +217,16 @@ class AccountManager { } public getOrgs(): Promise { - return this.get(urlEncode(["/orgs"])).then((res: JsonResponse) => res.body.orgs as Org[]); + return this.get(urlEncode`/orgs`).then((res: JsonResponse) => res.body.orgs as Org[]); } // The Expo release endpoint sits beside the manifest routes, not under /apps, and is addressed by // deployment key, so callers look the key up first. + // + // A busy server (503, or 429 with Retry-After) is retried for up to EXPO_BUSY_BUDGET_MS, waiting as long + // as Retry-After asks or backing off when it does not; `onBusy` is called before each wait. Past the budget + // it rejects with EXPO_BUSY_GIVE_UP_MESSAGE. The upload is sent whole each time, which is safe: a busy + // answer means nothing was released. public releaseExpo( deploymentKey: string, zipFilePath: string, @@ -177,9 +235,40 @@ class AccountManager { metadata?: object, rollout?: number, description?: string, - uploadProgressCallback?: (progress: number) => void + uploadProgressCallback?: (progress: number) => void, + appConfig?: object, + onBusy?: (waitMs: number) => void ): Promise { - const url = `${this._serverUrl}/expo/v1/${encodeURIComponent(deploymentKey)}/release`; + const started = this._clock.now(); + const attempt = (tries: number): Promise => + this.releaseExpoOnce(deploymentKey, zipFilePath, platform, runtimeVersion, metadata, rollout, description, uploadProgressCallback, appConfig).then( + (busy: ExpoBusy | null): Promise => { + if (!busy) return Q(null); + const backoff = EXPO_BUSY_BACKOFF_MS[Math.min(tries, EXPO_BUSY_BACKOFF_MS.length - 1)]; + const waitMs = busy.retryAfterMs ?? Math.round(backoff * (1 + Math.random() / 5)); + if (this._clock.now() + waitMs - started > EXPO_BUSY_BUDGET_MS) { + throw { message: EXPO_BUSY_GIVE_UP_MESSAGE, statusCode: busy.statusCode }; + } + if (onBusy) onBusy(waitMs); + return this._clock.sleep(waitMs).then(() => attempt(tries + 1)); + } + ); + return attempt(0); + } + + /** One upload. Resolves null once released, or with how long to wait when the server is busy. */ + private releaseExpoOnce( + deploymentKey: string, + zipFilePath: string, + platform: string, + runtimeVersion: string, + metadata?: object, + rollout?: number, + description?: string, + uploadProgressCallback?: (progress: number) => void, + appConfig?: object + ): Promise { + const url = this._serverUrl + urlEncode`/expo/v1/${deploymentKey}/release`; const releaseInfo = JSON.stringify({ platform, runtimeVersion, @@ -188,11 +277,15 @@ class AccountManager { ...(description ? { description } : {}), }); - return Promise((resolve, reject) => { + return Promise((resolve, reject) => { const request: superagent.Request = superagent .post(url) .attach("bundle", fs.createReadStream(zipFilePath), "bundle.zip") .field("releaseInfo", releaseInfo); + // The app's public config, served to devices as the manifest's extra.expoClient (Constants.expoConfig). + // Its own field rather than a file in the zip: the server checks and stores it apart from the bundle. + // An API from before 1.2.2 ignores the field. + if (appConfig) request.field("appConfig", JSON.stringify(appConfig)); this.attachCredentials(request); @@ -203,6 +296,13 @@ class AccountManager { } }) .end((err: any, res: superagent.Response) => { + const status = res ? res.status : err && err.status; + const header = (res && res.header) || (err && err.response && err.response.header) || {}; + const retryAfter = retryAfterMs(header["retry-after"], this._clock.now()); + if (status === 503 || (status === 429 && retryAfter !== null)) { + resolve({ retryAfterMs: retryAfter, statusCode: status }); + return; + } if (err && !res) { reject(this.getCodePushError(err, res)); return; @@ -211,7 +311,7 @@ class AccountManager { reject(expoRouteError(res)); return; } - resolve(null); + resolve(null); }); }); } @@ -221,7 +321,7 @@ class AccountManager { deploymentKey: string, rollout: number ): Promise> { - const url = `${this._serverUrl}/expo/v1/${encodeURIComponent(deploymentKey)}/release`; + const url = this._serverUrl + urlEncode`/expo/v1/${deploymentKey}/release`; return Promise>((resolve, reject) => { const request: superagent.Request = superagent.patch(url).set("Content-Type", "application/json").send({ rollout }); @@ -251,9 +351,9 @@ class AccountManager { options: { platform?: string; runtimeVersion?: string; toEmbedded?: boolean } = {} ): Promise<{ rollbacks: Array<{ platform: string; runtimeVersion: string; mode: string; releaseId: string }>; - skipped: Array<{ platform: string; runtimeVersion: string; reason: string }>; + skipped: Array<{ platform: string; runtimeVersion: string; reason: string; failed?: boolean; busy?: boolean }>; }> { - const url = `${this._serverUrl}/expo/v1/${encodeURIComponent(deploymentKey)}/rollback`; + const url = this._serverUrl + urlEncode`/expo/v1/${deploymentKey}/rollback`; const body: Record = {}; if (options.platform) body.platform = options.platform; if (options.runtimeVersion) body.runtimeVersion = options.runtimeVersion; @@ -289,9 +389,9 @@ class AccountManager { options: { platform?: string; rollout?: number } = {} ): Promise<{ promotions: Array<{ platform: string; runtimeVersion: string; label: string | null }>; - skipped: Array<{ platform: string; runtimeVersion: string; reason: string }>; + skipped: Array<{ platform: string; runtimeVersion: string; reason: string; failed?: boolean; busy?: boolean }>; }> { - const url = `${this._serverUrl}/expo/v1/${encodeURIComponent(deploymentKey)}/promote`; + const url = this._serverUrl + urlEncode`/expo/v1/${deploymentKey}/promote`; const body: Record = { to }; if (options.platform) body.platform = options.platform; if (options.rollout) body.rollout = options.rollout; @@ -336,7 +436,7 @@ class AccountManager { } public getWebhooks(): Promise { - return this.get(urlEncode(["/webhooks"])).then((res: JsonResponse) => res.body.webhooks); + return this.get(urlEncode`/webhooks`).then((res: JsonResponse) => res.body.webhooks); } public addWebhook(url: string, name?: string, events?: string[], secret?: string, enabled?: boolean): Promise { @@ -345,21 +445,21 @@ class AccountManager { if (events) body.events = events; if (secret) body.secret = secret; if (enabled !== undefined) body.enabled = enabled; - return this.post(urlEncode(["/webhooks"]), JSON.stringify(body), /*expectResponseBody=*/ true).then( + return this.post(urlEncode`/webhooks`, JSON.stringify(body), /*expectResponseBody=*/ true).then( (res: JsonResponse) => res.body.webhook ); } public updateWebhook(id: string, updates: Record): Promise { - return this.patch(urlEncode([`/webhooks/${id}`]), JSON.stringify(updates), /*expectResponseBody=*/ false).then(() => null); + return this.patch(urlEncode`/webhooks/${id}`, JSON.stringify(updates), /*expectResponseBody=*/ false).then(() => null); } public removeWebhook(id: string): Promise { - return this.del(urlEncode([`/webhooks/${id}`])).then(() => null); + return this.del(urlEncode`/webhooks/${id}`).then(() => null); } public getAccessKeys(): Promise { - return this.get(urlEncode(["/accessKeys"])).then((res: JsonResponse) => { + return this.get(urlEncode`/accessKeys`).then((res: JsonResponse) => { const accessKeys: AccessKey[] = []; res.body.accessKeys.forEach((serverAccessKey: ServerAccessKey) => { @@ -370,6 +470,8 @@ class AccountManager { name: serverAccessKey.friendlyName, scopes: serverAccessKey.scopes, appNames: serverAccessKey.appNames, + // Only when the server sends it: an older server's keys stay without it, not "never used". + ...("lastUsedAt" in serverAccessKey ? { lastUsedAt: serverAccessKey.lastUsedAt } : {}), }); }); @@ -378,17 +480,23 @@ class AccountManager { } public getSessions(): Promise { - return this.get(urlEncode(["/accessKeys"])).then((res: JsonResponse) => { + return this.get(urlEncode`/accessKeys`).then((res: JsonResponse) => { // A machine name might be associated with multiple session keys, // but we should only return one per machine name. const sessionMap: { [machineName: string]: Session } = {}; const now: number = new Date().getTime(); + // The machine is its hostname: machineName, which servers since 2026-10 also send as createdBy. + // Older servers sent the login IP as createdBy, so that is the IP when createdByIp is absent. + // `session rm` takes this name, and DELETE /sessions/:machine matches the hostname or the IP. res.body.accessKeys.forEach((serverAccessKey: ServerAccessKey) => { if (serverAccessKey.isSession && serverAccessKey.expires > now) { - sessionMap[serverAccessKey.createdBy] = { - loggedInTime: serverAccessKey.createdTime, - machineName: serverAccessKey.createdBy, - }; + const machineName: string = serverAccessKey.machineName || serverAccessKey.createdBy; + const ip: string | null = + serverAccessKey.createdByIp ?? (serverAccessKey.createdBy !== machineName ? serverAccessKey.createdBy : null); + const session: Session = sessionMap[machineName] ?? { loggedInTime: serverAccessKey.createdTime, machineName, ips: [] }; + session.loggedInTime = Math.max(session.loggedInTime ?? 0, serverAccessKey.createdTime ?? 0); + if (ip && !session.ips.includes(ip)) session.ips.push(ip); + sessionMap[machineName] = session; } }); @@ -404,7 +512,7 @@ class AccountManager { ttl, }; - return this.patch(urlEncode([`/accessKeys/${oldName}`]), JSON.stringify(accessKeyRequest)).then((res: JsonResponse) => { + return this.patch(urlEncode`/accessKeys/${oldName}`, JSON.stringify(accessKeyRequest)).then((res: JsonResponse) => { return { createdTime: res.body.accessKey.createdTime, expires: res.body.accessKey.expires, @@ -414,125 +522,136 @@ class AccountManager { } public removeAccessKey(name: string): Promise { - return this.del(urlEncode([`/accessKeys/${name}`])).then(() => null); + if (!isDotSegment(name)) { + return this.del(urlEncode`/accessKeys/${name}`).then(() => null); + } + // A "." or ".." name cannot travel in the path, so look the key up and revoke it by id, which goes in + // the query string as written. + return this.get(urlEncode`/accessKeys`).then((res: JsonResponse) => { + const key = (res.body.accessKeys || []).find((k: ServerAccessKey & { id?: string }) => !k.isSession && k.friendlyName === name); + if (!key || !key.id) { + throw { message: `Access key "${name}" does not exist.`, statusCode: AccountManager.ERROR_NOT_FOUND }; + } + return this.del(`/accessKeys?id=${encodeURIComponent(key.id)}`).then(() => null); + }); } public removeSession(machineName: string): Promise { - return this.del(urlEncode([`/sessions/${machineName}`])).then(() => null); + return this.del(urlEncode`/sessions/${machineName}`).then(() => null); } // Account public getAccountInfo(): Promise { - return this.get(urlEncode(["/account"])).then((res: JsonResponse) => res.body.account); + return this.get(urlEncode`/account`).then((res: JsonResponse) => res.body.account); } // Apps public getApps(): Promise { - return this.get(urlEncode(["/apps"])).then((res: JsonResponse) => res.body.apps); + return this.get(urlEncode`/apps`).then((res: JsonResponse) => res.body.apps); } public getApp(appName: string): Promise { - return this.get(urlEncode([`/apps/${appName}`])).then((res: JsonResponse) => res.body.app); + return this.get(urlEncode`/apps/${appName}`).then((res: JsonResponse) => res.body.app); } public addApp(appName: string, platform?: string): Promise { const app: App = { name: appName, ...(platform ? { platform } : {}) }; // Resolve with the server's app, not the one sent: only its copy carries generated fields. - return this.post(urlEncode(["/apps/"]), JSON.stringify(app), /*expectResponseBody=*/ true).then( + return this.post(urlEncode`/apps/`, JSON.stringify(app), /*expectResponseBody=*/ true).then( (res: JsonResponse) => res.body.app as App ); } public removeApp(appName: string): Promise { - return this.del(urlEncode([`/apps/${appName}`])).then(() => null); + return this.del(urlEncode`/apps/${appName}`).then(() => null); } public renameApp(oldAppName: string, newAppName: string): Promise { - return this.patch(urlEncode([`/apps/${oldAppName}`]), JSON.stringify({ name: newAppName })).then(() => null); + return this.patch(urlEncode`/apps/${oldAppName}`, JSON.stringify({ name: newAppName })).then(() => null); } public setAppPublicKey(appName: string, publicKey: string): Promise { - return this.patch(urlEncode([`/apps/${appName}`]), JSON.stringify({ signingKey: publicKey })).then(() => null); + return this.patch(urlEncode`/apps/${appName}`, JSON.stringify({ signingKey: publicKey })).then(() => null); } public transferApp(appName: string, email: string): Promise { - return this.post(urlEncode([`/apps/${appName}/transfer/${email}`]), /*requestBody=*/ null, /*expectResponseBody=*/ false).then( + return this.post(urlEncode`/apps/${appName}/transfer/${email}`, /*requestBody=*/ null, /*expectResponseBody=*/ false).then( () => null ); } // Collaborators public getCollaborators(appName: string): Promise { - return this.get(urlEncode([`/apps/${appName}/collaborators`])).then((res: JsonResponse) => res.body.collaborators); + return this.get(urlEncode`/apps/${appName}/collaborators`).then((res: JsonResponse) => res.body.collaborators); } public addCollaborator(appName: string, email: string): Promise { return this.post( - urlEncode([`/apps/${appName}/collaborators/${email}`]), + urlEncode`/apps/${appName}/collaborators/${email}`, /*requestBody=*/ null, /*expectResponseBody=*/ false ).then(() => null); } public removeCollaborator(appName: string, email: string): Promise { - return this.del(urlEncode([`/apps/${appName}/collaborators/${email}`])).then(() => null); + return this.del(urlEncode`/apps/${appName}/collaborators/${email}`).then(() => null); } // Deployments public addDeployment(appName: string, deploymentName: string): Promise { const deployment = { name: deploymentName }; - return this.post(urlEncode([`/apps/${appName}/deployments/`]), JSON.stringify(deployment), /*expectResponseBody=*/ true).then( + return this.post(urlEncode`/apps/${appName}/deployments/`, JSON.stringify(deployment), /*expectResponseBody=*/ true).then( (res: JsonResponse) => res.body.deployment ); } public clearDeploymentHistory(appName: string, deploymentName: string): Promise { - return this.del(urlEncode([`/apps/${appName}/deployments/${deploymentName}/history`])).then(() => null); + return this.del(urlEncode`/apps/${appName}/deployments/${deploymentName}/history`).then(() => null); } public getDeployments(appName: string): Promise { - return this.get(urlEncode([`/apps/${appName}/deployments/`])).then((res: JsonResponse) => res.body.deployments); + return this.get(urlEncode`/apps/${appName}/deployments/`).then((res: JsonResponse) => res.body.deployments); } public getDeployment(appName: string, deploymentName: string): Promise { - return this.get(urlEncode([`/apps/${appName}/deployments/${deploymentName}`])).then((res: JsonResponse) => res.body.deployment); + return this.get(urlEncode`/apps/${appName}/deployments/${deploymentName}`).then((res: JsonResponse) => res.body.deployment); } public renameDeployment(appName: string, oldDeploymentName: string, newDeploymentName: string): Promise { return this.patch( - urlEncode([`/apps/${appName}/deployments/${oldDeploymentName}`]), + urlEncode`/apps/${appName}/deployments/${oldDeploymentName}`, JSON.stringify({ name: newDeploymentName }) ).then(() => null); } public removeDeployment(appName: string, deploymentName: string): Promise { - return this.del(urlEncode([`/apps/${appName}/deployments/${deploymentName}`])).then(() => null); + return this.del(urlEncode`/apps/${appName}/deployments/${deploymentName}`).then(() => null); } public getAutoRollbackConfig(appName: string, deploymentName: string): Promise { - return this.get(urlEncode([`/apps/${appName}/deployments/${deploymentName}/auto-rollback`])).then( + return this.get(urlEncode`/apps/${appName}/deployments/${deploymentName}/auto-rollback`).then( (res: JsonResponse) => res.body.autoRollbackConfig ); } public setAutoRollbackConfig(appName: string, deploymentName: string, config: { enabled: boolean; errorRateThreshold: number; minDevices: number }): Promise { - return this.put(urlEncode([`/apps/${appName}/deployments/${deploymentName}/auto-rollback`]), JSON.stringify(config)).then( + return this.put(urlEncode`/apps/${appName}/deployments/${deploymentName}/auto-rollback`, JSON.stringify(config)).then( (res: JsonResponse) => res.body.autoRollbackConfig ); } public deleteAutoRollbackConfig(appName: string, deploymentName: string): Promise { - return this.del(urlEncode([`/apps/${appName}/deployments/${deploymentName}/auto-rollback`])).then(() => null); + return this.del(urlEncode`/apps/${appName}/deployments/${deploymentName}/auto-rollback`).then(() => null); } public getDeploymentMetrics(appName: string, deploymentName: string): Promise { - return this.get(urlEncode([`/apps/${appName}/deployments/${deploymentName}/metrics`])).then( + return this.get(urlEncode`/apps/${appName}/deployments/${deploymentName}/metrics`).then( (res: JsonResponse) => res.body.metrics ); } public getDeploymentHistory(appName: string, deploymentName: string): Promise { - return this.get(urlEncode([`/apps/${appName}/deployments/${deploymentName}/history`])).then( + return this.get(urlEncode`/apps/${appName}/deployments/${deploymentName}/history`).then( (res: JsonResponse) => res.body.history ); } @@ -549,70 +668,70 @@ class AccountManager { return Promise((resolve, reject, notify) => { updateMetadata.appVersion = targetBinaryVersion; const request: superagent.Request = superagent.post( - this._serverUrl + urlEncode([`/apps/${appName}/deployments/${deploymentName}/release`]) + this._serverUrl + urlEncode`/apps/${appName}/deployments/${deploymentName}/release` ); this.attachCredentials(request); - const getPackageFilePromise = Q.Promise((resolve, reject) => { - this.packageFileFromPath(filePath, signatureJwt) - .then((result) => { - resolve(result); - }) - .catch((error) => { - reject(error); - }); - }); + // Q.fcall turns a synchronous throw (lstat of a missing path) into a rejection, and the catch + // below hands any failure to build the package to the outer promise. Without it a failed zip + // write left this promise pending forever, node exited 0 with no output, and CI went green + // with nothing released. + Q.fcall(() => this.packageFileFromPath(filePath, signatureJwt)) + .catch((error: any) => { + throw new Error(`Could not prepare the release package from "${filePath}": ${(error && error.message) || error}`); + }) + .then((packageFile: PackageFile) => { + const file: any = fs.createReadStream(packageFile.path); + request + .attach("package", file) + .field("packageInfo", JSON.stringify(updateMetadata)); - getPackageFilePromise.then((packageFile: PackageFile) => { - const file: any = fs.createReadStream(packageFile.path); - request - .attach("package", file) - .field("packageInfo", JSON.stringify(updateMetadata)); + if (signatureJwt) { + request.field("packageSignature", signatureJwt); + } - if (signatureJwt) { - request.field("packageSignature", signatureJwt); - } + request + .on("progress", (event: any) => { + if (uploadProgressCallback && event && event.total > 0) { + const currentProgress: number = (event.loaded / event.total) * 100; + uploadProgressCallback(currentProgress); + } + }) + .end((err: any, res: superagent.Response) => { + if (packageFile.isTemporary) { + removeQuietly(packageFile.path); + } - request - .on("progress", (event: any) => { - if (uploadProgressCallback && event && event.total > 0) { - const currentProgress: number = (event.loaded / event.total) * 100; - uploadProgressCallback(currentProgress); - } - }) - .end((err: any, res: superagent.Response) => { - if (packageFile.isTemporary) { - fs.unlinkSync(packageFile.path); - } - - if (err) { - reject(this.getCodePushError(err, res)); - return; - } - - if (res.ok) { - resolve(null); - } else { - let body; - try { - body = JSON.parse(res.text); - } catch (err) {} - - if (body) { - reject({ - message: body.message, - statusCode: res && res.status, - }); + if (err) { + reject(this.getCodePushError(err, res)); + return; + } + + if (res.ok) { + resolve(null); } else { - reject({ - message: res.text, - statusCode: res && res.status, - }); + let body; + try { + body = JSON.parse(res.text); + } catch (err) {} + + if (body) { + reject({ + message: body.message, + statusCode: res && res.status, + }); + } else { + reject({ + message: res.text, + statusCode: res && res.status, + }); + } } - } - }); - }); + }); + }) + .catch((error: any) => reject(error)) + .done(); }); } @@ -620,7 +739,7 @@ class AccountManager { updateMetadata.label = label; const requestBody: string = JSON.stringify({ packageInfo: updateMetadata }); return this.patch( - urlEncode([`/apps/${appName}/deployments/${deploymentName}/release`]), + urlEncode`/apps/${appName}/deployments/${deploymentName}/release`, requestBody, /*expectResponseBody=*/ false ).then(() => null); @@ -634,7 +753,7 @@ class AccountManager { ): Promise { const requestBody: string = JSON.stringify({ packageInfo: updateMetadata }); return this.post( - urlEncode([`/apps/${appName}/deployments/${sourceDeploymentName}/promote/${destinationDeploymentName}`]), + urlEncode`/apps/${appName}/deployments/${sourceDeploymentName}/promote/${destinationDeploymentName}`, requestBody, /*expectResponseBody=*/ false ).then(() => null); @@ -642,7 +761,7 @@ class AccountManager { public rollback(appName: string, deploymentName: string, targetRelease?: string): Promise { return this.post( - urlEncode([`/apps/${appName}/deployments/${deploymentName}/rollback/${targetRelease || ``}`]), + urlEncode`/apps/${appName}/deployments/${deploymentName}/rollback/${targetRelease || ""}`, /*requestBody=*/ null, /*expectResponseBody=*/ false ).then(() => null); @@ -664,12 +783,17 @@ class AccountManager { const fileName: string = this.generateRandomFilename(15) + ".zip"; const zipFile = new yazl.ZipFile(); const writeStream: fs.WriteStream = fs.createWriteStream(fileName); + const fail = (error: Error): void => { + removeQuietly(fileName); + reject(error); + }; + // yazl reports a file it cannot read on the ZipFile itself, and pipe() does not forward + // source errors, so both ends need a handler or the promise never settles. + zipFile.on("error", fail); zipFile.outputStream .pipe(writeStream) - .on("error", (error: Error): void => { - reject(error); - }) + .on("error", fail) .on("close", (): void => { filePath = path.join(process.cwd(), fileName); @@ -697,12 +821,15 @@ class AccountManager { const fileName: string = this.generateRandomFilename(15) + ".zip"; const zipFile = new yazl.ZipFile(); const writeStream: fs.WriteStream = fs.createWriteStream(fileName); + const fail = (error: Error): void => { + removeQuietly(fileName); + reject(error); + }; + zipFile.on("error", fail); zipFile.outputStream .pipe(writeStream) - .on("error", (error: Error): void => { - reject(error); - }) + .on("error", fail) .on("close", (): void => { resolve({ isTemporary: true, path: path.join(process.cwd(), fileName) }); }); @@ -854,6 +981,11 @@ class AccountManager { } } +interface ExpoBusy { + retryAfterMs: number | null; + statusCode: number; +} + function expoRouteError(res: superagent.Response): CodePushError { return { message: messageFromResponseText(res.text), statusCode: res.status }; } diff --git a/script/types.ts b/script/types.ts index 9f80e4a..860bc57 100644 --- a/script/types.ts +++ b/script/types.ts @@ -35,6 +35,8 @@ export interface AccessKey { key?: string; scopes?: string[]; appNames?: string[] | null; + /** When the key was last used (ISO time); null if never. Absent from servers that do not report it. */ + lastUsedAt?: string | null; } /** A device's failure report for one release, as returned by GET /logs/errors. */ @@ -62,6 +64,8 @@ export interface DeploymentErrorsResult { export interface Session { loggedInTime: number; machineName: string; + /** The IP addresses this machine's sessions logged in from, when the server sends them. */ + ips?: string[]; } export type Headers = { [headerName: string]: string }; diff --git a/script/types/cli.ts b/script/types/cli.ts index ede1c28..d59761d 100644 --- a/script/types/cli.ts +++ b/script/types/cli.ts @@ -53,6 +53,8 @@ export enum CommandType { export interface ICommand { type: CommandType; org?: string; + /** --yes: answer yes to the "Are you sure?" prompt (scripts and CI). */ + yes?: boolean; } export interface IAccessKeyAddCommand extends ICommand { @@ -260,6 +262,10 @@ export interface IReleaseExpoCommand extends ICommand { metadata?: string; rollout?: number; description?: string; + /** A file holding the output of `npx expo config --type public --json`, read instead of running it. */ + appConfig?: string; + /** Release without the app config. Constants.expoConfig is then null on devices running the update. */ + skipAppConfig?: boolean; } export interface IBundleReactCommand extends ICommand { diff --git a/script/types/rest-definitions.ts b/script/types/rest-definitions.ts index 8863666..5508961 100644 --- a/script/types/rest-definitions.ts +++ b/script/types/rest-definitions.ts @@ -16,6 +16,9 @@ export interface AccessKey extends AccessKeyBase { /*generated*/ scopes?: string[]; /*generated*/ appIds?: string[] | null; /*generated*/ appNames?: string[] | null; + /*generated*/ lastUsedAt?: string | null; + /*generated, sessions only*/ createdByIp?: string | null; + /*generated, sessions only*/ machineName?: string | null; } /*in*/ diff --git a/test/cli.ts b/test/cli.ts index 38ece5c..df3b181 100644 --- a/test/cli.ts +++ b/test/cli.ts @@ -10,6 +10,7 @@ import * as path from "path"; import * as codePush from "../script/types"; import * as cli from "../script/types/cli"; import * as cmdexec from "../script/command-executor"; +import AccountManager = require("../script/management-sdk"); import { assertReleasePlatform, checkReleaseProjectKind, detectCodePushProjectKind, detectProjectRuntime } from "../script/ota-runtime"; import { getiOSHermesEnabled } from "../script/react-native-utils"; import * as rnUtils from "../script/react-native-utils"; @@ -615,6 +616,35 @@ describe("CLI", () => { }); }); + it("accessKeyList shows when each key was last used", (done: Mocha.Done): void => { + const lastUsed = new Date(NOW - 60 * 24 * 60 * 60 * 1000).toISOString(); + sandbox.stub(cmdexec.sdk, "getAccessKeys").returns( + Q([ + { createdTime: 0, name: "Used key", expires: NOW + DEFAULT_ACCESS_KEY_MAX_AGE, lastUsedAt: lastUsed }, + { createdTime: 0, name: "Unused key", expires: NOW + DEFAULT_ACCESS_KEY_MAX_AGE, lastUsedAt: null }, + { createdTime: 0, name: "Old server key", expires: NOW + DEFAULT_ACCESS_KEY_MAX_AGE }, + ]) + ); + var command: cli.IAccessKeyListCommand = { + type: cli.CommandType.accessKeyList, + format: "table", + }; + + cmdexec.execute(command).done((): void => { + var table: string = log.args[0][0]; + // The "Last used" cell (4th column) of each key's row, colours stripped. + const plain = table.replace(/\u001b\[[0-9;]*m/g, ""); + const lastUsedCell = (name: string): string => + (plain.split("\n").find((l) => l.includes(name)) ?? "").split("│").map((c) => c.trim())[4] ?? ""; + assert.ok(/Last used/.test(plain), table); + const used = lastUsedCell("Used key"); + assert.ok(used !== "" && used !== "Never", `a used key shows when: ${table}`); + assert.equal(lastUsedCell("Unused key"), "Never", table); + assert.equal(lastUsedCell("Old server key"), "", "a server that does not report it is not shown as Never"); + done(); + }); + }); + it("deploymentErrors returns only the requested deployment, with a summary, as JSON", (done: Mocha.Done): void => { var command: cli.IDeploymentErrorsCommand = { type: cli.CommandType.deploymentErrors, @@ -762,6 +792,57 @@ describe("CLI", () => { }); }); + describe("--yes", () => { + // The real prompt, not the suite's stub: --yes has to answer it, and only for the command it came with. + beforeEach(() => (cmdexec.confirm as unknown as sinon.SinonStub).restore()); + + function withoutTerminal(body: () => T): T { + const descriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); + Object.defineProperty(process.stdin, "isTTY", { value: false, configurable: true }); + try { + return body(); + } finally { + if (descriptor) Object.defineProperty(process.stdin, "isTTY", descriptor); + else delete (process.stdin as any).isTTY; + } + } + + it("answers the prompt in a script, where it would otherwise say no", (done: Mocha.Done): void => { + const removeAccessKey: sinon.SinonSpy = sandbox.spy(cmdexec.sdk, "removeAccessKey"); + withoutTerminal(() => + cmdexec.execute({ type: cli.CommandType.accessKeyRemove, accessKey: "8", yes: true }).done((): void => { + sinon.assert.calledOnceWithExactly(removeAccessKey, "8"); + sinon.assert.calledWithMatch(log, /\(y\/N\): yes \(--yes\)/); + done(); + }, done) + ); + }); + + it("without --yes, a script still gets no and nothing changes", (done: Mocha.Done): void => { + const removeAccessKey: sinon.SinonSpy = sandbox.spy(cmdexec.sdk, "removeAccessKey"); + withoutTerminal(() => + cmdexec.execute({ type: cli.CommandType.accessKeyRemove, accessKey: "8" }).done((): void => { + sinon.assert.notCalled(removeAccessKey); + sinon.assert.calledWithExactly(log, "Access key removal cancelled."); + done(); + }, done) + ); + }); + + it("one command's --yes does not answer the next command's prompt", (done: Mocha.Done): void => { + const removeApp: sinon.SinonSpy = sandbox.spy(cmdexec.sdk, "removeApp"); + withoutTerminal(() => + cmdexec + .execute({ type: cli.CommandType.accessKeyRemove, accessKey: "8", yes: true }) + .then(() => cmdexec.execute({ type: cli.CommandType.appRemove, appName: "a" })) + .done((): void => { + sinon.assert.notCalled(removeApp); + done(); + }, done) + ); + }); + }); + it("accessKeyRemove does not remove access key if cancelled", (done: Mocha.Done): void => { var command: cli.IAccessKeyRemoveCommand = { type: cli.CommandType.accessKeyRemove, @@ -995,7 +1076,7 @@ describe("CLI", () => { }); }); - it("appTransfer transfers app", (done: Mocha.Done): void => { + it("appTransfer sends a transfer invite and says the transfer waits for acceptance", (done: Mocha.Done): void => { var command: cli.IAppTransferCommand = { type: cli.CommandType.appTransfer, appName: "a", @@ -1007,7 +1088,10 @@ describe("CLI", () => { cmdexec.execute(command).done((): void => { sinon.assert.calledOnce(transferApp); sinon.assert.calledOnce(log); - sinon.assert.calledWithExactly(log, 'Successfully transferred the ownership of app "a" to the account with email "b@b.com".'); + sinon.assert.calledWithExactly( + log, + 'Transfer invite for app "a" sent to b@b.com. The transfer completes once they accept it from the email (invites expire after 7 days).' + ); done(); }); @@ -1714,6 +1798,279 @@ describe("CLI", () => { .done(); }); + it("rollback on an Expo Updates app exits non-zero when a target failed, still printing what it skipped", (done: Mocha.Done): void => { + var command: cli.IRollbackCommand = { + type: cli.CommandType.rollback, + appName: "expo-app", + deploymentName: "Production", + targetRelease: null, + }; + + sandbox.stub(cmdexec.sdk, "rollbackExpo").returns( + Q({ + rollbacks: [{ platform: "ios", runtimeVersion: "1.0.0", mode: "republish", releaseId: "r" }], + skipped: [ + { platform: "android", runtimeVersion: "1.0.0", reason: "failed with a server error and was left unchanged; try again", failed: true }, + ], + }) as any + ); + + cmdexec + .execute(command) + .then(() => done(new Error("Did not throw error."))) + .catch((err) => { + assert.ok(err.message.includes("1 target failed"), err.message); + sinon.assert.calledWithMatch(log, sinon.match("Skipped Android (runtime version 1.0.0)")); + sinon.assert.calledWithMatch(log, sinon.match("back to the previous release")); + done(); + }) + .done(); + }); + + it("rollback on an Expo Updates app succeeds when targets were only skipped, not failed", (done: Mocha.Done): void => { + var command: cli.IRollbackCommand = { + type: cli.CommandType.rollback, + appName: "expo-app", + deploymentName: "Production", + targetRelease: null, + }; + + sandbox.stub(cmdexec.sdk, "rollbackExpo").returns( + Q({ rollbacks: [], skipped: [{ platform: "android", runtimeVersion: "1.0.0", reason: "there is no previous release" }] }) as any + ); + + cmdexec + .execute(command) + .then(() => { + sinon.assert.calledWithMatch(log, sinon.match("Skipped Android")); + done(); + }) + .catch(done) + .done(); + }); + + // FINC-D5: a retry after a rollback that landed gets the all-skipped 400. "Already rolled back" is the + // state asked for, so it exits 0; any other reason in the same 400 is still a refusal. + const expoRollbackCommand = (): cli.IRollbackCommand => ({ + type: cli.CommandType.rollback, + appName: "expo-app", + deploymentName: "Production", + targetRelease: null, + }); + + it("rollback on an Expo Updates app succeeds when a retry finds every target already rolled back", (done: Mocha.Done): void => { + sandbox.stub(cmdexec.sdk, "rollbackExpo").returns( + Q.reject({ + statusCode: 400, + message: + 'Nothing was rolled back on the "Production" channel: ios runtime version 1.0.0 (already rolled back to the embedded bundle; release a new update to move forward); ' + + "android runtime version 1.0.0 (already serving that bundle; release a new update to move forward).", + }) as any + ); + + cmdexec + .execute(expoRollbackCommand()) + .then(() => { + sinon.assert.calledWithMatch(log, sinon.match("already serves what this rollback asked for")); + done(); + }) + .catch(done) + .done(); + }); + + it("rollback on an Expo Updates app still fails when one skipped target had nothing to go back to", (done: Mocha.Done): void => { + sandbox.stub(cmdexec.sdk, "rollbackExpo").returns( + Q.reject({ + statusCode: 400, + message: + 'Nothing was rolled back on the "Production" channel: ios runtime version 1.0.0 (already serving that bundle; release a new update to move forward); ' + + "android runtime version 1.0.0 (no earlier release to go back to; roll back to the embedded bundle instead).", + }) as any + ); + + cmdexec + .execute(expoRollbackCommand()) + .then(() => done(new Error("Did not throw error."))) + .catch((err) => { + assert.ok(err.message.includes("no earlier release"), err.message); + done(); + }) + .done(); + }); + + it("rollback on an Expo Updates app still fails on a busy channel", (done: Mocha.Done): void => { + sandbox.stub(cmdexec.sdk, "rollbackExpo").returns( + Q.reject({ + statusCode: 409, + message: 'Nothing was rolled back on the "Production" channel: ios runtime version 1.0.0 (already rolled back; another change to this channel is in progress).', + }) as any + ); + + cmdexec + .execute(expoRollbackCommand()) + .then(() => done(new Error("Did not throw error."))) + .catch((err) => { + assert.strictEqual(err.statusCode, 409); + done(); + }) + .done(); + }); + + it("promote on an Expo Updates app exits non-zero when a target failed", (done: Mocha.Done): void => { + var command: cli.IPromoteCommand = { + type: cli.CommandType.promote, + appName: "expo-app", + sourceDeploymentName: "Staging", + destDeploymentName: "Production", + description: null, + label: null, + mandatory: null, + disabled: null, + rollout: null, + appStoreVersion: null, + }; + + sandbox.stub(cmdexec.sdk, "promoteExpo").returns( + Q({ + promotions: [], + skipped: [ + { platform: "ios", runtimeVersion: "1.0.0", reason: "failed with a server error and was left unchanged; try again", failed: true }, + { platform: "android", runtimeVersion: "1.0.0", reason: "failed with a server error and was left unchanged; try again", failed: true }, + ], + }) as any + ); + + cmdexec + .execute(command) + .then(() => done(new Error("Did not throw error."))) + .catch((err) => { + assert.ok(err.message.includes("2 targets failed"), err.message); + sinon.assert.calledWithMatch(log, sinon.match("Skipped iOS (runtime version 1.0.0)")); + done(); + }) + .done(); + }); + + // CLI12-1: a target skipped because another change to the channel was in progress was left unchanged too, + // and exiting 0 on it let a script carry on as if the rollback had happened. + it("rollback on an Expo Updates app exits non-zero when a target was busy (busy: true)", (done: Mocha.Done): void => { + var command: cli.IRollbackCommand = { + type: cli.CommandType.rollback, + appName: "expo-app", + deploymentName: "Production", + targetRelease: null, + }; + + sandbox.stub(cmdexec.sdk, "rollbackExpo").returns( + Q({ + rollbacks: [{ platform: "ios", runtimeVersion: "1.0.0", mode: "republish", releaseId: "r" }], + skipped: [{ platform: "android", runtimeVersion: "1.0.0", reason: "another change to this channel is in progress, try again", busy: true }], + }) as any + ); + + cmdexec + .execute(command) + .then(() => done(new Error("Did not throw error."))) + .catch((err) => { + assert.ok(err.message.includes("1 target was left unchanged because another change to the channel was in progress"), err.message); + sinon.assert.calledWithMatch(log, sinon.match("Skipped Android (runtime version 1.0.0)")); + done(); + }) + .done(); + }); + + it("promote on an Expo Updates app exits non-zero on the busy reason from an API without the flag", (done: Mocha.Done): void => { + var command: cli.IPromoteCommand = { + type: cli.CommandType.promote, + appName: "expo-app", + sourceDeploymentName: "Staging", + destDeploymentName: "Production", + description: null, + label: null, + mandatory: null, + disabled: null, + rollout: null, + appStoreVersion: null, + }; + + sandbox.stub(cmdexec.sdk, "promoteExpo").returns( + Q({ + promotions: [], + skipped: [ + { platform: "ios", runtimeVersion: "1.0.0", reason: "another change to this channel is in progress, try again" }, + { platform: "android", runtimeVersion: "1.0.0", reason: "failed with a server error and was left unchanged; try again", failed: true }, + ], + }) as any + ); + + cmdexec + .execute(command) + .then(() => done(new Error("Did not throw error."))) + .catch((err) => { + assert.ok(err.message.includes("1 target failed and 1 target was busy"), err.message); + done(); + }) + .done(); + }); + + // AUTH12-L4: only `dpctl login` marks its /authenticated call as a login, so the API records one row per + // login instead of one per command. + // FINC-D1: a 401 that says why (a deleted account) must reach the user, not turn into "Invalid access key." + it("login --accessKey shows the API's reason for a 401", (done: Mocha.Done): void => { + sandbox.stub(cmdexec, "deserializeConnectionInfo").callsFake(() => null); + // As the SDK does: a 401 rejects with the body's message only when asked to throw, else resolves false. + sandbox + .stub(AccountManager.prototype, "isAuthenticated") + .callsFake(((throwIfUnauthorized?: boolean) => + throwIfUnauthorized ? Q.reject({ statusCode: 401, message: "This account has been deleted." }) : Q(false)) as any); + + cmdexec + .execute({ type: cli.CommandType.login, accessKey: "key-1" }) + .then(() => done(new Error("Did not throw error."))) + .catch((err) => { + assert.strictEqual(err.message, "This account has been deleted."); + done(); + }) + .done(); + }); + + it("login --accessKey still says Invalid access key. for a 401 with no reason", (done: Mocha.Done): void => { + sandbox.stub(cmdexec, "deserializeConnectionInfo").callsFake(() => null); + let calls = 0; + const bodies = ["Unauthorized", '{"authenticated":false}']; + sandbox + .stub(AccountManager.prototype, "isAuthenticated") + .callsFake(((throwIfUnauthorized?: boolean) => (throwIfUnauthorized ? Q.reject({ statusCode: 401, message: bodies[calls++] }) : Q(false))) as any); + + const attempt = () => + cmdexec.execute({ type: cli.CommandType.login, accessKey: "key-1" }).then( + () => assert.fail("Did not throw error."), + (err: any) => assert.strictEqual(err.message, "Invalid access key.") + ); + attempt() + .then(attempt) + .then(() => done()) + .catch(done) + .done(); + }); + + it("login --accessKey marks its key check as a login", (done: Mocha.Done): void => { + sandbox.stub(cmdexec, "deserializeConnectionInfo").callsFake(() => null); + // false: the login stops at "Invalid access key." before it would write a session file. + const isAuthenticated = sandbox.stub(AccountManager.prototype, "isAuthenticated").callsFake(() => Q(false) as any); + + cmdexec + .execute({ type: cli.CommandType.login, accessKey: "key-1" }) + .then(() => done(new Error("Did not throw error."))) + .catch((err) => { + assert.strictEqual(err.message, "Invalid access key."); + sinon.assert.calledOnce(isAuthenticated); + assert.strictEqual(isAuthenticated.firstCall.args[1], true, "isLogin"); + done(); + }) + .done(); + }); + it("rollback on an Expo Updates app refuses --targetRelease, which Expo releases have no label for", (done: Mocha.Done): void => { var command: cli.IRollbackCommand = { type: cli.CommandType.rollback, @@ -2541,6 +2898,43 @@ describe("CLI", () => { }); }); + // Org webhooks are selected by x-org-id (the API's org webhooks, PR #339), so webhook commands carry the + // organization like every other command; exempting them would manage the personal account's webhooks. + it("webhook commands send the organization in use as x-org-id", (done: Mocha.Done): void => { + const warn = sandbox.stub(console, "error"); + let saved: string | undefined; + sandbox.stub(cmdexec, "deserializeConnectionInfo").callsFake(() => { accessKey: "key", orgId: saved }); + const cases: Array<{ command: cli.ICommand; saved?: string; env?: string; expected: string }> = [ + { command: { type: cli.CommandType.webhookList, format: "json" }, saved: "org-id-saved", expected: "org-id-saved" }, + { command: { type: cli.CommandType.webhookList, format: "json" }, env: "org-id-env", expected: "org-id-env" }, + { command: { type: cli.CommandType.webhookList, format: "json", org: "acme" }, expected: "org-id-acme" }, + { command: ({ type: cli.CommandType.webhookRemove, id: "wh-1", org: "acme" }), expected: "org-id-acme" }, + ]; + let chain = Q(null); + cases.forEach((c) => { + chain = chain.then(() => { + saved = c.saved; + if (c.env) process.env.DEPLOYPULSE_ORG_ID = c.env; + else delete process.env.DEPLOYPULSE_ORG_ID; + (cmdexec.sdk as any).orgId = null; + return cmdexec.execute(c.command).then(() => { + assert.strictEqual((cmdexec.sdk as any).orgId, c.expected, cli.CommandType[c.command.type]); + }); + }); + }); + chain.done( + (): void => { + delete process.env.DEPLOYPULSE_ORG_ID; + assert.ok(!warn.getCalls().some((call) => String(call.args[0]).indexOf("--org is ignored") >= 0), "no webhook exemption warning"); + done(); + }, + (error: any) => { + delete process.env.DEPLOYPULSE_ORG_ID; + done(error); + } + ); + }); + it("runs against the personal account when nothing asks for an organization", (done: Mocha.Done): void => { var command: cli.IAppListCommand = { type: cli.CommandType.appList, format: "json" }; @@ -2714,6 +3108,21 @@ describe("CLI", () => { }); }); + it("sessionList shows each machine's hostname and login IP as a table", (done: Mocha.Done): void => { + sandbox.stub(cmdexec.sdk, "getSessions").returns( + Q([{ loggedInTime: 0, machineName: "Ryans-MBP.local", ips: ["203.0.113.7", "198.51.100.4"] }]) + ); + var command: cli.ISessionListCommand = { type: cli.CommandType.sessionList, format: "table" }; + + cmdexec.execute(command).done((): void => { + var table: string = log.args[0][0]; + assert.ok(/Machine/.test(table) && /IP/.test(table) && /Logged in/.test(table), table); + assert.ok(/Ryans-MBP\.local/.test(table), table); + assert.ok(/203\.0\.113\.7, 198\.51\.100\.4/.test(table), table); + done(); + }); + }); + it("sessionRemove removes session", (done: Mocha.Done): void => { var machineName = TEST_MACHINE_NAME; var command: cli.ISessionRemoveCommand = { @@ -3045,6 +3454,32 @@ describe("CLI", () => { }); } + const EXPO_APP_CONFIG = { + name: "Expo App", + slug: "expo-app", + version: "1.0.2", + extra: { apiUrl: "https://api.example.com", eas: { projectId: "0b4c2e0a-1111-4222-8333-444455556666" } }, + }; + + /** + * Fakes `npx expo config` (expo-utils spawns it through child_process): prints `stdout`, then exits with + * `code`. Returns the stub so a test can check what was run, and where. + */ + function fakeExpoConfig(stdout: string, code = 0, stderr = ""): sinon.SinonStub { + const { EventEmitter } = require("events"); + return sandbox.stub(require("child_process"), "spawn").callsFake(() => { + const child: any = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + process.nextTick(() => { + if (stdout) child.stdout.emit("data", Buffer.from(stdout)); + if (stderr) child.stderr.emit("data", Buffer.from(stderr)); + child.emit("close", code); + }); + return child; + }); + } + // Runs the command with the working directory set to `dir`, then asserts on how it settled. function executeIn(dir: string, command: cli.ICommand, done: Mocha.Done, check: (error?: any) => void, expectFailure: boolean): void { const previous = process.cwd(); @@ -3076,6 +3511,7 @@ describe("CLI", () => { runtimeVersion: "1.0.2", }; var entriesByPlatform: { [platform: string]: string[] } = {}; + fakeExpoConfig(JSON.stringify(EXPO_APP_CONFIG)); var releaseExpo: sinon.SinonStub = sandbox .stub(cmdexec.sdk, "releaseExpo") .callsFake((key: string, zipPath: string, platform: string) => @@ -3114,6 +3550,7 @@ describe("CLI", () => { runtimeVersion: "exposdk:52.0.0", metadata: '{"channel":"stable"}', }; + fakeExpoConfig(JSON.stringify(EXPO_APP_CONFIG)); var releaseExpo: sinon.SinonSpy = sandbox.spy(cmdexec.sdk, "releaseExpo"); executeIn(TEST_APP_DIR, command, done, () => { @@ -3124,6 +3561,168 @@ describe("CLI", () => { }, false); }); + it("release-expo says the server is busy each time the SDK waits to retry", (done: Mocha.Done): void => { + var command: cli.IReleaseExpoCommand = { + type: cli.CommandType.releaseExpo, + appName: "expo-app", + deploymentName: "Production", + platform: "ios", + exportDir: EXPO_EXPORT_DIR, + runtimeVersion: "1.0.2", + }; + fakeExpoConfig(JSON.stringify(EXPO_APP_CONFIG)); + sandbox.stub(cmdexec.sdk, "releaseExpo").callsFake((...args: any[]) => { + const onBusy: (waitMs: number) => void = args[9]; + onBusy(2000); + onBusy(4000); + return Q(null); + }); + + executeIn(TEST_APP_DIR, command, done, () => { + const busy = log.args.filter((args: any[]) => String(args[0]).includes("Server busy, retrying…")); + assert.equal(busy.length, 2); + }, false); + }); + + it("release-expo reports the SDK's give-up as the failure", (done: Mocha.Done): void => { + var command: cli.IReleaseExpoCommand = { + type: cli.CommandType.releaseExpo, + appName: "expo-app", + deploymentName: "Production", + platform: "ios", + exportDir: EXPO_EXPORT_DIR, + runtimeVersion: "1.0.2", + }; + fakeExpoConfig(JSON.stringify(EXPO_APP_CONFIG)); + sandbox.stub(cmdexec.sdk, "releaseExpo").callsFake(() => Q.reject({ message: "Server busy, try again later.", statusCode: 503 })); + + executeIn(TEST_APP_DIR, command, done, (error: any) => { + assert.equal(error.message, "Server busy, try again later."); + }, true); + }); + + // The app config: devices read it as Constants.expoConfig once they run the update, so it goes with every + // release, and a release that can't get it fails unless --skipAppConfig says otherwise. + + it("release-expo reads the public app config with npx expo config and sends it with every platform", (done: Mocha.Done): void => { + var command: cli.IReleaseExpoCommand = { + type: cli.CommandType.releaseExpo, + appName: "expo-app", + deploymentName: "Production", + exportDir: EXPO_EXPORT_DIR, + runtimeVersion: "1.0.2", + }; + // A config plugin's warning before the JSON line must not break parsing. + var expoConfig: sinon.SinonStub = fakeExpoConfig("warning: something a plugin printed\n" + JSON.stringify(EXPO_APP_CONFIG) + "\n"); + var releaseExpo: sinon.SinonSpy = sandbox.spy(cmdexec.sdk, "releaseExpo"); + + executeIn(TEST_APP_DIR, command, done, () => { + sinon.assert.calledOnce(expoConfig); + assert.deepEqual(expoConfig.args[0][1], ["--no", "expo", "config", "--type", "public", "--json"]); + assert.equal(fs.realpathSync(expoConfig.args[0][2].cwd), fs.realpathSync(TEST_APP_DIR)); + sinon.assert.calledTwice(releaseExpo); + assert.deepEqual(releaseExpo.args[0][8], EXPO_APP_CONFIG); + assert.deepEqual(releaseExpo.args[1][8], EXPO_APP_CONFIG); + }, false); + }); + + it("release-expo releases nothing when npx expo config fails, and says how to proceed", (done: Mocha.Done): void => { + var command: cli.IReleaseExpoCommand = { + type: cli.CommandType.releaseExpo, + appName: "expo-app", + deploymentName: "Production", + exportDir: EXPO_EXPORT_DIR, + runtimeVersion: "1.0.2", + }; + fakeExpoConfig("", 1, "ConfigError: app.config.js threw"); + var releaseExpo: sinon.SinonSpy = sandbox.spy(cmdexec.sdk, "releaseExpo"); + + executeIn(TEST_APP_DIR, command, done, (error: any) => { + assert.ok(error.message.includes("Could not read your app config"), error.message); + assert.ok(error.message.includes("app.config.js threw"), error.message); + assert.ok(error.message.includes("--skipAppConfig"), error.message); + assert.ok(error.message.includes("--appConfig"), error.message); + sinon.assert.notCalled(releaseExpo); + }, true); + }); + + it("release-expo releases nothing when npx expo config prints something that is not a JSON object", (done: Mocha.Done): void => { + var command: cli.IReleaseExpoCommand = { + type: cli.CommandType.releaseExpo, + appName: "expo-app", + deploymentName: "Production", + exportDir: EXPO_EXPORT_DIR, + runtimeVersion: "1.0.2", + }; + fakeExpoConfig("{ this is not json\n"); + var releaseExpo: sinon.SinonSpy = sandbox.spy(cmdexec.sdk, "releaseExpo"); + + executeIn(TEST_APP_DIR, command, done, (error: any) => { + assert.ok(error.message.includes("did not give a JSON object"), error.message); + sinon.assert.notCalled(releaseExpo); + }, true); + }); + + it("release-expo --skipAppConfig releases without the config, and warns", (done: Mocha.Done): void => { + var command: cli.IReleaseExpoCommand = { + type: cli.CommandType.releaseExpo, + appName: "expo-app", + deploymentName: "Production", + platform: "ios", + exportDir: EXPO_EXPORT_DIR, + runtimeVersion: "1.0.2", + skipAppConfig: true, + }; + var expoConfig: sinon.SinonStub = fakeExpoConfig("", 1); + var releaseExpo: sinon.SinonSpy = sandbox.spy(cmdexec.sdk, "releaseExpo"); + + executeIn(TEST_APP_DIR, command, done, () => { + sinon.assert.notCalled(expoConfig); + sinon.assert.calledOnce(releaseExpo); + assert.strictEqual(releaseExpo.args[0][8], undefined); + assert.ok(log.args.some((args: any[]) => String(args[0]).includes("Releasing without the app config"))); + }, false); + }); + + it("release-expo --appConfig sends a saved config without running expo", (done: Mocha.Done): void => { + var file: string = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "dpctl-test-config-")), "config.json"); + fs.writeFileSync(file, JSON.stringify(EXPO_APP_CONFIG)); + var command: cli.IReleaseExpoCommand = { + type: cli.CommandType.releaseExpo, + appName: "expo-app", + deploymentName: "Production", + platform: "android", + exportDir: EXPO_EXPORT_DIR, + runtimeVersion: "1.0.2", + appConfig: file, + }; + var expoConfig: sinon.SinonStub = fakeExpoConfig("", 1); + var releaseExpo: sinon.SinonSpy = sandbox.spy(cmdexec.sdk, "releaseExpo"); + + executeIn(TEST_APP_DIR, command, done, () => { + fs.rmSync(path.dirname(file), { recursive: true, force: true }); + sinon.assert.notCalled(expoConfig); + assert.deepEqual(releaseExpo.args[0][8], EXPO_APP_CONFIG); + }, false); + }); + + it("release-expo refuses an app config over the server's cap before uploading", (done: Mocha.Done): void => { + var command: cli.IReleaseExpoCommand = { + type: cli.CommandType.releaseExpo, + appName: "expo-app", + deploymentName: "Production", + exportDir: EXPO_EXPORT_DIR, + runtimeVersion: "1.0.2", + }; + fakeExpoConfig(JSON.stringify({ extra: { blob: "x".repeat(256 * 1024) } })); + var releaseExpo: sinon.SinonSpy = sandbox.spy(cmdexec.sdk, "releaseExpo"); + + executeIn(TEST_APP_DIR, command, done, (error: any) => { + assert.ok(error.message.includes("larger than 256 KB"), error.message); + sinon.assert.notCalled(releaseExpo); + }, true); + }); + it("release-expo uploads nothing when the export is missing a platform", (done: Mocha.Done): void => { var exportDir: string = fs.mkdtempSync(path.join(os.tmpdir(), "dpctl-test-export-")); fs.mkdirSync(path.join(exportDir, "_expo")); diff --git a/test/command-parser.ts b/test/command-parser.ts index 18eca28..d7e100c 100644 --- a/test/command-parser.ts +++ b/test/command-parser.ts @@ -140,6 +140,34 @@ describe("command line", function () { assert.ok(UNKNOWN_ARGUMENT.test(result.output), result.output.slice(0, 300)); }); + // --------------------------------------------------------------------------- + // --yes: the commands that ask "Are you sure?" can be answered from a script, and only those. + // --------------------------------------------------------------------------- + const CONFIRMING_COMMANDS: string[][] = [ + ["access-key", "rm", "CI key"], + ["app", "rm", "MyApp"], + ["app", "transfer", "MyApp", "a@example.com"], + ["collaborator", "rm", "MyApp", "a@example.com"], + ["deployment", "rm", "MyApp", "Staging"], + ["deployment", "clear", "MyApp", "Staging"], + ["rollback", "MyApp", "Staging"], + ["session", "rm", "build-box"], + ]; + + for (const args of CONFIRMING_COMMANDS) { + it(`${args.slice(0, 2).join(" ")} takes --yes and -y`, () => { + assert.strictEqual(parse(...args, "--yes").yes, true); + assert.strictEqual(parse(...args, "-y").yes, true); + assert.strictEqual(parse(...args).yes, undefined, "absent unless asked for"); + }); + } + + it("--yes is refused on a command that asks nothing, rather than silently ignored", () => { + const result = run("app", "ls", "--yes"); + assert.notStrictEqual(result.status, 0); + assert.ok(UNKNOWN_ARGUMENT.test(result.output), result.output.slice(0, 300)); + }); + it("--deployment is accepted as an alias for --deploymentName", () => { const result = run("release-react", "myapp", "ios", "--deployment", "Production"); assert.ok(!UNKNOWN_ARGUMENT.test(result.output), result.output.slice(0, 300)); @@ -154,7 +182,7 @@ describe("command line", function () { const result = run("release-expo", "-h"); assert.strictEqual(result.status, 0); assert.ok(/Usage: dpctl release-expo/.test(result.output), result.output.slice(0, 400)); - ["--exportDir", "--runtimeVersion", "--platform"].forEach((flag: string) => { + ["--exportDir", "--runtimeVersion", "--platform", "--appConfig", "--skipAppConfig"].forEach((flag: string) => { assert.ok(result.output.indexOf(flag) >= 0, `${flag} is missing from release-expo help`); }); }); diff --git a/test/management-sdk.ts b/test/management-sdk.ts index 59c642f..bd6dd3b 100644 --- a/test/management-sdk.ts +++ b/test/management-sdk.ts @@ -432,6 +432,34 @@ describe("Management SDK", () => { assert.strictEqual(messageFromResponseText(""), ""); }); + it("reads the API's validation list as sentences instead of printing it raw", () => { + assert.strictEqual(messageFromResponseText('[{"field":"name","message":"Field is invalid"}]'), "The name field is invalid."); + assert.strictEqual( + messageFromResponseText('[{"field":"name","message":"Field is invalid"},{"field":"appVersion","message":"Field is required"}]'), + "The name field is invalid. The appVersion field is required." + ); + // A message in other words keeps its own wording, after the field. + assert.strictEqual(messageFromResponseText('[{"field":"rollout","message":"Must be 1 to 100"}]'), "rollout: Must be 1 to 100."); + assert.strictEqual(messageFromResponseText('[{"message":"Something went wrong."}]'), "Something went wrong."); + assert.strictEqual(messageFromResponseText('[{"field":"name","message":"a & b is taken"}]'), "name: a & b is taken."); + // Anything else that happens to be a JSON list is left as it was. + assert.strictEqual(messageFromResponseText("[]"), "[]"); + assert.strictEqual(messageFromResponseText("[1,2]"), "[1,2]"); + assert.strictEqual(messageFromResponseText('[{"field":"name"}]'), '[{"field":"name"}]'); + assert.strictEqual(messageFromResponseText('[{"field":"name","message":"Field is invalid"},{"field":"x"}]'), '[{"field":"name","message":"Field is invalid"},{"field":"x"}]'); + }); + + // FINC-D4: the Expo routes answer in plain text with user-supplied names HTML-escaped. + it("prints HTML-escaped error text as the user typed it", () => { + assert.strictEqual( + messageFromResponseText("Nothing was rolled back on the "QA & Beta" channel: it's <busy>."), + 'Nothing was rolled back on the "QA & Beta" channel: it\'s .' + ); + assert.strictEqual(messageFromResponseText('{"message":"App 'a&b' not found"}'), "App 'a&b' not found"); + // One pass only, and unknown entities stay as written. + assert.strictEqual(messageFromResponseText("a &lt; b   c &bogus;"), "a < b   c &bogus;"); + }); + it("unwraps the envelope on the branch a real 4xx takes", () => { // superagent reports a 4xx as an error, so production rejects through getCodePushError, not // through the body-parsing branch below. superagent-mock cannot produce that combination (an @@ -470,6 +498,23 @@ describe("Management SDK", () => { ); }); + // AUTH12-L4: the API records a CLI login only for X-DeployPulse-Login: 1, so every other caller sends 0. + it("isAuthenticated marks a login check with X-DeployPulse-Login: 1", (done: Mocha.Done) => { + mockReturn(JSON.stringify({ authenticated: true }), 200, {}); + manager.isAuthenticated(false, true).done(() => { + assert.strictEqual(lastRequestHeaders["X-DeployPulse-Login"], "1"); + done(); + }, rejectHandler); + }); + + it("isAuthenticated from any other command sends X-DeployPulse-Login: 0", (done: Mocha.Done) => { + mockReturn(JSON.stringify({ authenticated: true }), 200, {}); + manager.isAuthenticated(true).done(() => { + assert.strictEqual(lastRequestHeaders["X-DeployPulse-Login"], "0"); + done(); + }, rejectHandler); + }); + it("identifies itself as dpctl, so a login is not a nameless CLI row", (done: Mocha.Done) => { mockReturn(JSON.stringify({ apps: [] }), 200); manager.getApps().done(() => { @@ -544,6 +589,145 @@ describe("Management SDK", () => { }, rejectHandler); }); + it("releaseExpo sends the app config as its own appConfig field, and no field without one", (done: Mocha.Done) => { + const os = require("os"); + const path = require("path"); + const fs = require("fs"); + const zip = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "dpctl-sdk-zip-")), "bundle.zip"); + fs.writeFileSync(zip, "zip bytes"); + const fields: Array<[string, string]> = []; + const originalField = request.Request.prototype.field; + request.Request.prototype.field = function (name: string, value: string) { + fields.push([name, value]); + return originalField.apply(this, arguments); + }; + const restore = () => { + request.Request.prototype.field = originalField; + fs.rmSync(path.dirname(zip), { recursive: true, force: true }); + }; + const appConfig = { name: "App", extra: { eas: { projectId: "p1" } } }; + mockReturn(JSON.stringify({ release: {} }), 201); + manager + .releaseExpo("dkey", zip, "ios", "1.0.0", undefined, undefined, undefined, undefined, appConfig) + .then(() => { + assert.deepStrictEqual(fields.find(([name]) => name === "appConfig"), ["appConfig", JSON.stringify(appConfig)]); + fields.length = 0; + return manager.releaseExpo("dkey", zip, "ios", "1.0.0"); + }) + .then(() => { + assert.deepStrictEqual(fields.map(([name]) => name), ["releaseInfo"], "an older-style release sends no appConfig field"); + restore(); + done(); + }, (err: any) => { + restore(); + rejectHandler(err); + }); + }); + + // 1.2.2: a busy server (503, or 429 with Retry-After) is retried for up to five minutes, then given up on + // with "Server busy, try again later." The clock is faked, so the waits are recorded, not slept. + describe("releaseExpo when the server is busy", () => { + const os = require("os"); + const path = require("path"); + const fs = require("fs"); + let zip: string; + let waits: number[]; + let now: number; + + beforeEach(() => { + zip = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "dpctl-sdk-busy-")), "bundle.zip"); + fs.writeFileSync(zip, "zip bytes"); + waits = []; + now = Date.parse("2026-10-15T12:00:00Z"); + (manager as any)._clock = { + now: () => now, + sleep: (ms: number) => { + waits.push(ms); + now += ms; + return Q(null); + }, + }; + }); + afterEach(() => fs.rmSync(path.dirname(zip), { recursive: true, force: true })); + + const release = (onBusy?: (ms: number) => void) => + manager.releaseExpo("dkey", zip, "ios", "1.0.0", undefined, undefined, undefined, undefined, undefined, onBusy); + + it("retries a 503 with backoff until the release goes through, telling the caller each time", () => { + const sent = mockSequence([{ status: 503 }, { status: 503 }, { status: 201, text: JSON.stringify({ release: {} }) }]); + const busy: number[] = []; + return release((ms) => busy.push(ms)).then(() => { + assert.strictEqual(sent.length, 3); + assert.deepStrictEqual(busy, waits); + assert.strictEqual(waits.length, 2); + assert.ok(waits[0] >= 2000 && waits[0] <= 2400, `first wait ${waits[0]}`); + assert.ok(waits[1] >= 4000 && waits[1] <= 4800, `second wait ${waits[1]}`); + }); + }); + + it("waits exactly as long as Retry-After asks, in seconds or as a date", () => { + mockSequence([ + { status: 429, header: { "retry-after": "7" } }, + { status: 503, header: { "retry-after": new Date(Date.parse("2026-10-15T12:00:37Z")).toUTCString() } }, + { status: 201, text: "{}" }, + ]); + return release().then(() => assert.deepStrictEqual(waits, [7000, 30000])); + }); + + it("a 429 without Retry-After is a rate limit, not a busy server: no retry", () => { + const sent = mockSequence([{ status: 429, text: JSON.stringify({ message: "Too many releases." }) }, { status: 201, text: "{}" }]); + return release().then( + () => assert.fail("should have rejected"), + (err: any) => { + assert.strictEqual(sent.length, 1); + assert.strictEqual(err.statusCode, 429); + assert.deepStrictEqual(waits, []); + } + ); + }); + + it("gives up with \"Server busy, try again later.\" once five minutes of waiting would pass", () => { + // Busy 100 times, then a success. The budget runs out long before the 101st try; if the give-up check + // were missing, the release would reach the success and resolve, failing this test instead of looping + // forever (the mock repeats its last answer, and the fake clock never lets mocha's timeout fire). + const sent = mockSequence([...Array.from({ length: 100 }, () => ({ status: 503 })), { status: 201, text: "{}" }]); + return release().then( + () => assert.fail("should have rejected"), + (err: any) => { + assert.strictEqual(err.message, "Server busy, try again later."); + assert.strictEqual(err.statusCode, 503); + const waited = waits.reduce((a, b) => a + b, 0); + assert.ok(waited <= 5 * 60 * 1000, `waited ${waited}ms`); + assert.ok(waited > 4 * 60 * 1000, `kept trying for most of the budget (${waited}ms)`); + assert.strictEqual(sent.length, waits.length + 1); + } + ); + }); + + it("a Retry-After past the budget gives up at once rather than waiting it out", () => { + const sent = mockSequence([{ status: 503, header: { "retry-after": "600" } }, { status: 201, text: "{}" }]); + return release().then( + () => assert.fail("should have rejected"), + (err: any) => { + assert.strictEqual(err.message, "Server busy, try again later."); + assert.strictEqual(sent.length, 1); + assert.deepStrictEqual(waits, []); + } + ); + }); + + it("other failures are not retried", () => { + const sent = mockSequence([{ status: 500, text: JSON.stringify({ message: "boom" }) }, { status: 201, text: "{}" }]); + return release().then( + () => assert.fail("should have rejected"), + (err: any) => { + assert.strictEqual(sent.length, 1); + assert.strictEqual(err.statusCode, 500); + } + ); + }); + }); + it("Expo requests carry the same credentials as every other route", (done: Mocha.Done) => { mockReturn(JSON.stringify({ releases: [] }), 200); manager.setOrgId("org-id-acme"); @@ -575,6 +759,313 @@ describe("Management SDK", () => { done(); }, rejectHandler); }); + + // Every user-supplied name is one encoded path segment, so a "#", "?", "%" or "/" in it can never cut the + // path short or address a different resource. + const awkward = "a#b%c?d/e fé"; + const enc = encodeURIComponent(awkward); + + const pathCases: [string, (m: AccountManager) => any, string][] = [ + ["getAccessKey", (m) => m.getAccessKey(awkward), `/accessKeys/${enc}`], + ["patchAccessKey", (m) => m.patchAccessKey(awkward, "new"), `/accessKeys/${enc}`], + ["removeAccessKey", (m) => m.removeAccessKey(awkward), `/accessKeys/${enc}`], + ["removeSession", (m) => m.removeSession(awkward), `/sessions/${enc}`], + ["updateWebhook", (m) => m.updateWebhook(awkward, {}), `/webhooks/${enc}`], + ["removeWebhook", (m) => m.removeWebhook(awkward), `/webhooks/${enc}`], + ["getApp", (m) => m.getApp(awkward), `/apps/${enc}`], + ["removeApp", (m) => m.removeApp(awkward), `/apps/${enc}`], + ["renameApp", (m) => m.renameApp(awkward, "new"), `/apps/${enc}`], + ["setAppPublicKey", (m) => m.setAppPublicKey(awkward, "key"), `/apps/${enc}`], + ["transferApp", (m) => m.transferApp(awkward, awkward), `/apps/${enc}/transfer/${enc}`], + ["getCollaborators", (m) => m.getCollaborators(awkward), `/apps/${enc}/collaborators`], + ["addCollaborator", (m) => m.addCollaborator(awkward, awkward), `/apps/${enc}/collaborators/${enc}`], + ["removeCollaborator", (m) => m.removeCollaborator(awkward, awkward), `/apps/${enc}/collaborators/${enc}`], + ["addDeployment", (m) => m.addDeployment(awkward, "Staging"), `/apps/${enc}/deployments/`], + ["getDeployments", (m) => m.getDeployments(awkward), `/apps/${enc}/deployments/`], + ["clearDeploymentHistory", (m) => m.clearDeploymentHistory(awkward, awkward), `/apps/${enc}/deployments/${enc}/history`], + ["getDeployment", (m) => m.getDeployment(awkward, awkward), `/apps/${enc}/deployments/${enc}`], + ["renameDeployment", (m) => m.renameDeployment(awkward, awkward, "new"), `/apps/${enc}/deployments/${enc}`], + ["removeDeployment", (m) => m.removeDeployment(awkward, awkward), `/apps/${enc}/deployments/${enc}`], + ["getAutoRollbackConfig", (m) => m.getAutoRollbackConfig(awkward, awkward), `/apps/${enc}/deployments/${enc}/auto-rollback`], + [ + "setAutoRollbackConfig", + (m) => m.setAutoRollbackConfig(awkward, awkward, { enabled: true, errorRateThreshold: 5, minDevices: 10 }), + `/apps/${enc}/deployments/${enc}/auto-rollback`, + ], + ["deleteAutoRollbackConfig", (m) => m.deleteAutoRollbackConfig(awkward, awkward), `/apps/${enc}/deployments/${enc}/auto-rollback`], + ["getDeploymentMetrics", (m) => m.getDeploymentMetrics(awkward, awkward), `/apps/${enc}/deployments/${enc}/metrics`], + ["getDeploymentHistory", (m) => m.getDeploymentHistory(awkward, awkward), `/apps/${enc}/deployments/${enc}/history`], + ["patchRelease", (m) => m.patchRelease(awkward, awkward, "v1", {}), `/apps/${enc}/deployments/${enc}/release`], + ["promote", (m) => m.promote(awkward, awkward, awkward, {}), `/apps/${enc}/deployments/${enc}/promote/${enc}`], + ["rollback", (m) => m.rollback(awkward, awkward, awkward), `/apps/${enc}/deployments/${enc}/rollback/${enc}`], + ["patchExpoRollout", (m) => m.patchExpoRollout(awkward, 25), `/expo/v1/${enc}/release`], + ]; + + it("encodes every name in the request path as exactly one segment", (done: Mocha.Done) => { + assert.strictEqual(enc, "a%23b%25c%3Fd%2Fe%20f%C3%A9"); + const requested = mockCapture(); + let chain = Q(null); + pathCases.forEach(([method, call, expected]) => { + chain = chain.then(() => + call(manager).then(() => { + assert.strictEqual(requested.pop(), "https://api.deploypulse.io" + expected, method); + }) + ); + }); + chain.done(() => done(), done); + }); + + it("removes Staging#2 itself, not Staging", (done: Mocha.Done) => { + const requested = mockCapture(); + manager.removeDeployment("my#app", "Staging#2").done(() => { + assert.deepStrictEqual(requested, ["https://api.deploypulse.io/apps/my%23app/deployments/Staging%232"]); + done(); + }, done); + }); + + it("leaves plain names as they were", (done: Mocha.Done) => { + const requested = mockCapture(); + manager + .promote("MyApp", "Staging", "Production", {}) + .then(() => manager.getAccessKey("ci-key")) + .done(() => { + assert.deepStrictEqual(requested, [ + "https://api.deploypulse.io/apps/MyApp/deployments/Staging/promote/Production", + "https://api.deploypulse.io/accessKeys/ci-key", + ]); + done(); + }, done); + }); + + it("refuses a name that is a dot segment before sending anything", (done: Mocha.Done) => { + const requested = mockCapture(); + const refused = [".", "..", " .. ", "%2e", "%2E%2e"]; + let chain = Q(null); + refused.forEach((name) => { + [ + (m: AccountManager) => m.removeDeployment("MyApp", name), + (m: AccountManager) => m.renameDeployment("MyApp", name, "new"), + (m: AccountManager) => m.removeApp(name), + (m: AccountManager) => m.removeCollaborator("MyApp", name), + (m: AccountManager) => m.rollback("MyApp", "Production", name), + (m: AccountManager) => m.patchExpoRollout(name, 10), + (m: AccountManager) => m.release("MyApp", name, "unused", "1.0.0", {}), + ].forEach((call) => { + chain = chain.then(() => + Q.fcall(() => call(manager)).then( + () => assert.fail(`expected "${name}" to be refused`), + (error: any) => { + assert.ok(/cannot be used in a request URL/.test(error.message), error.message); + assert.ok(/[Cc]ontact support/.test(error.message), error.message); + } + ) + ); + }); + }); + chain.done(() => { + assert.deepStrictEqual(requested, []); + done(); + }, done); + }); + + // FINC-D3: a key named "." or ".." cannot be addressed in the path, so it is revoked by id instead. + it("removes an access key whose name is a dot segment by id", (done: Mocha.Done) => { + const requested: string[] = []; + require("superagent-mock")(request, [ + { + pattern: "https://api.deploypulse.io/(.*)", + fixtures: function (match: any): any { + requested.push(match[0]); + const body = { + accessKeys: [ + { id: "sess-1", friendlyName: "..", isSession: true }, + { id: "key-id/1", friendlyName: "..", isSession: false }, + { id: "key-2", friendlyName: ".", isSession: false }, + ], + }; + return { text: JSON.stringify(body), status: 200, ok: true, header: {}, headers: {} }; + }, + callback: (match: any, data: any): any => data, + }, + ]); + manager + .removeAccessKey("..") + .then(() => manager.removeAccessKey(".")) + .done(() => { + assert.deepStrictEqual(requested, [ + "https://api.deploypulse.io/accessKeys", + "https://api.deploypulse.io/accessKeys?id=key-id%2F1", + "https://api.deploypulse.io/accessKeys", + "https://api.deploypulse.io/accessKeys?id=key-2", + ]); + done(); + }, done); + }); + + // Sessions are the machine's hostname, with the login IPs alongside. Servers since 2026-10 send the + // hostname as createdBy and the IP as createdByIp; older ones sent the IP as createdBy. + it("groups sessions by machine name and collects their login IPs", (done: Mocha.Done) => { + const later = Date.now() + 60 * 60 * 1000; + mockReturn( + JSON.stringify({ + accessKeys: [ + { isSession: true, expires: later, createdTime: 1, createdBy: "mbp.local", createdByIp: "203.0.113.7", machineName: "mbp.local" }, + { isSession: true, expires: later, createdTime: 5, createdBy: "mbp.local", createdByIp: "198.51.100.4", machineName: "mbp.local" }, + { isSession: true, expires: later, createdTime: 2, createdBy: "192.0.2.9", machineName: "ci-box" }, // an older server + { isSession: true, expires: later, createdTime: 3, createdBy: "192.0.2.10" }, // a session with no machine name + { isSession: false, expires: later, createdTime: 4, createdBy: "laptop" }, + { isSession: true, expires: 1, createdTime: 1, createdBy: "gone", machineName: "gone" }, // expired + ], + }), + 200 + ); + manager.getSessions().done((sessions) => { + assert.deepStrictEqual(sessions, [ + { machineName: "mbp.local", loggedInTime: 5, ips: ["203.0.113.7", "198.51.100.4"] }, + { machineName: "ci-box", loggedInTime: 2, ips: ["192.0.2.9"] }, + { machineName: "192.0.2.10", loggedInTime: 3, ips: [] }, + ]); + done(); + }, done); + }); + + it("refuses to remove a dot-segment access key that does not exist", (done: Mocha.Done) => { + mockReturn(JSON.stringify({ accessKeys: [{ id: "k", friendlyName: "other", isSession: false }] }), 200); + manager.removeAccessKey("..").done( + () => done(new Error("expected a not-found error")), + (error: any) => { + assert.strictEqual(error.statusCode, 404); + assert.ok(/does not exist/.test(error.message), error.message); + done(); + } + ); + }); + + it("allows names that only contain dots", (done: Mocha.Done) => { + const requested = mockCapture(); + manager + .removeDeployment("MyApp", "...") + .then(() => manager.removeDeployment("MyApp", ".a")) + .then(() => manager.removeDeployment("MyApp", "a.")) + .done(() => { + assert.deepStrictEqual(requested, [ + "https://api.deploypulse.io/apps/MyApp/deployments/...", + "https://api.deploypulse.io/apps/MyApp/deployments/.a", + "https://api.deploypulse.io/apps/MyApp/deployments/a.", + ]); + done(); + }, done); + }); + + describe("release when the package cannot be prepared", function () { + // A release that never settles is the failure being guarded: node exits 0 with no output and CI + // goes green with nothing released. A short timeout turns a hang into a test failure. + this.timeout(5000); + + const fs = require("fs"); + const os = require("os"); + const path = require("path"); + const realCreateWriteStream = fs.createWriteStream; + const startDir = process.cwd(); + let workDir: string; + + beforeEach(() => { + workDir = fs.mkdtempSync(path.join(os.tmpdir(), "dpctl-release-")); + }); + + afterEach(() => { + process.chdir(startDir); + fs.createWriteStream = realCreateWriteStream; + fs.rmSync(workDir, { recursive: true, force: true }); + }); + + function expectRejection(promise: Q.Promise, pattern: RegExp, requested: string[], done: Mocha.Done): void { + promise.done( + () => done(new Error("release resolved, but the package was never built")), + (error: any) => { + try { + assert.match(error.message, pattern); + assert.deepStrictEqual(requested, [], "nothing should be uploaded"); + done(); + } catch (e) { + done(e); + } + } + ); + } + + // Every temp zip lands in a directory that does not exist, as a full disk or a read-only working + // directory would make it fail. + function failZipWrites(): void { + fs.createWriteStream = (name: string, ...rest: any[]) => realCreateWriteStream(path.join(workDir, "missing", name), ...rest); + } + + it("rejects when the zip of a directory cannot be written", (done: Mocha.Done) => { + const bundleDir = path.join(workDir, "bundle"); + fs.mkdirSync(bundleDir); + fs.writeFileSync(path.join(bundleDir, "index.bundle"), "console.log(1);"); + const requested = mockCapture(); + failZipWrites(); + + expectRejection( + manager.release("MyApp", "Staging", bundleDir, "1.0.0", {}), + /^Could not prepare the release package from ".*bundle": ENOENT/, + requested, + done + ); + }); + + it("rejects when the signed zip of a single file cannot be written", (done: Mocha.Done) => { + const bundleFile = path.join(workDir, "index.bundle"); + fs.writeFileSync(bundleFile, "console.log(1);"); + const requested = mockCapture(); + failZipWrites(); + + expectRejection( + manager.release("MyApp", "Staging", bundleFile, "1.0.0", {}, undefined, "header.payload.signature"), + /^Could not prepare the release package from ".*index\.bundle": ENOENT/, + requested, + done + ); + }); + + it("rejects when the package path does not exist", (done: Mocha.Done) => { + const requested = mockCapture(); + + expectRejection( + manager.release("MyApp", "Staging", path.join(workDir, "nope"), "1.0.0", {}), + /^Could not prepare the release package from ".*nope": ENOENT/, + requested, + done + ); + }); + + it("rejects when a file in the directory cannot be read into the zip", (done: Mocha.Done) => { + // A dangling symlink is listed by the directory walk but fails yazl's stat, which yazl reports + // on the ZipFile rather than its output stream. + const bundleDir = path.join(workDir, "bundle"); + fs.mkdirSync(bundleDir); + fs.symlinkSync(path.join(workDir, "gone"), path.join(bundleDir, "index.bundle")); + const requested = mockCapture(); + process.chdir(workDir); + + expectRejection( + manager.release("MyApp", "Staging", bundleDir, "1.0.0", {}), + /^Could not prepare the release package from ".*bundle": ENOENT/, + requested, + (error?: any) => { + if (!error) { + assert.deepStrictEqual( + fs.readdirSync(workDir).filter((name: string) => name.endsWith(".zip")), + [], + "the partial temp zip should be removed" + ); + } + done(error); + } + ); + }); + }); }); // Helper method that is used everywhere that an assert.fail() is needed in a promise handler @@ -636,3 +1127,40 @@ function mockReturn(bodyText: string, statusCode: number, header = {}, throwOnEr }, ]); } + +function mockCapture(): string[] { + const requested: string[] = []; + const body = { accessKey: { friendlyName: "k" }, app: {}, deployment: {}, deployments: [], collaborators: {}, history: [] }; + require("superagent-mock")(request, [ + { + pattern: "https://api.deploypulse.io/(.*)", + fixtures: function (match: any): any { + requested.push(match[0]); + return { text: JSON.stringify(body), status: 200, ok: true, header: {}, headers: {} }; + }, + callback: function (match: any, data: any): any { + return data; + }, + }, + ]); + return requested; +} + +/** Answers each request with the next response in `responses`; returns the URLs requested. */ +function mockSequence(responses: Array<{ status: number; text?: string; header?: Record }>): string[] { + const requested: string[] = []; + require("superagent-mock")(request, [ + { + pattern: "https://api.deploypulse.io/(.*)", + fixtures: function (match: any): any { + requested.push(match[0]); + const next = responses[Math.min(requested.length, responses.length) - 1]; + return { text: next.text ?? "", status: next.status, ok: next.status >= 200 && next.status < 300, header: next.header ?? {}, headers: {} }; + }, + callback: function (match: any, data: any): any { + return data; + }, + }, + ]); + return requested; +}