From a0cf51cd4bd518e371d019463a2a13ca3e9ccb40 Mon Sep 17 00:00:00 2001 From: Revopush Date: Thu, 15 Oct 2026 21:23:06 +0300 Subject: [PATCH 1/2] Treat Ctrl+C at a y/N prompt as no; clean up temp zips safely; remove dead code Co-Authored-By: Claude Opus 5.5 --- script/command-executor.ts | 12 +++++----- script/management-sdk.ts | 20 +++++++++++----- script/types/rest-definitions.ts | 2 -- script/ui/terminal.ts | 6 +++++ test/management-sdk.ts | 33 ++++++++++++++++++++++++++ test/terminal.ts | 40 ++++++++++++++++++++++++++++++++ 6 files changed, 99 insertions(+), 14 deletions(-) create mode 100644 test/terminal.ts diff --git a/script/command-executor.ts b/script/command-executor.ts index 30fe4c6..146d854 100644 --- a/script/command-executor.ts +++ b/script/command-executor.ts @@ -461,7 +461,7 @@ function getTotalActiveFromDeploymentMetrics(metrics: DeploymentMetrics): number return totalActive; } -function initiateExternalAuthenticationAsync(action: string, serverUrl?: string): void { +function openLoginPage(serverUrl?: string): void { const hostname: string = os.hostname(); const url: string = `${serverUrl || AccountManager.APP_SERVER_URL}/cli-login?hostname=${hostname}`; log("Opening your browser..."); @@ -470,7 +470,7 @@ function initiateExternalAuthenticationAsync(action: string, serverUrl?: string) } async function link(command: cli.ILinkCommand): Promise { - initiateExternalAuthenticationAsync("link", command.serverUrl); + openLoginPage(command.serverUrl); } async function login(command: cli.ILoginCommand): Promise { @@ -482,12 +482,12 @@ async function login(command: cli.ILoginCommand): Promise { throw new Error("Invalid access key."); } } else { - await loginWithExternalAuthentication("login", command.apiServerUrl, command.appServerUrl); + await loginWithExternalAuthentication(command.apiServerUrl, command.appServerUrl); } } -async function loginWithExternalAuthentication(action: string, apiServerUrl?: string, appServerUrl?: string): Promise { - initiateExternalAuthenticationAsync(action, appServerUrl); +async function loginWithExternalAuthentication(apiServerUrl?: string, appServerUrl?: string): Promise { + openLoginPage(appServerUrl); log(""); // Insert newline const accessKey: string = await requestAccessKey(); @@ -536,7 +536,7 @@ async function getReactNativeProjectVersionInfo( } async function register(command: cli.IRegisterCommand): Promise { - return loginWithExternalAuthentication("register", command.serverUrl); + return loginWithExternalAuthentication(command.serverUrl); } async function promote(command: cli.IPromoteCommand): Promise { diff --git a/script/management-sdk.ts b/script/management-sdk.ts index 8df2ddb..715c51d 100644 --- a/script/management-sdk.ts +++ b/script/management-sdk.ts @@ -364,7 +364,10 @@ class AccountManager { }) .end((err: any, res: superagent.Response) => { if (packageFile.isTemporary) { - fs.unlinkSync(packageFile.path); + // Best effort: failing to delete the temp file must not hide the upload's result + try { + fs.rmSync(packageFile.path, { force: true }); + } catch {} } if (err) { @@ -431,11 +434,16 @@ class AccountManager { const files: string[] = await listFiles(filePath); const baseDirectoryPath: string = path.dirname(filePath); const zipPath: string = path.join(process.cwd(), this.generateRandomFilename(15) + ".zip"); - await createZip( - zipPath, - // yazl does not like backslash (\) in the metadata path. - files.map((file) => [file, normalizePath(path.relative(baseDirectoryPath, file))]) - ); + try { + await createZip( + zipPath, + // yazl does not like backslash (\) in the metadata path. + files.map((file) => [file, normalizePath(path.relative(baseDirectoryPath, file))]) + ); + } catch (error) { + fs.rmSync(zipPath, { force: true }); + throw error; + } return { isTemporary: true, path: zipPath }; } diff --git a/script/types/rest-definitions.ts b/script/types/rest-definitions.ts index 7aa80cf..0fee44a 100644 --- a/script/types/rest-definitions.ts +++ b/script/types/rest-definitions.ts @@ -161,5 +161,3 @@ export interface Package extends PackageInfo { /*generated*/ size: number; /*generated*/ uploadTime: number; } - -export * from "./rest-definitions"; diff --git a/script/ui/terminal.ts b/script/ui/terminal.ts index c7721af..e2ba25c 100644 --- a/script/ui/terminal.ts +++ b/script/ui/terminal.ts @@ -24,6 +24,12 @@ export const confirm = (message: string = "Are you sure?"): Promise => }, }, (err: any, result: any): void => { + // Ctrl+C means "no"; other errors (e.g. no terminal) still fail the command + if (err?.message === "canceled") { + resolve(false); + return; + } + const accepted = result.response && result.response.toLowerCase() === "y"; const rejected = !result.response || result.response.toLowerCase() === "n"; diff --git a/test/management-sdk.ts b/test/management-sdk.ts index d087f22..6521b38 100644 --- a/test/management-sdk.ts +++ b/test/management-sdk.ts @@ -2,10 +2,15 @@ // Licensed under the MIT License. import { strict as assert } from "assert"; +import * as fs from "fs"; +import * as path from "path"; +import * as sinon from "sinon"; import superagent = require("superagent"); import installSuperagentMock = require("superagent-mock"); import AccountManager = require("../script/management-sdk"); +import * as fileUtils from "../script/utils/file-utils"; +import { useTempDirectory } from "./helpers/temp-directory"; interface MockedResponse { text: string; @@ -273,4 +278,32 @@ describe("Management SDK", () => { assert.equal(await manager.rollback("appName", "deploymentName", "v1"), null); }); }); + + describe("uploads", () => { + const project = useTempDirectory("management-sdk", { chdir: true }); + + afterEach(() => sinon.restore()); + + it("release deletes the partly written zip when zipping fails", async () => { + project.write("update/index.bundle", "bundle"); + sinon.stub(fileUtils, "createZip").callsFake(async (zipPath: string) => { + fs.writeFileSync(zipPath, "partial"); + throw new Error("zip failed"); + }); + + await assert.rejects(manager.release("appName", "deploymentName", "update", {}), { message: "zip failed" }); + assert.deepEqual( + fs.readdirSync(project.path).filter((file) => path.extname(file) === ".zip"), + [] + ); + }); + + it("releaseNative rejects with the server error when its zip is already gone", async () => { + mockServerResponse(500, "Server error"); + + await assert.rejects(manager.releaseNative("appName", "deploymentName", path.join(project.path, "missing.zip"), {}), { + statusCode: 500, + }); + }); + }); }); diff --git a/test/terminal.ts b/test/terminal.ts new file mode 100644 index 0000000..93b1bf7 --- /dev/null +++ b/test/terminal.ts @@ -0,0 +1,40 @@ +import { strict as assert } from "assert"; +import * as sinon from "sinon"; +import { confirm } from "../script/ui/terminal"; + +const prompt = require("prompt"); + +describe("terminal", () => { + afterEach(() => sinon.restore()); + + describe("confirm", () => { + const answer = (error: Error | null, response?: string) => + sinon + .stub(prompt, "get") + .callsFake((_schema: unknown, callback: (error: Error | null, result?: unknown) => void) => + callback(error, error ? undefined : { response }) + ); + + beforeEach(() => sinon.stub(prompt, "start")); + + it("accepts y", async () => { + answer(null, "y"); + assert.equal(await confirm(), true); + }); + + it("rejects an empty answer", async () => { + answer(null, ""); + assert.equal(await confirm(), false); + }); + + it("rejects when the prompt is cancelled with Ctrl+C", async () => { + answer(new Error("canceled")); + assert.equal(await confirm(), false); + }); + + it("still fails on other prompt errors, so scripts don't treat them as a cancel", async () => { + answer(new Error("stdin closed")); + await assert.rejects(confirm()); + }); + }); +}); From f8f4f66f193c1fd60d062299ebb45e377a5fbf98 Mon Sep 17 00:00:00 2001 From: Revopush Date: Fri, 16 Oct 2026 04:53:29 +0300 Subject: [PATCH 2/2] Final clean-up: comments, dependencies, debug ios, register URLs, package contents - Keep only "why" comments; move release and metrics helpers next to the code that uses them - One bundler runner: fails unless the bundler exits 0, reports spawn errors, ignores blank CODE_PUSH_NODE_ARGS - hermesc no longer gets CODE_PUSH_NODE_ARGS (copy-paste from the original code) - Replace backslash, email-validator, wordwrap, which and simctl with small local code that keeps their behaviour - Repair debug ios on current Xcode: read simulator logs with simctl log stream - register takes [apiServerUrl] [appServerUrl] like login - release-native removes its metadata zip when the release fails before uploading - Publish only bin/script; clean before build and pack - Tests use a private temp folder Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 221 +---------------------- package.json | 14 +- script/auth/connection.ts | 8 +- script/command-executor.ts | 131 ++++++-------- script/commands/debug.ts | 79 ++++---- script/management-sdk.ts | 2 +- script/parser/commands.ts | 87 +++++---- script/parser/create-command.ts | 31 +++- script/platforms/android/binary.ts | 7 - script/platforms/ios/binary.ts | 3 - script/platforms/ios/xcode.ts | 10 +- script/platforms/react-native/bundler.ts | 123 ++++--------- script/platforms/react-native/hermes.ts | 1 - script/types/cli.ts | 3 +- script/ui/printers.ts | 28 ++- script/ui/terminal.ts | 12 +- script/utils/file-utils.ts | 14 +- script/utils/hash-utils.ts | 7 +- script/utils/sign.ts | 2 +- script/utils/validation-utils.ts | 12 +- test/bundler.ts | 69 +++++++ test/file-utils.ts | 14 +- test/setup.ts | 8 +- test/terminal.ts | 11 +- 24 files changed, 361 insertions(+), 536 deletions(-) create mode 100644 test/bundler.ts diff --git a/package-lock.json b/package-lock.json index 1214654..fef3774 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,12 +9,10 @@ "version": "0.0.15", "dependencies": { "@devicefarmer/adbkit-apkreader": "^3.2.4", - "backslash": "^0.2.0", "bplist-parser": "^0.3.2", "chalk": "^4.1.2", "cli-table": "^0.3.11", "dotenv": "^17.2.1", - "email-validator": "^2.0.4", "gradle-to-js": "2.0.1", "jsonwebtoken": "^9.0.2", "opener": "^1.5.2", @@ -25,10 +23,7 @@ "properties": "^1.2.1", "protobufjs": "^7.6.3", "semver": "^7.5.3", - "simctl": "^2.0.3", "superagent": "^8.0.9", - "which": "^1.2.7", - "wordwrap": "1.0.0", "xcode": "^3.0.1", "yargs": "^17.7.2", "yauzl": "^3.4.0", @@ -907,16 +902,6 @@ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, - "node_modules/backslash": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/backslash/-/backslash-0.2.0.tgz", - "integrity": "sha512-Avs+8FUZ1HF/VFP4YWwHQZSGzRPm37ukU1JQYQWijuHhtXdOuAzcZ8PcAzfIw898a8PyBzdn+RtnKA6MzW0X2A==" - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" - }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -970,16 +955,6 @@ "node": ">= 5.10.0" } }, - "node_modules/brace-expansion": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", - "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/browser-stdout": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", @@ -1137,11 +1112,6 @@ "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==" }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" - }, "node_modules/cookiejar": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", @@ -1268,14 +1238,6 @@ "safe-buffer": "^5.0.1" } }, - "node_modules/email-validator": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/email-validator/-/email-validator-2.0.4.tgz", - "integrity": "sha512-gYCwo7kh5S3IDyZPLZf6hSS0MnZT8QmJFqYvbqlDZSbwdZlY6QZWxJ4i/6UhITOJ4XzyI647Bm2MXKCLqnJ4nQ==", - "engines": { - "node": ">4.0" - } - }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -1674,11 +1636,6 @@ "url": "https://ko-fi.com/tunnckoCore/commissions" } }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" - }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -1732,25 +1689,6 @@ "node": ">= 0.4" } }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -1786,17 +1724,6 @@ "gradle-to-js": "cli.js" } }, - "node_modules/has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dependencies": { - "function-bind": "^1.1.1" - }, - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -1882,39 +1809,6 @@ "node": ">=0.8.19" } }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, - "node_modules/interpret": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz", - "integrity": "sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-core-module": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.12.1.tgz", - "integrity": "sha512-Q4ZuBAe2FUsKtyQJoQHlvP8OvBERxO3jEmy1I7hcRXcJBGGHFh/aJBswbXuS9sgrDH2QUO8ilkwNPHvHMd8clg==", - "dependencies": { - "has": "^1.0.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -1966,7 +1860,8 @@ "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true }, "node_modules/isstream": { "version": "0.1.2", @@ -2172,17 +2067,6 @@ "node": ">= 0.6" } }, - "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -2570,14 +2454,6 @@ "node": ">=8" } }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -2587,11 +2463,6 @@ "node": ">=8" } }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" - }, "node_modules/path-scurry": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", @@ -2797,17 +2668,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/rechoir": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", - "integrity": "sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==", - "dependencies": { - "resolve": "^1.1.6" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -2816,22 +2676,6 @@ "node": ">=0.10.0" } }, - "node_modules/resolve": { - "version": "1.22.2", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.2.tgz", - "integrity": "sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g==", - "dependencies": { - "is-core-module": "^2.11.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/revalidator": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/revalidator/-/revalidator-0.1.8.tgz", @@ -2901,22 +2745,6 @@ "node": ">=8" } }, - "node_modules/shelljs": { - "version": "0.8.5", - "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.8.5.tgz", - "integrity": "sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow==", - "dependencies": { - "glob": "^7.0.0", - "interpret": "^1.0.0", - "rechoir": "^0.6.2" - }, - "bin": { - "shjs": "bin/shjs" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/side-channel": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", @@ -2985,15 +2813,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/simctl": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/simctl/-/simctl-2.0.3.tgz", - "integrity": "sha512-kKCak0yszxHae5eVWcmrjV3ouUGac3sjlhjdLWpyPu4eiQcWoHsCrqS34kkgzHN8Ystqkh/LFjzrldk/g3BYJg==", - "dependencies": { - "shelljs": "^0.8.5", - "tail": "^0.4.0" - } - }, "node_modules/simple-plist": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/simple-plist/-/simple-plist-1.3.1.tgz", @@ -3128,25 +2947,6 @@ "node": ">=8" } }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/tail": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/tail/-/tail-0.4.0.tgz", - "integrity": "sha512-i5rOhX0PwkFSbjID14mmuoqrLUIqpJeBwg0esugSbb+6Y+dzgN/O3YZXzzPL7dnQJGbQLs8cwM8Zsak5kFJGcA==", - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -3270,18 +3070,6 @@ "uuid": "dist/esm/bin/uuid" } }, - "node_modules/which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "which": "bin/which" - } - }, "node_modules/winston": { "version": "2.4.7", "resolved": "https://registry.npmjs.org/winston/-/winston-2.4.7.tgz", @@ -3306,11 +3094,6 @@ "lodash": "^4.17.14" } }, - "node_modules/wordwrap": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", - "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==" - }, "node_modules/workerpool": { "version": "10.0.3", "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-10.0.3.tgz", diff --git a/package.json b/package.json index a27def5..e82dc7e 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,10 @@ "description": "Management CLI for the CodePush service", "scripts": { "start": "node ./bin/script/cli.js", - "build": "tsc", - "pretest": "node -e \"require('fs').rmSync('bin', { recursive: true, force: true })\"", + "clean": "node -e \"require('fs').rmSync('bin', { recursive: true, force: true })\"", + "build": "npm run clean && tsc", + "prepack": "npm run build", + "pretest": "npm run clean", "test": "tsc && node -e \"require('fs').cpSync('test/resources', 'bin/test/resources', { recursive: true })\" && mocha", "prettier": "prettier --write \"./**/*.ts\"", "format:check": "prettier --check \"script/**/*.ts\" \"test/**/*.ts\"", @@ -15,6 +17,9 @@ "bin": { "revopush": "./bin/script/cli.js" }, + "files": [ + "bin/script" + ], "repository": { "type": "git", "url": "https://github.com/revopush/code-push-cli" @@ -26,12 +31,10 @@ ], "dependencies": { "@devicefarmer/adbkit-apkreader": "^3.2.4", - "backslash": "^0.2.0", "bplist-parser": "^0.3.2", "chalk": "^4.1.2", "cli-table": "^0.3.11", "dotenv": "^17.2.1", - "email-validator": "^2.0.4", "gradle-to-js": "2.0.1", "jsonwebtoken": "^9.0.2", "opener": "^1.5.2", @@ -42,10 +45,7 @@ "properties": "^1.2.1", "protobufjs": "^7.6.3", "semver": "^7.5.3", - "simctl": "^2.0.3", "superagent": "^8.0.9", - "which": "^1.2.7", - "wordwrap": "1.0.0", "xcode": "^3.0.1", "yargs": "^17.7.2", "yauzl": "^3.4.0", diff --git a/script/auth/connection.ts b/script/auth/connection.ts index 2aafc85..f73d191 100644 --- a/script/auth/connection.ts +++ b/script/auth/connection.ts @@ -41,7 +41,6 @@ export function deserializeConnectionInfo(): ILoginConnectionInfo { }); let connectionInfo: ILegacyLoginConnectionInfo | ILoginConnectionInfo = JSON.parse(savedConnection); - // If the connection info is in the legacy format, convert it to the modern format if ((connectionInfo as ILegacyLoginConnectionInfo).accessKeyName) { connectionInfo = { accessKey: (connectionInfo as ILegacyLoginConnectionInfo).accessKeyName, @@ -75,12 +74,7 @@ export function serializeConnectionInfo(accessKey: string, preserveAccessKeyOnLo export function getSdk(accessKey: string, customServerUrl: string): AccountManager { const sdk: any = new AccountManager(accessKey, CLI_HEADERS, customServerUrl); - /* - * If the server returns `Unauthorized`, it must be due to an invalid - * (or expired) access key. For convenience, we patch every SDK call - * to delete the cached connection so the user can simply - * login again instead of having to log out first. - */ + // Unauthorized means the access key is invalid or expired: forget it, so the user can log in again without logging out Object.getOwnPropertyNames(AccountManager.prototype).forEach((functionName: any) => { if (typeof sdk[functionName] === "function") { const originalFunction = sdk[functionName]; diff --git a/script/command-executor.ts b/script/command-executor.ts index 146d854..607bab5 100644 --- a/script/command-executor.ts +++ b/script/command-executor.ts @@ -152,11 +152,6 @@ async function appRename(command: cli.IAppRenameCommand): Promise { log('Successfully renamed the "' + command.currentAppName + '" app to "' + command.newAppName + '".'); } -export const createEmptyTempReleaseFolder = async (folderPath: string): Promise => { - await deleteFolder(folderPath); - fs.mkdirSync(folderPath); -}; - async function appTransfer(command: cli.IAppTransferCommand): Promise { throwForInvalidEmail(command.email); @@ -195,18 +190,6 @@ async function removeCollaborator(command: cli.ICollaboratorRemoveCommand): Prom log("App collaborator removal cancelled."); } -function deleteFolder(folderPath: string): Promise { - return fs.promises.rm(folderPath, { recursive: true, force: true, maxRetries: 3 }); -} - -async function deleteReactTempFolders(): Promise { - const tempFolder: string = os.tmpdir(); - // An unreadable temp folder means there is nothing to delete - const names: string[] = await fs.promises.readdir(tempFolder).catch((): string[] => []); - const reactFolders: string[] = names.filter((name) => name.startsWith("react-")); - await Promise.all(reactFolders.map((name) => deleteFolder(path.join(tempFolder, name)))); -} - async function deploymentAdd(command: cli.IDeploymentAddCommand): Promise { const deployment: Deployment = await sdk.addDeployment(command.appName, command.deploymentName, command.key); log( @@ -236,9 +219,18 @@ async function deploymentHistoryClear(command: cli.IDeploymentHistoryClearComman log("Clear deployment cancelled."); } +function getTotalActiveFromDeploymentMetrics(metrics: DeploymentMetrics): number { + let totalActive = 0; + Object.keys(metrics).forEach((label: string) => { + totalActive += metrics[label].active; + }); + + return totalActive; +} + export const deploymentList = async (command: cli.IDeploymentListCommand, showPackage: boolean = true): Promise => { throwForInvalidOutputFormat(command.format); - const DEPLOYMENTS_MAX_LENGTH = 10; // do not take metrics if number of deployment higher than this + const DEPLOYMENTS_MAX_LENGTH = 10; // Metrics are skipped for apps with more deployments const deployments: Deployment[] = await sdk.getDeployments(command.appName); if (showPackage && deployments.length < DEPLOYMENTS_MAX_LENGTH) { @@ -447,20 +439,11 @@ export async function execute(command: cli.ICommand): Promise { return whoami(command); default: - // We should never see this message as invalid commands should be caught by the argument parser. + // Unreachable: the parser rejects unknown commands throw new Error("Invalid command: " + JSON.stringify(command)); } } -function getTotalActiveFromDeploymentMetrics(metrics: DeploymentMetrics): number { - let totalActive = 0; - Object.keys(metrics).forEach((label: string) => { - totalActive += metrics[label].active; - }); - - return totalActive; -} - function openLoginPage(serverUrl?: string): void { const hostname: string = os.hostname(); const url: string = `${serverUrl || AccountManager.APP_SERVER_URL}/cli-login?hostname=${hostname}`; @@ -488,11 +471,11 @@ async function login(command: cli.ILoginCommand): Promise { async function loginWithExternalAuthentication(apiServerUrl?: string, appServerUrl?: string): Promise { openLoginPage(appServerUrl); - log(""); // Insert newline + log(""); const accessKey: string = await requestAccessKey(); if (accessKey === null) { - // The user has aborted the synchronous prompt (e.g.: via [CTRL]+[C]). + // Cancelled with Ctrl+C return; } @@ -511,7 +494,7 @@ async function logout(_command: cli.ICommand): Promise { try { await sdk.removeSession(machineName); } catch (error) { - // If we are not authenticated or the session doesn't exist anymore, just swallow the error instead of displaying it + // Not logged in, or the session is already gone: nothing to remove if (error.statusCode !== AccountManager.ERROR_UNAUTHORIZED && error.statusCode !== AccountManager.ERROR_NOT_FOUND) { throw error; } @@ -522,21 +505,8 @@ async function logout(_command: cli.ICommand): Promise { deleteConnectionInfoCache(); } -async function getReactNativeProjectVersionInfo( - command: cli.IReleaseReactCommand, - projectName: string -): Promise { - log(chalk.cyan(`Detecting ${command.platform} app version:\n`)); - - if (command.platform === "ios") { - return getIosVersionInfo(command, projectName); - } else if (command.platform === "android") { - return getAndroidVersionInfo(command.gradleFile); - } -} - async function register(command: cli.IRegisterCommand): Promise { - return loginWithExternalAuthentication(command.serverUrl); + return loginWithExternalAuthentication(command.apiServerUrl, command.appServerUrl); } async function promote(command: cli.IPromoteCommand): Promise { @@ -577,8 +547,7 @@ async function patch(command: cli.IPatchCommand): Promise { buildNumber: command.buildNumber, // undefined = skip, null = reset to wildcard, string = retarget }; - // Standard fields use null as "not provided"; buildNumber uses undefined (null means reset). - // Check both to avoid treating an unset buildNumber (undefined) as a valid update. + // Other fields are null when not given; buildNumber is undefined, since null resets it const hasUpdate = Object.values(packageInfo).some((v) => v !== null && v !== undefined) || command.buildNumber !== undefined; if (!hasUpdate) { @@ -594,7 +563,7 @@ async function patch(command: cli.IPatchCommand): Promise { } export const release = async (command: cli.IReleaseCommand): Promise => { - // for initial release we explicitly define release as optional, disabled, without rollout, with a special description + // An initial release is always optional, disabled and without rollout const updateMetadata: PackageInfo = { description: command.initial ? `Zero release for v${command.appStoreVersion}` : command.description, isDisabled: command.initial ? true : command.disabled, @@ -608,6 +577,36 @@ export const release = async (command: cli.IReleaseCommand): Promise => { return doRelease(command, updateMetadata); }; +export const createEmptyTempReleaseFolder = async (folderPath: string): Promise => { + await deleteFolder(folderPath); + fs.mkdirSync(folderPath); +}; + +function deleteFolder(folderPath: string): Promise { + return fs.promises.rm(folderPath, { recursive: true, force: true, maxRetries: 3 }); +} + +async function deleteReactTempFolders(): Promise { + const tempFolder: string = os.tmpdir(); + // An unreadable temp folder means there is nothing to delete + const names: string[] = await fs.promises.readdir(tempFolder).catch((): string[] => []); + const reactFolders: string[] = names.filter((name) => name.startsWith("react-")); + await Promise.all(reactFolders.map((name) => deleteFolder(path.join(tempFolder, name)))); +} + +async function getReactNativeProjectVersionInfo( + command: cli.IReleaseReactCommand, + projectName: string +): Promise { + log(chalk.cyan(`Detecting ${command.platform} app version:\n`)); + + if (command.platform === "ios") { + return getIosVersionInfo(command, projectName); + } else if (command.platform === "android") { + return getAndroidVersionInfo(command.gradleFile); + } +} + const REQUIRED_OUTPUT_DIR_NAME = "CodePush"; interface ReactReleaseInputs { @@ -617,19 +616,16 @@ interface ReactReleaseInputs { projectName: string; } -// Up-front validation for the React-style release flows (release-react / release-expo). -// Throws on the first violation and returns the derived inputs. The semver-range check -// is done by the caller since it needs the resolved app version. +// Checks a release-react/release-expo command and derives its inputs. The version range is checked later, once it's known function validateReactReleaseCommand(command: cli.IReleaseReactCommand, options: { resolveEntryFile: boolean }): ReactReleaseInputs { const { resolveEntryFile } = options; const platform: string = (command.platform = command.platform.toLowerCase()); - // Only android and ios are supported. if (platform !== "android" && platform !== "ios") { throw new Error('Platform must be either "android" or "ios".'); } - // Diff updates require the package to live under a "CodePush" folder. + // Diff updates require the package to live under a "CodePush" folder if (command.outputDir && path.basename(command.outputDir) !== REQUIRED_OUTPUT_DIR_NAME) { throw new Error( `The "--outputDir" path must end with a folder named "${REQUIRED_OUTPUT_DIR_NAME}" ` + @@ -637,7 +633,6 @@ function validateReactReleaseCommand(command: cli.IReleaseReactCommand, options: ); } - // The command must run inside a React Native project. let projectPackageJson: any; try { projectPackageJson = require(path.join(process.cwd(), "package.json")); @@ -655,7 +650,6 @@ function validateReactReleaseCommand(command: cli.IReleaseReactCommand, options: throw new Error("The project in the CWD is not a React Native project."); } - // Resolve and validate the JS entry file (only flows that bundle locally need it). let entryFile: string = command.entryFile; if (resolveEntryFile) { if (!entryFile) { @@ -671,7 +665,6 @@ function validateReactReleaseCommand(command: cli.IReleaseReactCommand, options: } } - // Default the bundle name from the platform when the user did not pass one. const bundleName: string = command.bundleName || (platform === "ios" ? "main.jsbundle" : `index.${platform}.bundle`); return { platform, bundleName, entryFile, projectName }; @@ -779,20 +772,17 @@ async function releaseReactBundle(command: cli.IReleaseReactCommand, bundler: Re const outputFolder: string = command.outputDir || path.join(os.tmpdir(), "CodePush"); const sourcemapOutputFolder: string = command.sourcemapOutput || path.join(os.tmpdir(), "CodePushSourceMap"); const baseReleaseTmpFolder: string = path.join(os.tmpdir(), "CodePushBaseRelease"); - // releaseCommand below is `command` itself, and it overwrites command.outputDir with the - // resolved path -- so capture whether the user actually passed one before that happens. + // Read before command.outputDir is overwritten below const isTempOutputFolder: boolean = !command.outputDir; const releaseCommand: cli.IReleaseReactCommand = command; releaseCommand.package = outputFolder; releaseCommand.outputDir = outputFolder; releaseCommand.bundleName = bundleName; - // Check that the app and deployment exist before releasing an update. - // This validation helps to save about 1 minute or more in case user has typed wrong app or deployment name. + // Fail fast on a wrong app or deployment name, before the slow bundling await sdk.getDeployment(command.appName, command.deploymentName); - // buildNumber is NOT auto-detected here — it must be passed explicitly via --buildNumber. - // Auto-detection only applies to release-native where the binary build number is the natural targeting key. + // Only release-native detects the build number; here it comes from --buildNumber const appVersion: string = command.appStoreVersion || (await getReactNativeProjectVersionInfo(command, projectName)).appVersion; if (!command.sourcemapOutput) { @@ -803,8 +793,7 @@ async function releaseReactBundle(command: cli.IReleaseReactCommand, bundler: Re releaseCommand.appStoreVersion = appVersion; await createEmptyTempReleaseFolder(outputFolder); - // This is needed to clear the react native bundler cache: - // https://github.com/facebook/react-native/issues/4289 + // Clears the React Native bundler cache: https://github.com/facebook/react-native/issues/4289 await deleteReactTempFolders(); const { isHermes, sourceMaps, bundleOptions } = await planBundle(command, outputFolder, bundleName, sourcemapOutputFolder); @@ -845,15 +834,12 @@ export const releaseNative = async (command: cli.IReleaseNativeCommand): Promise const targetBinaryPath: string = command.targetBinary; const outputFolder: string = command.outputDir || path.join(os.tmpdir(), "CodePush"); const extractFolder: string = path.join(os.tmpdir(), "CodePushBinaryExtract"); - // Validate platform if (platform !== "ios" && platform !== "android") { throw new Error('Platform must be either "ios" or "android" for the "release-native" command.'); } - // Validate target binary file exists if (!fileExists(targetBinaryPath)) { throw new Error(`Target binary file "${targetBinaryPath}" does not exist.`); } - // Validate file extension matches platform const targetBinaryPathNormalised = targetBinaryPath.toLowerCase(); if (platform === "ios" && !targetBinaryPathNormalised.endsWith(".ipa")) { throw new Error("For iOS platform, target binary must be an .ipa file."); @@ -864,6 +850,7 @@ export const releaseNative = async (command: cli.IReleaseNativeCommand): Promise await sdk.getDeployment(command.appName, command.deploymentName); + let releaseCommandPartial: Partial; try { await createEmptyTempReleaseFolder(outputFolder); await createEmptyTempReleaseFolder(extractFolder); @@ -871,7 +858,6 @@ export const releaseNative = async (command: cli.IReleaseNativeCommand): Promise if (!bundleName) { bundleName = platform === "ios" ? "main.jsbundle" : `index.android.bundle`; } - let releaseCommandPartial: Partial; if (platform === "ios") { log(chalk.cyan(`\nReading IPA file:\n`)); @@ -931,18 +917,19 @@ export const releaseNative = async (command: cli.IReleaseNativeCommand): Promise }; await doNativeRelease(releaseCommand); - if (fs.existsSync(releaseCommandPartial.package)) { - fs.unlinkSync(releaseCommandPartial.package); - } } finally { try { await deleteFolder(extractFolder); await deleteFolder(outputFolder); + // The upload deletes the metadata zip, but not when the release fails before uploading + if (releaseCommandPartial?.package) { + await fs.promises.rm(releaseCommandPartial.package, { force: true }); + } } catch {} } }; -// For an initial release we explicitly define it as optional, disabled, without rollout, with a special description +// An initial release is always optional, disabled and without rollout function getReactNativePackageInfo(command: cli.IReleaseReactCommand): ReactNativePackageInfo { return { description: command.initial ? `Zero release for v${command.appStoreVersion}` : command.description, diff --git a/script/commands/debug.ts b/script/commands/debug.ts index 3a5168c..c84e7d5 100644 --- a/script/commands/debug.ts +++ b/script/commands/debug.ts @@ -3,10 +3,6 @@ import * as childProcess from "child_process"; import * as cli from "../../script/types/cli"; -import * as path from "path"; - -const simctl = require("simctl"); -const which = require("which"); interface IDebugPlatform { getLogProcess(): any; @@ -15,9 +11,7 @@ interface IDebugPlatform { class AndroidDebugPlatform implements IDebugPlatform { public getLogProcess(): any { - try { - which.sync("adb"); - } catch { + if (childProcess.spawnSync("adb", ["version"]).error) { throw new Error("ADB command not found. Please ensure it is installed and available on your path."); } @@ -26,10 +20,7 @@ class AndroidDebugPlatform implements IDebugPlatform { throw new Error("No Android devices found. Re-run this command after starting one."); } - // For now there is no ability to specify device for debug like: - // code-push debug android "192.168.121.102:5555" - // So we have to throw an error in case more than 1 android device was attached - // otherwise we will very likely run into an exception while trying to read ‘adb logcat’ from device which codepushified app is not running on. + // adb logcat can't be pointed at one device here, so it would read whichever adb picks if (numberOfAvailableDevices > 1) { throw new Error(`Found "${numberOfAvailableDevices}" android devices. Please leave only one device you need to debug.`); } @@ -37,43 +28,27 @@ class AndroidDebugPlatform implements IDebugPlatform { return childProcess.spawn("adb", ["logcat"]); } - // The following is an example of what the output looks - // like when running the "adb devices" command. - // - // List of devices attached - // emulator-5554 device - // 192.168.121.102:5555 device + // Counts the " device" lines of `adb devices` private getNumberOfAvailableDevices(): number { const output = childProcess.execSync("adb devices").toString(); - const matches = output.match(/\b(device)\b/gim); - if (matches !== null) { - return matches.length; - } - return 0; + return output.match(/\b(device)\b/gim)?.length ?? 0; } public normalizeLogMessage(message: string): string { - // Check to see whether the message includes the source URL - // suffix, and if so, strip it. This can occur in Android Cordova apps. + // Android Cordova apps append the source URL const sourceURLIndex: number = message.indexOf('", source: file:///'); - if (~sourceURLIndex) { - return message.substring(0, sourceURLIndex); - } else { - return message; - } + return sourceURLIndex === -1 ? message : message.substring(0, sourceURLIndex); } } class iOSDebugPlatform implements IDebugPlatform { private getSimulatorID(): string { - const output: any = simctl.list({ devices: true, silent: true }); - const simulators: string[] = output.json.devices - .map((platform: any) => platform.devices) - .reduce((prev: any, next: any) => prev.concat(next)) - .filter((device: any) => device.state === "Booted") - .map((device: any) => device.id); - - return simulators[0]; + // Devices are grouped by runtime + const { devices } = JSON.parse(childProcess.execFileSync("xcrun", ["simctl", "list", "devices", "--json"], { encoding: "utf8" })); + const booted: any = Object.values(devices) + .flat() + .find((device: any) => device.state === "Booted"); + return booted?.udid; } public getLogProcess(): any { @@ -86,8 +61,20 @@ class iOSDebugPlatform implements IDebugPlatform { throw new Error("No iOS simulators found. Re-run this command after starting one."); } - const logFilePath: string = path.join(process.env.HOME, "Library/Logs/CoreSimulator", simulatorID, "system.log"); - return childProcess.spawn("tail", ["-f", logFilePath]); + // Simulators no longer write system.log; their logs are read through unified logging + return childProcess.spawn("xcrun", [ + "simctl", + "spawn", + simulatorID, + "log", + "stream", + "--level", + "debug", + "--style", + "compact", + "--predicate", + 'eventMessage CONTAINS "[CodePush]"', + ]); } public normalizeLogMessage(message: string): string { @@ -102,12 +89,7 @@ function processLogData(logData: Buffer) { .split("\n") .filter((line: string) => line.indexOf(logMessagePrefix) > -1) .map((line: string) => { - // Allow the current platform - // to normalize the message first. line = this.normalizeLogMessage(line); - - // Strip the CodePush-specific, platform agnostic - // log message prefix that is added to each entry. const message = line.substring(line.indexOf(logMessagePrefix) + logMessagePrefix.length); const timeStamp = new Date().toLocaleTimeString("en-US", { hourCycle: "h23" }); @@ -136,9 +118,12 @@ export default function (command: cli.IDebugCommand): Promise { console.log(`Listening for ${platform} debug logs (Press CTRL+C to exit)`); logProcess.stdout.on("data", processLogData.bind(debugPlatform)); - logProcess.stderr.on("data", reject); - - logProcess.on("close", resolve); + // Log tools also print warnings to stderr, so only a failed exit ends the command with an error + logProcess.stderr.on("data", (data: Buffer) => console.error(data.toString().trim())); + logProcess.on("error", reject); + logProcess.on("close", (exitCode: number | null) => + exitCode ? reject(new Error(`Reading ${platform} debug logs failed (exit code ${exitCode}).`)) : resolve() + ); } catch (e) { reject(e); } diff --git a/script/management-sdk.ts b/script/management-sdk.ts index 715c51d..7bde069 100644 --- a/script/management-sdk.ts +++ b/script/management-sdk.ts @@ -571,7 +571,7 @@ class AccountManager { request.set("Accept", `application/vnd.code-push.v${AccountManager.API_VERSION}+json`); request.set("Authorization", `Bearer ${this._accessKey}`); - request.set("X-CodePush-SDK-Version", packageJson.version); // TODO get version differently without require("../../package.json"); + request.set("X-CodePush-SDK-Version", packageJson.version); } } diff --git a/script/parser/commands.ts b/script/parser/commands.ts index 12594a3..fcd6afe 100644 --- a/script/parser/commands.ts +++ b/script/parser/commands.ts @@ -8,8 +8,7 @@ const packageJson = require("../../../package.json"); const ROLLOUT_PERCENTAGE_REGEX: RegExp = /^(100|[1-9][0-9]|[1-9])%?$/; const USAGE_PREFIX = "Usage: revopush"; -// Command categories are: access-key, app, release, deployment, deployment-key, login, logout, register -// Commands are the verb following the command category (e.g.: "add" in "app add"). +// Set by every subcommand's builder; a category's `.check` uses it to reject an unknown subcommand (e.g. "app foo") let isValidCommand = false; let wasHelpShown = false; @@ -257,7 +256,7 @@ function accessKeyAdd(commandName: string, yargs: yargs.Argv): void { isValidCommand = true; yargs .usage(USAGE_PREFIX + " access-key " + commandName + " ") - .demand(/*count*/ 1, /*max*/ 1) // Require exactly one non-option arguments + .demand(/*count*/ 1, /*max*/ 1) .example( "access-key " + commandName + ' "VSTS Integration"', 'Creates a new access key with the name "VSTS Integration", which expires in 60 days' @@ -280,7 +279,7 @@ function accessKeyPatch(commandName: string, yargs: yargs.Argv): void { isValidCommand = true; yargs .usage(USAGE_PREFIX + " access-key " + commandName + " ") - .demand(/*count*/ 1, /*max*/ 1) // Require exactly one non-option arguments + .demand(/*count*/ 1, /*max*/ 1) .example( "access-key " + commandName + ' "Key for build server" --name "Key for CI machine"', 'Renames the access key named "Key for build server" to "Key for CI machine"' @@ -325,7 +324,7 @@ function accessKeyRemove(commandName: string, yargs: yargs.Argv): void { isValidCommand = true; yargs .usage(USAGE_PREFIX + " access-key " + commandName + " ") - .demand(/*count*/ 1, /*max*/ 1) // Require exactly one non-option arguments + .demand(/*count*/ 1, /*max*/ 1) .example("access-key " + commandName + ' "VSTS Integration"', 'Removes the "VSTS Integration" access key'); addCommonConfiguration(yargs); @@ -352,7 +351,7 @@ function appRemove(commandName: string, yargs: yargs.Argv): void { isValidCommand = true; yargs .usage(USAGE_PREFIX + " app " + commandName + " ") - .demand(/*count*/ 1, /*max*/ 1) // Require exactly one non-option arguments + .demand(/*count*/ 1, /*max*/ 1) .example("app " + commandName + " MyApp", 'Removes app "MyApp"'); addCommonConfiguration(yargs); @@ -362,7 +361,7 @@ function listCollaborators(commandName: string, yargs: yargs.Argv): void { isValidCommand = true; yargs .usage(USAGE_PREFIX + " collaborator " + commandName + " [options]") - .demand(/*count*/ 1, /*max*/ 1) // Require exactly one non-option arguments + .demand(/*count*/ 1, /*max*/ 1) .example("collaborator " + commandName + " MyApp", 'Lists the collaborators for app "MyApp" in tabular format') .example("collaborator " + commandName + " MyApp --format json", 'Lists the collaborators for app "MyApp" in JSON format') .option("format", { @@ -379,7 +378,7 @@ function removeCollaborator(commandName: string, yargs: yargs.Argv): void { isValidCommand = true; yargs .usage(USAGE_PREFIX + " collaborator " + commandName + " ") - .demand(/*count*/ 2, /*max*/ 2) // Require exactly two non-option arguments + .demand(/*count*/ 2, /*max*/ 2) .example("collaborator " + commandName + " MyApp foo@bar.com", 'Removes foo@bar.com as a collaborator from app "MyApp"'); addCommonConfiguration(yargs); @@ -406,7 +405,7 @@ function sessionRemove(commandName: string, yargs: yargs.Argv): void { isValidCommand = true; yargs .usage(USAGE_PREFIX + " session " + commandName + " ") - .demand(/*count*/ 1, /*max*/ 1) // Require exactly one non-option arguments + .demand(/*count*/ 1, /*max*/ 1) .example("session " + commandName + ' "John\'s PC"', 'Removes the existing login session from "John\'s PC"'); addCommonConfiguration(yargs); @@ -416,7 +415,7 @@ function deploymentHistoryClear(commandName: string, yargs: yargs.Argv): void { isValidCommand = true; yargs .usage(USAGE_PREFIX + " deployment " + commandName + " ") - .demand(/*count*/ 2, /*max*/ 2) // Require exactly two non-option arguments + .demand(/*count*/ 2, /*max*/ 2) .example( "deployment " + commandName + " MyApp MyDeployment", 'Clears the release history associated with deployment "MyDeployment" from app "MyApp"' @@ -429,7 +428,7 @@ function deploymentList(commandName: string, yargs: yargs.Argv): void { isValidCommand = true; yargs .usage(USAGE_PREFIX + " deployment " + commandName + " [options]") - .demand(/*count*/ 1, /*max*/ 1) // Require exactly one non-option arguments + .demand(/*count*/ 1, /*max*/ 1) .example("deployment " + commandName + " MyApp", 'Lists the deployments for app "MyApp" in tabular format') .example("deployment " + commandName + " MyApp --format json", 'Lists the deployments for app "MyApp" in JSON format') .option("format", { @@ -452,7 +451,7 @@ function deploymentRemove(commandName: string, yargs: yargs.Argv): void { isValidCommand = true; yargs .usage(USAGE_PREFIX + " deployment " + commandName + " ") - .demand(/*count*/ 2, /*max*/ 2) // Require exactly two non-option arguments + .demand(/*count*/ 2, /*max*/ 2) .example("deployment " + commandName + " MyApp MyDeployment", 'Removes deployment "MyDeployment" from app "MyApp"') .option("force", { default: false, @@ -468,7 +467,7 @@ function deploymentHistory(commandName: string, yargs: yargs.Argv): void { isValidCommand = true; yargs .usage(USAGE_PREFIX + " deployment " + commandName + " [options]") - .demand(/*count*/ 2, /*max*/ 2) // Require exactly two non-option arguments + .demand(/*count*/ 2, /*max*/ 2) .example( "deployment " + commandName + " MyApp MyDeployment", 'Displays the release history for deployment "MyDeployment" from app "MyApp" in tabular format' @@ -496,30 +495,30 @@ function deploymentHistory(commandName: string, yargs: yargs.Argv): void { yargs .usage(USAGE_PREFIX + " ") - .demand(/*count*/ 1, /*max*/ 1) // Require exactly one non-option argument. + .demand(/*count*/ 1, /*max*/ 1) .command("access-key", "View and manage the access keys associated with your account", (yargs: yargs.Argv) => { yargs .usage(USAGE_PREFIX + " access-key ") - .demand(/*count*/ 2, /*max*/ 2) // Require exactly two non-option arguments. + .demand(/*count*/ 2, /*max*/ 2) .command("add", "Create a new access key associated with your account", (yargs: yargs.Argv) => accessKeyAdd("add", yargs)) .command("patch", "Update the name and/or TTL of an existing access key", (yargs: yargs.Argv) => accessKeyPatch("patch", yargs)) .command("remove", "Remove an existing access key", (yargs: yargs.Argv) => accessKeyRemove("remove", yargs)) .command("rm", "Remove an existing access key", (yargs: yargs.Argv) => accessKeyRemove("rm", yargs)) .command("list", "List the access keys associated with your account", (yargs: yargs.Argv) => accessKeyList("list", yargs)) .command("ls", "List the access keys associated with your account", (yargs: yargs.Argv) => accessKeyList("ls", yargs)) - .check((): any => isValidCommand); // Report unrecognized, non-hyphenated command category. + .check((): any => isValidCommand); addCommonConfiguration(yargs); }) .command("app", "View and manage your CodePush apps", (yargs: yargs.Argv) => { yargs .usage(USAGE_PREFIX + " app ") - .demand(/*count*/ 2, /*max*/ 2) // Require exactly two non-option arguments. + .demand(/*count*/ 2, /*max*/ 2) .command("add", "Add a new app to your account", (yargs: yargs.Argv): void => { isValidCommand = true; yargs .usage(USAGE_PREFIX + " app add ") - .demand(/*count*/ 1, /*max*/ 1) // Require exactly one non-option arguments + .demand(/*count*/ 1, /*max*/ 1) .example("app add MyApp", 'Adds app "MyApp"'); addCommonConfiguration(yargs); @@ -530,7 +529,7 @@ yargs isValidCommand = true; yargs .usage(USAGE_PREFIX + " app rename ") - .demand(/*count*/ 2, /*max*/ 2) // Require exactly two non-option arguments + .demand(/*count*/ 2, /*max*/ 2) .example("app rename CurrentName NewName", 'Renames app "CurrentName" to "NewName"'); addCommonConfiguration(yargs); @@ -541,24 +540,24 @@ yargs isValidCommand = true; yargs .usage(USAGE_PREFIX + " app transfer ") - .demand(/*count*/ 2, /*max*/ 2) // Require exactly two non-option arguments + .demand(/*count*/ 2, /*max*/ 2) .example("app transfer MyApp foo@bar.com", 'Transfers the ownership of app "MyApp" to an account with email "foo@bar.com"'); addCommonConfiguration(yargs); }) - .check((): any => isValidCommand); // Report unrecognized, non-hyphenated command category. + .check((): any => isValidCommand); addCommonConfiguration(yargs); }) .command("collaborator", "View and manage app collaborators", (yargs: yargs.Argv) => { yargs .usage(USAGE_PREFIX + " collaborator ") - .demand(/*count*/ 2, /*max*/ 2) // Require exactly two non-option arguments. + .demand(/*count*/ 2, /*max*/ 2) .command("add", "Add a new collaborator to an app", (yargs: yargs.Argv): void => { isValidCommand = true; yargs .usage(USAGE_PREFIX + " collaborator add ") - .demand(/*count*/ 2, /*max*/ 2) // Require exactly two non-option arguments + .demand(/*count*/ 2, /*max*/ 2) .example("collaborator add MyApp foo@bar.com", 'Adds foo@bar.com as a collaborator to app "MyApp"'); addCommonConfiguration(yargs); @@ -567,7 +566,7 @@ yargs .command("rm", "Remove a collaborator from an app", (yargs: yargs.Argv) => removeCollaborator("rm", yargs)) .command("list", "List the collaborators for an app", (yargs: yargs.Argv) => listCollaborators("list", yargs)) .command("ls", "List the collaborators for an app", (yargs: yargs.Argv) => listCollaborators("ls", yargs)) - .check((): any => isValidCommand); // Report unrecognized, non-hyphenated command category. + .check((): any => isValidCommand); addCommonConfiguration(yargs); }) @@ -575,7 +574,7 @@ yargs isValidCommand = true; yargs .usage(USAGE_PREFIX + " debug ") - .demand(/*count*/ 1, /*max*/ 1) // Require exactly one non-option arguments + .demand(/*count*/ 1, /*max*/ 1) .example("debug android", "View the CodePush debug logs for an Android emulator or device") .example("debug ios", "View the CodePush debug logs for the iOS simulator"); @@ -584,12 +583,12 @@ yargs .command("deployment", "View and manage your app deployments", (yargs: yargs.Argv) => { yargs .usage(USAGE_PREFIX + " deployment ") - .demand(/*count*/ 2, /*max*/ 2) // Require exactly two non-option arguments. + .demand(/*count*/ 2, /*max*/ 2) .command("add", "Add a new deployment to an app", (yargs: yargs.Argv): void => { isValidCommand = true; yargs .usage(USAGE_PREFIX + " deployment add ") - .demand(/*count*/ 2, /*max*/ 2) // Require exactly two non-option arguments + .demand(/*count*/ 2, /*max*/ 2) .example("deployment add MyApp MyDeployment", 'Adds deployment "MyDeployment" to app "MyApp"') .example("deployment add MyApp MyDeployment -k abc123", 'Adds deployment key "abc123"') .option("key", { @@ -610,7 +609,7 @@ yargs isValidCommand = true; yargs .usage(USAGE_PREFIX + " deployment rename ") - .demand(/*count*/ 3, /*max*/ 3) // Require exactly three non-option arguments + .demand(/*count*/ 3, /*max*/ 3) .example( "deployment rename MyApp CurrentDeploymentName NewDeploymentName", 'Renames deployment "CurrentDeploymentName" to "NewDeploymentName"' @@ -622,7 +621,7 @@ yargs .command("ls", "List the deployments associated with an app", (yargs: yargs.Argv) => deploymentList("ls", 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)) - .check((): any => isValidCommand); // Report unrecognized, non-hyphenated command category. + .check((): any => isValidCommand); addCommonConfiguration(yargs); }) @@ -630,9 +629,9 @@ yargs isValidCommand = true; yargs .usage(USAGE_PREFIX + " link") - .demand(/!*count*!/ 0, /!*max*!/ 1) //set 'max' to one to allow usage of serverUrl undocument parameter for testing + .demand(/!*count*!/ 0, /!*max*!/ 1) // The server URL arguments are undocumented, for testing .example("link", "Links an account on the CodePush server") - .check((): any => isValidCommand); // Report unrecognized, non-hyphenated command category. + .check((): any => isValidCommand); addCommonConfiguration(yargs); })*/ @@ -640,7 +639,7 @@ yargs isValidCommand = true; yargs .usage(USAGE_PREFIX + " login [options]") - .demand(/*count*/ 0, /*max*/ 2) //set 'max' to one to allow usage of serverUrl undocument parameter for testing + .demand(/*count*/ 0, /*max*/ 2) // The server URL arguments are undocumented, for testing .example("login", "Logs in to the CodePush server") .example("login --accessKey mykey", 'Logs in on behalf of the user who owns and created the access key "mykey"') .option("accessKey", { @@ -651,7 +650,7 @@ yargs "Access key to authenticate against the CodePush server with, instead of providing your username and password credentials", type: "string", }) - .check((): any => isValidCommand); // Report unrecognized, non-hyphenated command category. + .check((): any => isValidCommand); addCommonConfiguration(yargs); }) @@ -666,7 +665,7 @@ yargs .command("patch", "Update the metadata for an existing release", (yargs: yargs.Argv) => { yargs .usage(USAGE_PREFIX + " patch [options]") - .demand(/*count*/ 2, /*max*/ 2) // Require exactly two non-option arguments + .demand(/*count*/ 2, /*max*/ 2) .example( 'patch MyApp Production --des "Updated description" -r 50%', 'Updates the description of the latest release for "MyApp" app\'s "Production" deployment and updates the rollout value to 50%' @@ -735,7 +734,7 @@ yargs .command("promote", "Promote the latest release from one app deployment to another", (yargs: yargs.Argv) => { yargs .usage(USAGE_PREFIX + " promote [options]") - .demand(/*count*/ 3, /*max*/ 3) // Require exactly three non-option arguments + .demand(/*count*/ 3, /*max*/ 3) .example( "promote MyApp Staging Production", 'Promotes the latest release within the "Staging" deployment of "MyApp" to "Production"' @@ -807,16 +806,16 @@ yargs isValidCommand = true; yargs .usage(USAGE_PREFIX + " register") - .demand(/*count*/ 0, /*max*/ 1) //set 'max' to one to allow usage of serverUrl undocument parameter for testing + .demand(/*count*/ 0, /*max*/ 2) // The server URL arguments are undocumented, for testing .example("register", "Registers a new CodePush account") - .check((): any => isValidCommand); // Report unrecognized, non-hyphenated command category. + .check((): any => isValidCommand); addCommonConfiguration(yargs); }) .command("release", "Release an update to an app deployment", (yargs: yargs.Argv) => { yargs .usage(USAGE_PREFIX + " release [options]") - .demand(/*count*/ 3, /*max*/ 3) // Require exactly three non-option arguments. + .demand(/*count*/ 3, /*max*/ 3) .example( 'release MyApp app.js "*"', 'Releases the "app.js" file to the "MyApp" app\'s "Staging" deployment, targeting any binary version using the "*" wildcard range syntax.' @@ -888,7 +887,7 @@ yargs .command("release-react", "Release a React Native update to an app deployment", (yargs: yargs.Argv) => { yargs .usage(USAGE_PREFIX + " release-react [options]") - .demand(/*count*/ 2, /*max*/ 2) // Require exactly two non-option arguments + .demand(/*count*/ 2, /*max*/ 2) .example( "release-react MyApp ios", 'Releases the React Native iOS project in the current working directory to the "MyApp" app\'s "Staging" deployment' @@ -907,7 +906,7 @@ yargs yargs .usage(USAGE_PREFIX + " release-expo [options]") - .demand(/*count*/ 2, /*max*/ 2) // Require exactly two non-option arguments + .demand(/*count*/ 2, /*max*/ 2) .example( "release-expo MyApp ios", 'Releases the Expo-managed iOS project in the current working directory to the "MyApp" app\'s "Staging" deployment' @@ -924,7 +923,7 @@ yargs .command("release-native", "Release a binary update to an app deployment", (yargs: yargs.Argv) => { yargs .usage(USAGE_PREFIX + " release-native [options]") - .demand(/*count*/ 3, /*max*/ 3) // Require exactly three non-option arguments + .demand(/*count*/ 3, /*max*/ 3) .example( "release-native MyApp ios ./app.ipa", 'Releases the React Native iOS project from "./app.ipa" to the "MyApp" app\'s "Staging" deployment' @@ -964,7 +963,7 @@ yargs .command("rollback", "Rollback the latest release for an app deployment", (yargs: yargs.Argv) => { yargs .usage(USAGE_PREFIX + " rollback [options]") - .demand(/*count*/ 2, /*max*/ 2) // Require exactly two non-option arguments + .demand(/*count*/ 2, /*max*/ 2) .example("rollback MyApp Production", 'Performs a rollback on the "Production" deployment of "MyApp"') .example( "rollback MyApp Production --targetRelease v4", @@ -984,14 +983,14 @@ yargs .command("session", "View and manage the current login sessions associated with your account", (yargs: yargs.Argv) => { yargs .usage(USAGE_PREFIX + " session ") - .demand(/*count*/ 2, /*max*/ 2) // Require exactly two non-option arguments. + .demand(/*count*/ 2, /*max*/ 2) .command("remove", "Remove an existing login session", (yargs: yargs.Argv) => sessionRemove("remove", yargs)) .command("rm", "Remove an existing login session", (yargs: yargs.Argv) => sessionRemove("rm", yargs)) .command("list", "List the current login sessions associated with your account", (yargs: yargs.Argv) => sessionList("list", yargs) ) .command("ls", "List the current login sessions associated with your account", (yargs: yargs.Argv) => sessionList("ls", yargs)) - .check((): any => isValidCommand); // Report unrecognized, non-hyphenated command category. + .check((): any => isValidCommand); addCommonConfiguration(yargs); }) diff --git a/script/parser/create-command.ts b/script/parser/create-command.ts index e57c32e..4b090b9 100644 --- a/script/parser/create-command.ts +++ b/script/parser/create-command.ts @@ -3,7 +3,6 @@ import * as yargs from "yargs"; import * as cli from "../types/cli"; -import backslash = require("backslash"); import parseDuration = require("parse-duration"); // Importing this module defines the commands with yargs, which must happen before argv is parsed import { isHelpShown } from "./commands"; @@ -14,7 +13,6 @@ export function createCommand(): cli.ICommand { const argv = yargs.parseSync(); if (!isHelpShown() && argv._ && argv._.length > 0) { - // Create a command object const arg0: any = argv._[0]; const arg1: any = argv._[1]; const arg2: any = argv._[2]; @@ -275,7 +273,7 @@ export function createCommand(): cli.ICommand { patchCommand.deploymentName = arg2; patchCommand.label = argv["label"] as any; // Description must be set to null to indicate that it is not being patched. - patchCommand.description = argv["description"] ? backslash(argv["description"]) : null; + patchCommand.description = argv["description"] ? unescapeBackslashes(argv["description"] as string) : null; patchCommand.disabled = argv["disabled"] as any; patchCommand.mandatory = argv["mandatory"] as any; patchCommand.rollout = getRolloutValue(argv["rollout"] as any); @@ -294,7 +292,7 @@ export function createCommand(): cli.ICommand { deploymentPromoteCommand.appName = arg1; deploymentPromoteCommand.sourceDeploymentName = arg2; deploymentPromoteCommand.destDeploymentName = arg3; - deploymentPromoteCommand.description = argv["description"] ? backslash(argv["description"]) : ""; + deploymentPromoteCommand.description = argv["description"] ? unescapeBackslashes(argv["description"] as string) : ""; deploymentPromoteCommand.label = argv["label"] as any; deploymentPromoteCommand.disabled = argv["disabled"] as any; deploymentPromoteCommand.mandatory = argv["mandatory"] as any; @@ -309,7 +307,8 @@ export function createCommand(): cli.ICommand { const registerCommand = cmd as cli.IRegisterCommand; - registerCommand.serverUrl = getServerUrl(arg1); + registerCommand.apiServerUrl = getServerUrl(arg1); + registerCommand.appServerUrl = getServerUrl(arg2); break; case "release": @@ -323,7 +322,7 @@ export function createCommand(): cli.ICommand { releaseCommand.appStoreVersion = arg3; releaseCommand.buildNumber = argv["buildNumber"] as any; releaseCommand.deploymentName = argv["deploymentName"] as any; - releaseCommand.description = argv["description"] ? backslash(argv["description"]) : ""; + releaseCommand.description = argv["description"] ? unescapeBackslashes(argv["description"] as string) : ""; releaseCommand.disabled = argv["disabled"] as any; releaseCommand.mandatory = argv["mandatory"] as any; releaseCommand.noDuplicateReleaseError = argv["noDuplicateReleaseError"] as any; @@ -358,8 +357,6 @@ export function createCommand(): cli.ICommand { releaseBinaryCommand.initial = true; releaseBinaryCommand.disabled = true; releaseBinaryCommand.mandatory = false; - // TODO add support for releaseBinaryCommand.bundleName - // TODO add support for releaseBinaryCommand.outputDir } break; @@ -420,7 +417,7 @@ function getReleaseReactCommand( releaseReactCommand.bundleName = argv["bundleName"] as any; releaseReactCommand.deploymentName = argv["deploymentName"] as any; releaseReactCommand.disabled = argv["disabled"] as any; - releaseReactCommand.description = argv["description"] ? backslash(argv["description"]) : ""; + releaseReactCommand.description = argv["description"] ? unescapeBackslashes(argv["description"] as string) : ""; releaseReactCommand.development = argv["development"] as any; releaseReactCommand.entryFile = argv["entryFile"] as any; releaseReactCommand.gradleFile = argv["gradleFile"] as any; @@ -472,3 +469,19 @@ function parseDurationMilliseconds(durationString: string): number { // handling of unparseable input (v2 returns null instead of 0). return Math.floor(parseDuration.default(durationString) ?? 0); } + +const ESCAPES: Record = { b: "\b", f: "\f", n: "\n", r: "\r", t: "\t", v: "\v" }; + +/** Turns escapes typed in a description, like \n, \101 or \u00e9, into the characters they stand for, as the backslash package did */ +function unescapeBackslashes(text: string | string[]): string { + // A repeated option is an array, which the backslash package joined unchanged + if (typeof text !== "string") { + return Array.isArray(text) ? text.join("") : ""; + } + // Octal escapes take up to three digits; a backslash at the very end is dropped + return text.replace(/\\(u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|[0-3][0-7]{0,2}|[4-7][0-7]?|[\s\S]?)/g, (_, escape: string) => { + if (/^[0-7]/.test(escape)) return String.fromCharCode(parseInt(escape, 8)); + if (escape.length > 1) return String.fromCharCode(parseInt(escape.slice(1), 16)); + return ESCAPES[escape] ?? escape; + }); +} diff --git a/script/platforms/android/binary.ts b/script/platforms/android/binary.ts index c46e598..069ca13 100644 --- a/script/platforms/android/binary.ts +++ b/script/platforms/android/binary.ts @@ -25,10 +25,8 @@ export async function extractMetadataFromAndroid(extractFolder, outputFolder) { log(chalk.yellow(`\nWarning: CodepushMetadata file not found in APK. Check used version of SDK\n`)); } - // Get index.android.bundle from root of app folder const mainJsBundlePath = path.join(assetsFolder, "index.android.bundle"); if (fs.existsSync(mainJsBundlePath)) { - // Copy bundle to output folder const outputCodePushFolder = path.join(outputFolder, "CodePush"); fs.mkdirSync(outputCodePushFolder, { recursive: true }); const outputBundlePath = path.join(outputCodePushFolder, "index.android.bundle"); @@ -37,12 +35,10 @@ export async function extractMetadataFromAndroid(extractFolder, outputFolder) { throw new Error("index.android.bundle not found in APK root folder."); } - // Save packageManifest.json const manifestPath = path.join(outputFolder, "packageManifest.json"); fs.writeFileSync(manifestPath, JSON.stringify(fileHashes, null, 2)); log(chalk.cyan(`\nSaved packageManifest.json with ${Object.keys(fileHashes).length} entries.\n`)); - // Create zip archive with packageManifest.json and bundle file const zipPath = path.join(os.tmpdir(), `CodePushBinary-${Date.now()}.zip`); await createZipArchive(outputFolder, zipPath, ["packageManifest.json", "CodePush/index.android.bundle"]); @@ -61,9 +57,6 @@ async function takeHashesFromMetadata(metadataPath: string): Promise item.split(":"))); } -// --------------------------------------------------------------------------- -// Android App Bundle (.aab) -// --------------------------------------------------------------------------- // Minimal .aab manifest reader; replaces the unmaintained aab-parser, which // pinned a vulnerable protobufjs (^6.11.2). diff --git a/script/platforms/ios/binary.ts b/script/platforms/ios/binary.ts index 1a26da1..bb10afa 100644 --- a/script/platforms/ios/binary.ts +++ b/script/platforms/ios/binary.ts @@ -46,17 +46,14 @@ export async function extractMetadataFromIOS(ipaPath: string, outputFolder: stri log(chalk.cyan(`\nFound main.jsbundle, calculating hash:\n`)); fileHashes["CodePush/main.jsbundle"] = sha256(bundleBuffer); - // Write bundle to output folder (needed for the release package zip) const outputCodePushFolder = path.join(outputFolder, "CodePush"); fs.mkdirSync(outputCodePushFolder, { recursive: true }); fs.writeFileSync(path.join(outputCodePushFolder, "main.jsbundle"), bundleBuffer); - // Save packageManifest.json const manifestPath = path.join(outputFolder, "packageManifest.json"); fs.writeFileSync(manifestPath, JSON.stringify(fileHashes, null, 2)); log(chalk.cyan(`\nSaved packageManifest.json with ${Object.keys(fileHashes).length} entries.\n`)); - // Create zip archive with packageManifest.json and bundle file const zipPath = path.join(os.tmpdir(), `CodePushBinary-${Date.now()}.zip`); await createZipArchive(outputFolder, zipPath, ["packageManifest.json", "CodePush/main.jsbundle"]); diff --git a/script/platforms/ios/xcode.ts b/script/platforms/ios/xcode.ts index 0bc620e..aefaaad 100644 --- a/script/platforms/ios/xcode.ts +++ b/script/platforms/ios/xcode.ts @@ -12,15 +12,11 @@ const xcode = require("xcode"); export async function getIosVersionInfo(command: cli.IReleaseReactCommand, projectName: string): Promise { let resolvedPlistFile: string = command.plistFile; if (resolvedPlistFile) { - // If a plist file path is explicitly provided, then we don't - // need to attempt to "resolve" it within the well-known locations. if (!fileExists(resolvedPlistFile)) { throw new Error("The specified plist file doesn't exist. Please check that the provided path is correct."); } } else { - // Allow the plist prefix to be specified with or without a trailing - // separator character, but prescribe the use of a hyphen when omitted, - // since this is the most commonly used convention for plist files. + // The prefix may end with a separator; add the usual hyphen when it doesn't if (command.plistFilePrefix && /.+[^-.]$/.test(command.plistFilePrefix)) { command.plistFilePrefix += "-"; } @@ -72,10 +68,8 @@ async function getAppVersionFromXcodeProject(command: cli.IReleaseReactCommand, const pbxprojFileName = "project.pbxproj"; let resolvedPbxprojFile: string = command.xcodeProjectFile; if (resolvedPbxprojFile) { - // If the xcode project file path is explicitly provided, then we don't - // need to attempt to "resolve" it within the well-known locations. if (!resolvedPbxprojFile.endsWith(pbxprojFileName)) { - // Specify path to pbxproj file if the provided file path is an Xcode project file. + // An .xcodeproj folder was given resolvedPbxprojFile = path.join(resolvedPbxprojFile, pbxprojFileName); } if (!fileExists(resolvedPbxprojFile)) { diff --git a/script/platforms/react-native/bundler.ts b/script/platforms/react-native/bundler.ts index 43acf52..385637d 100644 --- a/script/platforms/react-native/bundler.ts +++ b/script/platforms/react-native/bundler.ts @@ -16,23 +16,16 @@ export interface BundleOptions { extraBundlerOptions: string[]; } -export const runReactNativeBundleCommand = async ( +export function runReactNativeBundleCommand( { platform, development, outputFolder, bundleName, packagerSourceMap, extraBundlerOptions }: BundleOptions, entryFile: string, isHermes: boolean -) => { - const reactNativeBundleArgs: string[] = []; - const envNodeArgs: string = process.env.CODE_PUSH_NODE_ARGS; - - if (typeof envNodeArgs !== "undefined") { - reactNativeBundleArgs.push(...envNodeArgs.trim().split(/\s+/)); - } - +): Promise { const reactNativePackagePath = getReactNativePackagePath(); const oldCliPath = path.join(reactNativePackagePath, "local-cli", "cli.js"); const cliPath = fs.existsSync(oldCliPath) ? oldCliPath : path.join(reactNativePackagePath, "cli.js"); - reactNativeBundleArgs.push( + return runBundler("react-native bundle", [ cliPath, "bundle", "--assets-dest", @@ -45,59 +38,24 @@ export const runReactNativeBundleCommand = async ( entryFile, "--platform", platform, - "--reset-cache" - ); - - reactNativeBundleArgs.push("--sourcemap-output", packagerSourceMap); - - reactNativeBundleArgs.push(...getMinifyParams(platform, isHermes)); - - if (extraBundlerOptions.length > 0) { - reactNativeBundleArgs.push(...extraBundlerOptions); - } - - log(chalk.cyan('Running "react-native bundle" command:\n')); - const reactNativeBundleProcess = spawn("node", reactNativeBundleArgs); - log(`node ${reactNativeBundleArgs.join(" ")}`); - - return new Promise((resolve, reject) => { - reactNativeBundleProcess.stdout.on("data", (data: Buffer) => { - log(data.toString().trim()); - }); - - reactNativeBundleProcess.stderr.on("data", (data: Buffer) => { - console.error(data.toString().trim()); - }); - - reactNativeBundleProcess.on("close", (exitCode: number) => { - if (exitCode) { - reject(new Error(`"react-native bundle" command exited with code ${exitCode}.`)); - } - - resolve(); - }); - }); -}; + "--reset-cache", + "--sourcemap-output", + packagerSourceMap, + ...getMinifyParams(platform, isHermes), + ...extraBundlerOptions, + ]); +} -export const runExpoExportEmbedCommand = async ({ +export function runExpoExportEmbedCommand({ platform, development, outputFolder, bundleName, packagerSourceMap, extraBundlerOptions, -}: BundleOptions) => { - const expoBundleArgs: string[] = []; - const envNodeArgs: string = process.env.CODE_PUSH_NODE_ARGS; - - if (typeof envNodeArgs !== "undefined") { - expoBundleArgs.push(...envNodeArgs.trim().split(/\s+/)); - } - - const expoCliPath = getExpoCliPath(); - - expoBundleArgs.push( - expoCliPath, +}: BundleOptions): Promise { + return runBundler("expo export:embed", [ + getExpoCliPath(), "export:embed", "--assets-dest", outputFolder, @@ -109,43 +67,34 @@ export const runExpoExportEmbedCommand = async ({ platform, "--minify", "false", - "--reset-cache" - ); - - expoBundleArgs.push("--sourcemap-output", packagerSourceMap); - - if (extraBundlerOptions.length > 0) { - expoBundleArgs.push(...extraBundlerOptions); - } - - log(chalk.cyan('Running "expo export:embed" command:\n')); - - const projectRoot = process.cwd(); - expoBundleArgs.push(projectRoot); - - log("expoBundleArgs raw:" + JSON.stringify(expoBundleArgs, null, 2)); + "--reset-cache", + "--sourcemap-output", + packagerSourceMap, + ...extraBundlerOptions, + process.cwd(), + ]); +} - const expoBundleProcess = spawn("node", expoBundleArgs); - log(`node ${expoBundleArgs.join(" ")}`); +// Runs the bundler's script with node; fails unless it exits with code 0 +function runBundler(name: string, scriptArgs: string[]): Promise { + const nodeArgs = [...(process.env.CODE_PUSH_NODE_ARGS?.trim().split(/\s+/).filter(Boolean) ?? []), ...scriptArgs]; + log(chalk.cyan(`Running "${name}" command:\n`)); + const bundlerProcess = spawn("node", nodeArgs); + log(`node ${nodeArgs.join(" ")}`); return new Promise((resolve, reject) => { - expoBundleProcess.stdout.on("data", (data: Buffer) => { - log(data.toString().trim()); - }); - - expoBundleProcess.stderr.on("data", (data: Buffer) => { - console.error(data.toString().trim()); - }); - - expoBundleProcess.on("close", (exitCode: number) => { - if (exitCode) { - reject(new Error(`"expo export:embed" command exited with code ${exitCode}.`)); + bundlerProcess.stdout.on("data", (data: Buffer) => log(data.toString().trim())); + bundlerProcess.stderr.on("data", (data: Buffer) => console.error(data.toString().trim())); + bundlerProcess.on("error", reject); + bundlerProcess.on("close", (exitCode: number | null, signal: NodeJS.Signals | null) => { + if (exitCode === 0) { + resolve(); + } else { + reject(new Error(`"${name}" command ${signal ? `was stopped by ${signal}` : `exited with code ${exitCode}`}.`)); } - - resolve(); }); }); -}; +} function getMinifyParams(platform: string, isHermes: boolean): string[] { switch (platform) { diff --git a/script/platforms/react-native/hermes.ts b/script/platforms/react-native/hermes.ts index cc6fb29..b24a404 100644 --- a/script/platforms/react-native/hermes.ts +++ b/script/platforms/react-native/hermes.ts @@ -153,7 +153,6 @@ export async function runHermesEmitBinaryCommand( const hermesc = await findHermesc(project); const hermesArgs = [ - ...(process.env.CODE_PUSH_NODE_ARGS?.trim().split(/\s+/).filter(Boolean) ?? []), "-emit-binary", "-O", "-out", diff --git a/script/types/cli.ts b/script/types/cli.ts index 7088403..3b9892b 100644 --- a/script/types/cli.ts +++ b/script/types/cli.ts @@ -177,7 +177,8 @@ export interface IPromoteCommand extends ICommand, IPackageInfo { } export interface IRegisterCommand extends ICommand { - serverUrl?: string; + apiServerUrl?: string; + appServerUrl?: string; } export interface IReleaseBaseCommand extends ICommand, IPackageInfo { diff --git a/script/ui/printers.ts b/script/ui/printers.ts index 2996b5c..b96d903 100644 --- a/script/ui/printers.ts +++ b/script/ui/printers.ts @@ -3,7 +3,6 @@ import * as cli from "../types/cli"; import { AccessKey, App, CollaboratorMap, CollaboratorProperties, Deployment, Package, PackageWithMetrics, Session } from "../types"; import { log } from "./terminal"; import AccountManager = require("../management-sdk"); -import wordwrap = require("wordwrap"); const Table = require("cli-table"); @@ -14,7 +13,7 @@ export function printAppList(format: string, apps: App[]): void { const headers = ["Name", "Deployments"]; printTable(headers, (dataSource: any[]): void => { apps.forEach((app: App): void => { - const row = [app.name, wordwrap(50)(app.deployments.join(", "))]; + const row = [app.name, wrap(app.deployments.join(", "), 50)]; dataSource.push(row); }); }); @@ -117,7 +116,7 @@ export function printDeploymentHistory( row.push(releasedBy); } - row.push(packageObject.description ? wordwrap(30)(packageObject.description) : ""); + row.push(packageObject.description ? wrap(packageObject.description, 30) : ""); row.push(getPackageMetricsString(packageObject) + (packageObject.isDisabled ? `\n${chalk.green("Disabled:")} Yes` : "")); if (packageObject.isDisabled) { row = row.map((cellContents: string) => applyChalkSkippingLineBreaks(cellContents, chalk.dim)); @@ -158,7 +157,7 @@ function getPackageString(packageObject: Package): string { "\n" + chalk.green("Released By: ") + (packageObject.releasedBy ? packageObject.releasedBy : "") + - (packageObject.description ? wordwrap(70)("\n" + chalk.green("Description: ") + packageObject.description) : ""); + (packageObject.description ? wrap("\n" + chalk.green("Description: ") + packageObject.description, 70) : ""); if (packageObject.isDisabled) { packageString += `\n${chalk.green("Disabled:")} Yes`; @@ -294,3 +293,24 @@ function printTable(columnNames: string[], readData: (dataSource: any[]) => void log(table.toString()); } + +/** Wraps text at spaces so no line is longer than width, unless a single word is */ +function wrap(text: string, width: number): string { + return text + .replace(/\t/g, " ") // The table counts a tab as one column, but the terminal shows it wider + .split("\n") + .map((line) => { + const lines: string[] = []; + let current = ""; + for (const word of line.split(" ")) { + if (current && current.length + 1 + word.length > width) { + lines.push(current); + current = word; + } else { + current = current ? `${current} ${word}` : word; + } + } + return [...lines, current].join("\n"); + }) + .join("\n"); +} diff --git a/script/ui/terminal.ts b/script/ui/terminal.ts index e2ba25c..674b245 100644 --- a/script/ui/terminal.ts +++ b/script/ui/terminal.ts @@ -9,7 +9,7 @@ export function log(message: unknown): void { export const confirm = (message: string = "Are you sure?"): Promise => { message += " (y/N):"; - return new Promise((resolve): void => { + return new Promise((resolve, reject): void => { prompt.message = ""; prompt.delimiter = ""; @@ -24,9 +24,13 @@ export const confirm = (message: string = "Are you sure?"): Promise => }, }, (err: any, result: any): void => { - // Ctrl+C means "no"; other errors (e.g. no terminal) still fail the command - if (err?.message === "canceled") { - resolve(false); + // Ctrl+C means "no"; other errors fail the command + if (err) { + if (err.message === "canceled") { + resolve(false); + } else { + reject(err); + } return; } diff --git a/script/utils/file-utils.ts b/script/utils/file-utils.ts index 0f54a32..e2c47cf 100644 --- a/script/utils/file-utils.ts +++ b/script/utils/file-utils.ts @@ -63,7 +63,7 @@ export function fileDoesNotExistOrIsDirectory(path: string): boolean { } export function normalizePath(filePath: string): string { - //replace all backslashes coming from cli running on windows machines by slashes + // Windows paths use backslashes; zip entries need slashes return filePath.replace(/\\/g, "/"); } @@ -103,13 +103,19 @@ export function createZip(zipPath: string, entries: ZipEntry[]): Promise { return new Promise((resolve, reject) => { const zipFile = new yazl.ZipFile(); const writeStream = fs.createWriteStream(zipPath); + let failure: Error | undefined; + // Settle only once the zip file is closed, so a caller can delete it + const fail = (error: Error): void => { + failure ??= error; + writeStream.destroy(); + }; // yazl reports unreadable input files on the ZipFile itself - zipFile.on("error", reject); + zipFile.on("error", fail); zipFile.outputStream .pipe(writeStream) - .on("error", reject) - .on("close", () => resolve()); + .on("error", fail) + .on("close", () => (failure ? reject(failure) : resolve())); for (const [filePath, zipEntryPath] of entries) { zipFile.addFile(filePath, zipEntryPath); diff --git a/script/utils/hash-utils.ts b/script/utils/hash-utils.ts index 932fd26..29ed2fb 100644 --- a/script/utils/hash-utils.ts +++ b/script/utils/hash-utils.ts @@ -32,8 +32,7 @@ export function generatePackageManifestFromZip(filePath: string): Promise((resolve, reject) => { yauzl.open(filePath, { lazyEntries: true }, (error?: any, openedZipFile?: any): void => { if (error) { - // This is the first time we try to read the package as a .zip file; - // however, it may not be a .zip file. Handle this gracefully. + // The package may not be a zip resolve(null); return; } @@ -42,7 +41,6 @@ export function generatePackageManifestFromZip(filePath: string): Promise(); const hashFilePromises: Promise[] = []; - // Read each entry in the archive sequentially and generate a hash for it. zipFile.readEntry(); zipFile .on("error", (error: any): void => { @@ -148,8 +146,7 @@ export class PackageManifest { entries.push(name + ":" + hash); }); - // Make sure this list is alphabetically ordered so that other clients - // can also compute this hash easily given the update contents. + // Sorted, so other clients compute the same hash from the same contents entries = entries.sort(); return crypto.createHash(HASH_ALGORITHM).update(JSON.stringify(entries)).digest("hex"); diff --git a/script/utils/sign.ts b/script/utils/sign.ts index 6b19918..f80807b 100644 --- a/script/utils/sign.ts +++ b/script/utils/sign.ts @@ -25,7 +25,7 @@ export default async function sign(privateKeyPath: string, updateContentsPath: s throw new Error(`The path specified for the signing key ("${privateKeyPath}") was not valid.`); } - // If releasing a single file, copy the file to a temporary 'CodePush' directory in which to publish the release + // A single file is released inside a 'CodePush' folder if (!isDirectory(updateContentsPath)) { updateContentsPath = copyFileToTmpDir(updateContentsPath); } diff --git a/script/utils/validation-utils.ts b/script/utils/validation-utils.ts index c68569e..3c6bee2 100644 --- a/script/utils/validation-utils.ts +++ b/script/utils/validation-utils.ts @@ -1,9 +1,17 @@ import * as semver from "semver"; -const emailValidator = require("email-validator"); +// The rules of the email-validator package (2.0.4) this replaced +const EMAIL_REGEX = + /^[-!#$%&'*+/0-9=?A-Z^_a-z{|}~](\.?[-!#$%&'*+/0-9=?A-Z^_a-z`{|}~])*@[a-zA-Z0-9](-*\.?[a-zA-Z0-9])*\.[a-zA-Z](-?[a-zA-Z0-9])+$/; + +function isValidEmail(email: string): boolean { + if (!email || email.length > 254 || !EMAIL_REGEX.test(email)) return false; + const [localPart, domain] = email.split("@"); + return localPart.length <= 64 && domain.split(".").every((label) => label.length <= 63); +} export function throwForInvalidEmail(email: string): void { - if (!emailValidator.validate(email)) { + if (!isValidEmail(email)) { throw new Error('"' + email + '" is an invalid e-mail address.'); } } diff --git a/test/bundler.ts b/test/bundler.ts new file mode 100644 index 0000000..cee72c1 --- /dev/null +++ b/test/bundler.ts @@ -0,0 +1,69 @@ +import { strict as assert } from "assert"; +import { ChildProcess } from "child_process"; +import { EventEmitter } from "events"; +import * as sinon from "sinon"; +import * as bundler from "../script/platforms/react-native/bundler"; +import * as terminal from "../script/ui/terminal"; +import { restoreEnvAfterEach } from "./helpers/env"; + +const options: bundler.BundleOptions = { + platform: "android", + development: false, + outputFolder: "out", + bundleName: "index.android.bundle", + packagerSourceMap: "out/index.android.bundle.map", + extraBundlerOptions: [], +}; + +describe("bundler", () => { + restoreEnvAfterEach(); + + let bundlerProcess: EventEmitter & { stdout: EventEmitter; stderr: EventEmitter }; + let spawn: sinon.SinonStub; + + beforeEach(() => { + bundlerProcess = Object.assign(new EventEmitter(), { stdout: new EventEmitter(), stderr: new EventEmitter() }); + spawn = sinon.stub(bundler, "spawn").returns(bundlerProcess as unknown as ChildProcess); + sinon.stub(terminal, "log"); + }); + + afterEach(() => sinon.restore()); + + it("resolves when the bundler exits with code 0", async () => { + const bundling = bundler.runReactNativeBundleCommand(options, "index.js", false); + bundlerProcess.emit("close", 0, null); + await bundling; + }); + + it("rejects when the bundler exits with an error code", async () => { + const bundling = bundler.runReactNativeBundleCommand(options, "index.js", false); + bundlerProcess.emit("close", 1, null); + await assert.rejects(bundling, { message: '"react-native bundle" command exited with code 1.' }); + }); + + it("rejects when the bundler is killed, instead of releasing a partial bundle", async () => { + const bundling = bundler.runReactNativeBundleCommand(options, "index.js", false); + bundlerProcess.emit("close", null, "SIGKILL"); + await assert.rejects(bundling, { message: '"react-native bundle" command was stopped by SIGKILL.' }); + }); + + it("rejects when the bundler can't be started", async () => { + const bundling = bundler.runReactNativeBundleCommand(options, "index.js", false); + bundlerProcess.emit("error", new Error("spawn node ENOENT")); + await assert.rejects(bundling, { message: "spawn node ENOENT" }); + }); + + it("passes CODE_PUSH_NODE_ARGS to node, ignoring a blank value", async () => { + process.env.CODE_PUSH_NODE_ARGS = " "; + const bundling = bundler.runReactNativeBundleCommand(options, "index.js", false); + bundlerProcess.emit("close", 0, null); + await bundling; + assert.ok(!spawn.args[0][1].includes("")); + + process.env.CODE_PUSH_NODE_ARGS = " --max-old-space-size=8192 "; + const bundlingWithArgs = bundler.runReactNativeBundleCommand(options, "index.js", false); + bundlerProcess.emit("close", 0, null); + await bundlingWithArgs; + assert.equal(spawn.args[1][1][0], "--max-old-space-size=8192"); + }); +}); diff --git a/test/file-utils.ts b/test/file-utils.ts index b621846..0f5f9cb 100644 --- a/test/file-utils.ts +++ b/test/file-utils.ts @@ -1,7 +1,7 @@ import { strict as assert } from "assert"; import * as fs from "fs"; import * as path from "path"; -import { copyFileToTmpDir, listFiles } from "../script/utils/file-utils"; +import { copyFileToTmpDir, createZip, listFiles } from "../script/utils/file-utils"; import { useTempDirectory } from "./helpers/temp-directory"; describe("file-utils", () => { @@ -43,4 +43,16 @@ describe("file-utils", () => { } }); }); + + describe("createZip", () => { + it("rejects only after closing the zip, so the caller can delete it", async () => { + const zipPath = path.join(dir.path, "out.zip"); + + await assert.rejects(createZip(zipPath, [[path.join(dir.path, "missing.txt"), "missing.txt"]]), { code: "ENOENT" }); + fs.rmSync(zipPath, { force: true }); + await new Promise((resolve) => setTimeout(resolve, 50)); + + assert.equal(fs.existsSync(zipPath), false); + }); + }); }); diff --git a/test/setup.ts b/test/setup.ts index 4ec843f..a809235 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -7,6 +7,12 @@ import * as path from "path"; const home = fs.mkdtempSync(path.join(os.tmpdir(), "revopush-test-home-")); process.env.HOME = home; process.env.LOCALAPPDATA = home; -process.on("exit", () => fs.rmSync(home, { recursive: true, force: true })); +// Release flows clear react-* and CodePush* folders in the temp dir, so they get a temp dir of their own +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "revopush-test-tmp-")); +process.env.TMPDIR = process.env.TMP = process.env.TEMP = tmp; +process.on("exit", () => { + fs.rmSync(home, { recursive: true, force: true }); + fs.rmSync(tmp, { recursive: true, force: true }); +}); // Expected dates in the tests are US Pacific time process.env.TZ = "America/Los_Angeles"; diff --git a/test/terminal.ts b/test/terminal.ts index 93b1bf7..a3b4d1d 100644 --- a/test/terminal.ts +++ b/test/terminal.ts @@ -34,7 +34,16 @@ describe("terminal", () => { it("still fails on other prompt errors, so scripts don't treat them as a cancel", async () => { answer(new Error("stdin closed")); - await assert.rejects(confirm()); + await assert.rejects(confirm(), { message: "stdin closed" }); + }); + + it("rejects on a prompt error reported asynchronously, as the real prompt does", async () => { + sinon + .stub(prompt, "get") + .callsFake((_schema: unknown, callback: (error: Error | null) => void) => + setImmediate(() => callback(new Error("stdin closed"))) + ); + await assert.rejects(confirm(), { message: "stdin closed" }); }); }); });