diff --git a/.gitignore b/.gitignore index 9e1210e7..1576fba0 100644 --- a/.gitignore +++ b/.gitignore @@ -44,4 +44,9 @@ JsonStorage.json *.crt *.pem *.key -*.cnf \ No newline at end of file +*.cnf + +# Azurite local storage files +__azurite_db_* +__blobstorage__ +__queuestorage__ diff --git a/api/.env.example b/api/.env.example new file mode 100644 index 00000000..352ed8fb --- /dev/null +++ b/api/.env.example @@ -0,0 +1,71 @@ +########################################## +# Environment Variables for CodePush Server +########################################## + +# ============================== +# Storage Configuration (REQUIRED - choose one) +# ============================== +EMULATED=false # Set to 'true' to use the local emulator + +# --- Azure Storage Configuration --- +AZURE_STORAGE_ACCOUNT= # Azure storage account name +AZURE_STORAGE_ACCESS_KEY= # Azure storage access key (if KeyVault not used) + +# ============================== +# Server Configuration (REQUIRED) +# ============================== +SERVER_URL=http://localhost:3000 # The URL of your server + +# ============================== +# Authentication (REQUIRED - at least one provider) +# ============================== + +# --- GitHub OAuth --- +GITHUB_CLIENT_ID= # GitHub OAuth client ID +GITHUB_CLIENT_SECRET= # GitHub OAuth client secret + +# --- Microsoft OAuth --- +MICROSOFT_CLIENT_ID= # Microsoft OAuth client ID +MICROSOFT_CLIENT_SECRET= # Microsoft OAuth client secret + +# ============================== +# Optional Configuration +# ============================== + +# --- HTTPS Configuration --- +HTTPS= # Set to 'true' to enable HTTPS for local deployment + +# --- Debugging Configuration --- +LOGGING=false # Enable CodePush-specific logging +DEBUG_DISABLE_AUTH=false # Disable OAuth authentication route +DEBUG_USER_ID= # Backend user ID for debugging session + +# ============================== +# Redis Configuration +# ============================== +REDIS_HOST= # Redis server IP address +REDIS_PORT=6379 # Redis port (default: 6379) +REDIS_KEY= # Redis authentication key + +# ============================== +# Unit Testing Configuration +# ============================== +TEST_AZURE_STORAGE=false # Run API unit tests against Azure storage +AZURE_ACQUISITION_URL= # URL for acquisition tests + +# ============================== +# Other Configuration +# ============================== +DISABLE_ACQUISITION=false # Disable acquisition routes +DISABLE_MANAGEMENT=false # Disable management routes +ENABLE_ACCOUNT_REGISTRATION=true # Enable account registration +UPLOAD_SIZE_LIMIT_MB=200 # Max file upload size (in MB) +ENABLE_PACKAGE_DIFFING=false # Enable generating diffs for releases + +# ============================== +# Azure KeyVault Configuration (Optional) +# ============================== +AZURE_KEYVAULT_ACCOUNT= # Azure KeyVault account name +CLIENT_ID= # Active Directory app client ID +CERTIFICATE_THUMBPRINT= # AD app certificate thumbprint +REFRESH_CREDENTIALS_INTERVAL=86400000 # Credential refresh interval (in ms, default: 1 day) diff --git a/api/ENVIRONMENT.md b/api/ENVIRONMENT.md index 0f4067e6..c16f1405 100644 --- a/api/ENVIRONMENT.md +++ b/api/ENVIRONMENT.md @@ -2,7 +2,7 @@ The CodePush Server is configured using environment variables. -Currently, the following environment variables are available. For convenience, we will also load the server environment from any '.env' file in the api directory, and the test environment from any '.test.env' file in the root directory. +For convenience, we will also load the server environment from any '.env' file in the api directory, and the test environment from any '.test.env' file in the root directory. Use the `.env.example` file as a template for setting up your environment variables. ## Mandatory parameters diff --git a/api/README.md b/api/README.md index b9747afd..e8f042c3 100644 --- a/api/README.md +++ b/api/README.md @@ -16,11 +16,26 @@ Additionally, you need to specify [EMULATED](ENVIRONMENT.md#emulated) flag equal #### Steps To run the CodePush Server locally, follow these steps: -1. Clone the CodePush Service repository. -1. Create a `.env` file and configure the mandatory variables as outlined in the `ENVIRONMENT.md` file. -1. Install dependencies by running `npm install`. -1. Build the server by running `npm run build`. -1. Start the server by running `npm run start:env`. + +1. Clone the CodePush Service repository to your local machine. + +2. Copy the `.env.example` file to a new file named `.env` in the root directory: + ````bash + cp .env.example .env + ```` + Fill in the values for each environment variable in the `.env` file according to your development or production setup. +3. Install all necessary dependencies: + ````bash + npm install + ```` +4. Compile the server code: + ````bash + npm run build + ```` +5. Launch the server with the environment-specific start command: + ````bash + npm run start:env + ```` By default, local CodePush server runs on HTTP. To run CodePush Server on HTTPS: @@ -115,3 +130,15 @@ Both work and personal accounts use the same application for authentication. The 1. Only letters are allowed. 1. Maximum 15 characters. + +## Metrics + +Installation metrics allow monitoring release activity via the CLI. For detailed usage instructions, please refer to the [CLI documentation](../cli/README.md#development-parameter). + +Redis is required for Metrics to work. + +### Steps + +1. Install Redis by following [official installation guide](https://redis.io/docs/latest/operate/oss_and_stack/install/install-redis/). +1. TLS is required. Follow [official Redis TLS run guide](https://redis.io/docs/latest/operate/oss_and_stack/management/security/encryption/#running-manually). +1. Set the necessary environment variables for [Redis](./ENVIRONMENT.md#redis). \ No newline at end of file diff --git a/api/SECURITY.md b/api/SECURITY.md index 2ee12dc5..022413d5 100644 --- a/api/SECURITY.md +++ b/api/SECURITY.md @@ -19,4 +19,4 @@ All secrets used in the system should be handled with the utmost care. They must It is essential to review and apply security best practices for all system components. As this setup is minimal, it is the customer’s responsibility to harden the system for production use. - [Azure Storage Security Recommendations](https://learn.microsoft.com/en-us/azure/storage/blobs/security-recommendations) -- [Azure WebApp Security Best Practices](https://learn.microsoft.com/en-us/azure/app-service/overview-security) +- [Azure WebApp Security Best Practices](https://learn.microsoft.com/en-us/azure/app-service/overview-security) \ No newline at end of file diff --git a/api/package-lock.json b/api/package-lock.json index 4575bed7..df50d9dc 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -18,7 +18,7 @@ "cookie-session": "^2.0.0", "ejs": "^3.1.10", "email-validator": "1.0.3", - "express": "^4.19.2", + "express": "^4.21.1", "express-domain-middleware": "0.1.0", "express-rate-limit": "^7.4.0", "multer": "^1.4.5-lts.1", @@ -2031,9 +2031,9 @@ } }, "node_modules/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", "engines": { "node": ">= 0.6" } @@ -2671,16 +2671,16 @@ } }, "node_modules/express": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.0.tgz", - "integrity": "sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz", + "integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", - "cookie": "0.6.0", + "cookie": "0.7.1", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", diff --git a/api/package.json b/api/package.json index 549e859e..8adc3b7f 100644 --- a/api/package.json +++ b/api/package.json @@ -32,7 +32,7 @@ "cookie-session": "^2.0.0", "ejs": "^3.1.10", "email-validator": "1.0.3", - "express": "^4.19.2", + "express": "^4.21.1", "express-domain-middleware": "0.1.0", "express-rate-limit": "^7.4.0", "multer": "^1.4.5-lts.1", diff --git a/api/script/routes/passport-authentication.ts b/api/script/routes/passport-authentication.ts index 488d0d75..46bb8f28 100644 --- a/api/script/routes/passport-authentication.ts +++ b/api/script/routes/passport-authentication.ts @@ -167,17 +167,17 @@ export class PassportAuthentication { router.get("/auth/login", this._cookieSessionMiddleware, (req: Request, res: Response): any => { req.session["hostname"] = req.query.hostname; - res.render("authenticate", { action: "login" }); + res.render("authenticate", { action: "login", isGitHubAuthenticationEnabled, isMicrosoftAuthenticationEnabled }); }); router.get("/auth/link", this._cookieSessionMiddleware, (req: Request, res: Response): any => { req.session["authorization"] = req.query.access_token; - res.render("authenticate", { action: "link" }); + res.render("authenticate", { action: "link", isGitHubAuthenticationEnabled, isMicrosoftAuthenticationEnabled }); }); router.get("/auth/register", this._cookieSessionMiddleware, (req: Request, res: Response): any => { req.session["hostname"] = req.query.hostname; - res.render("authenticate", { action: "register" }); + res.render("authenticate", { action: "register", isGitHubAuthenticationEnabled, isMicrosoftAuthenticationEnabled }); }); return router; diff --git a/api/script/views/authenticate.ejs b/api/script/views/authenticate.ejs index cb621898..33251b46 100644 --- a/api/script/views/authenticate.ejs +++ b/api/script/views/authenticate.ejs @@ -85,11 +85,15 @@
Please select an authentication provider for your CodePush account:

- GitHub - - Microsoft (Personal) - - Microsoft (Work)
+ <% if (isGitHubAuthenticationEnabled) { %> + GitHub + + <% } %> + <% if (isMicrosoftAuthenticationEnabled) { %> + Microsoft (Personal) + + Microsoft (Work)
+ <% } %>
diff --git a/cli/README.md b/cli/README.md index b95bd6ba..325919c7 100644 --- a/cli/README.md +++ b/cli/README.md @@ -392,6 +392,13 @@ code-push-standalone release-react [--sourcemapOutput ] [--targetBinaryVersion ] [--rollout ] +[--useHermes ] +[--podFile ] +[--extraHermesFlags ] +[--privateKeyPath ] +[--xcodeProjectFile ] +[--xcodeTargetName ] +[--buildConfigurationName ] ``` The `release-react` command is a React Native-specific version of the "vanilla" [`release`](#releasing-app-updates) command, which supports all of the same parameters (e.g. `--mandatory`, `--description`), yet simplifies the process of releasing updates by performing the following additional behavior: @@ -521,6 +528,48 @@ This specifies the relative path to where the assets, JS bundle and sourcemap fi _NOTE: This parameter can be set using either --outputDir or -o_ +#### Use Hermes parameter + +This parameter enforces the use of the Hermes compiler. If not specified, the automatic checks will be performed, inspecting the `build.gradle` and `Podfile` for the Hermes flag. + +_NOTE: This parameter can be set using either --hermesEnabled or -h_ + +#### Podfile parameter (iOS only) + +The Podfile path will be used for Hermes automatic check. Not used if `--useHermes` is specified. + +_NOTE: This parameter can be set using either --podfile or -pod_ + +#### Extra hermes flags parameter + +Hermes flags which will be passed to Hermes compiler. + +_NOTE: This parameter can be set using either --extraHermesFlags or -hf_ + +#### Private key path parameter + +Private key path which is used for code signing. + +_NOTE: This parameter can be set using either --privateKeyPath or -k_ + +#### Xcode project file parameter + +Path to the Xcode project or project.pbxproj file. + +_NOTE: This parameter can be set using either --xcodeProjectFile or -xp_ + +#### Xcode target name parameter + +Name of target (PBXNativeTarget) which specifies the binary version you want to target this release at (iOS only). + +_NOTE: This parameter can be set using either --xcodeTargetName or -xt_ + +#### Build configuration name parameter + +Name of build configuration which specifies the binary version you want to target this release at. For example, 'Debug' or 'Release' (iOS only). + +_NOTE: This parameter can be set using either --buildConfigurationName or -c_ + ## Debugging CodePush 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 CodePush 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: diff --git a/cli/package-lock.json b/cli/package-lock.json index 2f8aa9bf..be476f1b 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -13,6 +13,7 @@ "cli-table": "^0.3.11", "email-validator": "^2.0.4", "gradle-to-js": "2.0.1", + "jsonwebtoken": "^9.0.2", "moment": "^2.29.4", "opener": "^1.5.2", "parse-duration": "1.1.0", @@ -25,11 +26,12 @@ "rimraf": "^2.5.1", "semver": "^7.5.3", "simctl": "^2.0.3", - "simple-update-notifier": "^2.0.0", "slash": "1.0.0", "superagent": "^8.0.9", + "temp": "^0.9.4", "which": "^1.2.7", "wordwrap": "1.0.0", + "xcode": "^3.0.1", "xml2js": "^0.6.0", "yargs": "^17.7.2", "yazl": "^2.5.1" @@ -1049,6 +1051,14 @@ } ] }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "engines": { + "node": ">=0.6" + } + }, "node_modules/body-parser": { "version": "1.20.3", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", @@ -1073,6 +1083,25 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/bplist-creator": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", + "integrity": "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==", + "dependencies": { + "stream-buffers": "2.2.x" + } + }, + "node_modules/bplist-parser": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.1.tgz", + "integrity": "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA==", + "dependencies": { + "big-integer": "1.6.x" + }, + "engines": { + "node": ">= 5.10.0" + } + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -1102,6 +1131,11 @@ "node": "*" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1259,9 +1293,9 @@ } }, "node_modules/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", "dev": true, "engines": { "node": ">= 0.6" @@ -1424,6 +1458,14 @@ "node": ">=6.0.0" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -1736,9 +1778,9 @@ } }, "node_modules/express": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.0.tgz", - "integrity": "sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz", + "integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==", "dev": true, "dependencies": { "accepts": "~1.3.8", @@ -1746,7 +1788,7 @@ "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", - "cookie": "0.6.0", + "cookie": "0.7.1", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", @@ -2490,12 +2532,57 @@ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, "node_modules/just-extend": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.2.1.tgz", "integrity": "sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==", "dev": true }, + "node_modules/jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -2535,21 +2622,45 @@ "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", "dev": true }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" }, - "node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" }, "node_modules/media-typer": { "version": "0.3.0", @@ -2641,6 +2752,14 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/mkdirp": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", @@ -3208,7 +3327,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, "funding": [ { "type": "github", @@ -3236,12 +3354,9 @@ "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==" }, "node_modules/semver": { - "version": "7.5.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz", - "integrity": "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==", - "dependencies": { - "lru-cache": "^6.0.0" - }, + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "bin": { "semver": "bin/semver.js" }, @@ -3388,15 +3503,14 @@ "tail": "^0.4.0" } }, - "node_modules/simple-update-notifier": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", - "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "node_modules/simple-plist": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/simple-plist/-/simple-plist-1.3.1.tgz", + "integrity": "sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw==", "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" + "bplist-creator": "0.1.0", + "bplist-parser": "0.3.1", + "plist": "^3.0.5" } }, "node_modules/sinon": { @@ -3454,6 +3568,14 @@ "node": ">= 0.8" } }, + "node_modules/stream-buffers": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-2.2.0.tgz", + "integrity": "sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==", + "engines": { + "node": ">= 0.10.0" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -3584,6 +3706,41 @@ "node": ">= 0.4.0" } }, + "node_modules/temp": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", + "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", + "dependencies": { + "mkdirp": "^0.5.1", + "rimraf": "~2.6.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/temp/node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/temp/node_modules/rimraf": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -3709,6 +3866,14 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz", + "integrity": "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -3783,6 +3948,18 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, + "node_modules/xcode": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/xcode/-/xcode-3.0.1.tgz", + "integrity": "sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==", + "dependencies": { + "simple-plist": "^1.1.0", + "uuid": "^7.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/xml2js": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.0.tgz", @@ -3819,11 +3996,6 @@ "node": ">=10" } }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" - }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/cli/package.json b/cli/package.json index e1ab92a2..ef15ad5e 100644 --- a/cli/package.json +++ b/cli/package.json @@ -24,6 +24,7 @@ "cli-table": "^0.3.11", "email-validator": "^2.0.4", "gradle-to-js": "2.0.1", + "jsonwebtoken": "^9.0.2", "moment": "^2.29.4", "opener": "^1.5.2", "parse-duration": "1.1.0", @@ -36,11 +37,12 @@ "rimraf": "^2.5.1", "semver": "^7.5.3", "simctl": "^2.0.3", - "simple-update-notifier": "^2.0.0", "slash": "1.0.0", "superagent": "^8.0.9", + "temp": "^0.9.4", "which": "^1.2.7", "wordwrap": "1.0.0", + "xcode": "^3.0.1", "xml2js": "^0.6.0", "yargs": "^17.7.2", "yazl": "^2.5.1" diff --git a/cli/script/command-executor.ts b/cli/script/command-executor.ts index 84377f90..97d369ec 100644 --- a/cli/script/command-executor.ts +++ b/cli/script/command-executor.ts @@ -21,6 +21,8 @@ const Table = require("cli-table"); const which = require("which"); import wordwrap = require("wordwrap"); import * as cli from "../script/types/cli"; +import sign from "./sign"; +const xcode = require("xcode"); import { AccessKey, Account, @@ -36,6 +38,17 @@ import { Session, UpdateMetrics, } from "../script/types"; +import { + getAndroidHermesEnabled, + getiOSHermesEnabled, + runHermesEmitBinaryCommand, + isValidVersion +} from "./react-native-utils"; +import { + fileDoesNotExistOrIsDirectory, + isBinaryOrZip, + fileExists +} from "./utils/file-utils"; const configFilePath: string = path.join(process.env.LOCALAPPDATA || process.env.HOME, ".code-push.config"); const emailValidator = require("email-validator"); @@ -550,14 +563,6 @@ export function execute(command: cli.ICommand) { }); } -function fileDoesNotExistOrIsDirectory(filePath: string): boolean { - try { - return fs.lstatSync(filePath).isDirectory(); - } catch (error) { - return true; - } -} - function getTotalActiveFromDeploymentMetrics(metrics: DeploymentMetrics): number { let totalActive = 0; Object.keys(metrics).forEach((label: string) => { @@ -853,16 +858,6 @@ function getPackageMetricsString(obj: Package): string { } function getReactNativeProjectAppVersion(command: cli.IReleaseReactCommand, projectName: string): Promise { - const fileExists = (file: string): boolean => { - try { - return fs.statSync(file).isFile(); - } catch (e) { - return false; - } - }; - - const isValidVersion = (version: string): boolean => !!semver.valid(version) || /^\d+\.\d+$/.test(version); - log(chalk.cyan(`Detecting ${command.platform} app version:\n`)); if (command.platform === "ios") { @@ -912,9 +907,13 @@ function getReactNativeProjectAppVersion(command: cli.IReleaseReactCommand, proj log(`Using the target binary version value "${parsedPlist.CFBundleShortVersionString}" from "${resolvedPlistFile}".\n`); return Q(parsedPlist.CFBundleShortVersionString); } else { - throw new Error( - `The "CFBundleShortVersionString" key in the "${resolvedPlistFile}" file needs to specify a valid semver string, containing both a major and minor version (e.g. 1.3.2, 1.1).` - ); + if (parsedPlist.CFBundleShortVersionString !== "$(MARKETING_VERSION)") { + throw new Error( + `The "CFBundleShortVersionString" key in the "${resolvedPlistFile}" file needs to specify a valid semver string, containing both a major and minor version (e.g. 1.3.2, 1.1).` + ); + } + + return getAppVersionFromXcodeProject(command, projectName); } } else { throw new Error(`The "CFBundleShortVersionString" key doesn't exist within the "${resolvedPlistFile}" file.`); @@ -1050,6 +1049,53 @@ function getReactNativeProjectAppVersion(command: cli.IReleaseReactCommand, proj } } +function getAppVersionFromXcodeProject(command: cli.IReleaseReactCommand, projectName: string): Promise { + const pbxprojFileName = "project.pbxproj"; + let resolvedPbxprojFile: string = command.xcodeProjectFile; + if (resolvedPbxprojFile) { + // If the xcode project file path is explicitly provided, then we don't + // need to attempt to "resolve" it within the well-known locations. + if (!resolvedPbxprojFile.endsWith(pbxprojFileName)) { + // Specify path to pbxproj file if the provided file path is an Xcode project file. + resolvedPbxprojFile = path.join(resolvedPbxprojFile, pbxprojFileName); + } + if (!fileExists(resolvedPbxprojFile)) { + throw new Error("The specified pbx project file doesn't exist. Please check that the provided path is correct."); + } + } else { + const iOSDirectory = "ios"; + const xcodeprojDirectory = `${projectName}.xcodeproj`; + const pbxprojKnownLocations = [ + path.join(iOSDirectory, xcodeprojDirectory, pbxprojFileName), + path.join(iOSDirectory, pbxprojFileName), + ]; + resolvedPbxprojFile = pbxprojKnownLocations.find(fileExists); + + if (!resolvedPbxprojFile) { + throw new Error( + `Unable to find either of the following pbxproj files in order to infer your app's binary version: "${pbxprojKnownLocations.join( + '", "' + )}".` + ); + } + } + + const xcodeProj = xcode.project(resolvedPbxprojFile).parseSync(); + const marketingVersion = xcodeProj.getBuildProperty( + "MARKETING_VERSION", + command.buildConfigurationName, + command.xcodeTargetName + ); + if (!isValidVersion(marketingVersion)) { + throw new Error( + `The "MARKETING_VERSION" key in the "${resolvedPbxprojFile}" file needs to specify a valid semver string, containing both a major and minor version (e.g. 1.3.2, 1.1).` + ); + } + console.log(`Using the target binary version value "${marketingVersion}" from "${resolvedPbxprojFile}".\n`); + + return marketingVersion; +} + function printJson(object: any): void { log(JSON.stringify(object, /*replacer=*/ null, /*spacing=*/ 2)); } @@ -1276,10 +1322,6 @@ export const releaseReact = (command: cli.IReleaseReactCommand): Promise = } } - if (command.appStoreVersion) { - throwForInvalidSemverRange(command.appStoreVersion); - } - const appVersionPromise: Promise = command.appStoreVersion ? Q(command.appStoreVersion) : getReactNativeProjectAppVersion(command, projectName); @@ -1291,7 +1333,9 @@ export const releaseReact = (command: cli.IReleaseReactCommand): Promise = return appVersionPromise; }) .then((appVersion: string) => { + throwForInvalidSemverRange(appVersion); releaseCommand.appStoreVersion = appVersion; + return createEmptyTempReleaseFolder(outputFolder); }) // This is needed to clear the react native bundler cache: @@ -1307,6 +1351,31 @@ export const releaseReact = (command: cli.IReleaseReactCommand): Promise = command.sourcemapOutput ) ) + .then(async () => { + const isHermesEnabled = + command.useHermes || + (platform === "android" && (await getAndroidHermesEnabled(command.gradleFile))) || // Check if we have to run hermes to compile JS to Byte Code if Hermes is enabled in build.gradle and we're releasing an Android build + (platform === "ios" && (await getiOSHermesEnabled(command.podFile))); // Check if we have to run hermes to compile JS to Byte Code if Hermes is enabled in Podfile and we're releasing an iOS build + + if (isHermesEnabled) { + log(chalk.cyan("\nRunning hermes compiler...\n")); + await runHermesEmitBinaryCommand( + bundleName, + outputFolder, + command.sourcemapOutput, + command.extraHermesFlags, + command.gradleFile + ); + } + }) + .then(async () => { + if (command.privateKeyPath) { + log(chalk.cyan("\nSigning the bundle:\n")); + await sign(command.privateKeyPath, outputFolder); + } else { + console.log("private key was not provided"); + } + }) .then(() => { log(chalk.cyan("\nReleasing update contents to CodePush:\n")); return release(releaseCommand); @@ -1474,10 +1543,6 @@ function releaseErrorHandler(error: CodePushError, command: cli.ICommand): void } } -function isBinaryOrZip(path: string): boolean { - return path.search(/\.zip$/i) !== -1 || path.search(/\.apk$/i) !== -1 || path.search(/\.ipa$/i) !== -1; -} - function throwForInvalidEmail(email: string): void { if (!emailValidator.validate(email)) { throw new Error('"' + email + '" is an invalid e-mail address.'); diff --git a/cli/script/command-parser.ts b/cli/script/command-parser.ts index aaa87172..afd641c2 100644 --- a/cli/script/command-parser.ts +++ b/cli/script/command-parser.ts @@ -4,7 +4,6 @@ import * as yargs from "yargs"; import * as cli from "../script/types/cli"; import * as chalk from "chalk"; -const updateNotifier = require("simple-update-notifier"); import backslash = require("backslash"); import parseDuration = require("parse-duration"); @@ -28,7 +27,6 @@ export function showHelp(showRootDescription?: boolean): void { console.log(chalk.cyan("======================================")); console.log(""); console.log("CodePush is a service that enables you to deploy mobile app updates directly to your users' devices.\n"); - updateCheck(); } yargs.showHelp(); @@ -36,13 +34,6 @@ export function showHelp(showRootDescription?: boolean): void { } } -function updateCheck(): void { - const notifier = updateNotifier({ pkg: packageJson }); - if (notifier.update) { - notifier.notify(); - } -} - function accessKeyAdd(commandName: string, yargs: yargs.Argv): void { isValidCommand = true; yargs @@ -784,6 +775,56 @@ yargs "Path to where the bundle and sourcemap should be written. If omitted, a bundle and sourcemap will not be written.", type: "string", }) + .option("useHermes", { + alias: "h", + default: false, + demand: false, + description: "Enable hermes and bypass automatic checks", + type: "boolean", + }) + .option("podFile", { + alias: "pod", + default: null, + demand: false, + description: "Path to the cocopods config file (iOS only).", + type: "string", + }) + .option("extraHermesFlags", { + alias: "hf", + default: [], + demand: false, + description: + "Flags that get passed to Hermes, JavaScript to bytecode compiler. Can be specified multiple times.", + type: "array", + }) + .option("privateKeyPath", { + alias: "k", + default: null, + demand: false, + description: "Path to private key used for code signing.", + type: "string", + }) + .option("xcodeProjectFile", { + alias: "xp", + default: null, + demand: false, + description: "Path to the Xcode project or project.pbxproj file", + type: "string", + }) + .option("xcodeTargetName", { + alias: "xt", + default: undefined, + demand: false, + description: "Name of target (PBXNativeTarget) which specifies the binary version you want to target this release at (iOS only)", + type: "string", + }) + .option("buildConfigurationName", { + alias: "c", + default: undefined, + demand: false, + description: "Name of build configuration which specifies the binary version you want to target this release at. For example, 'Debug' or 'Release' (iOS only)", + type: "string", + }) .check((argv: any, aliases: { [aliases: string]: string }): any => { return checkValidReleaseOptions(argv); }); @@ -1178,6 +1219,13 @@ export function createCommand(): cli.ICommand { releaseReactCommand.rollout = getRolloutValue(argv["rollout"] as any); releaseReactCommand.sourcemapOutput = argv["sourcemapOutput"] as any; releaseReactCommand.outputDir = argv["outputDir"] as any; + releaseReactCommand.useHermes = argv["useHermes"] as any; + releaseReactCommand.extraHermesFlags = argv["extraHermesFlags"] as any; + releaseReactCommand.podFile = argv["podFile"] as any; + releaseReactCommand.privateKeyPath = argv["privateKeyPath"] as any; + releaseReactCommand.xcodeProjectFile = argv["xcodeProjectFile"] as any; + releaseReactCommand.xcodeTargetName = argv["xcodeTargetName"] as any; + releaseReactCommand.buildConfigurationName = argv["buildConfigurationName"] as any; } break; diff --git a/cli/script/react-native-utils.ts b/cli/script/react-native-utils.ts new file mode 100644 index 00000000..9270fed3 --- /dev/null +++ b/cli/script/react-native-utils.ts @@ -0,0 +1,283 @@ +import * as fs from "fs"; +import * as chalk from "chalk"; +import * as path from "path"; +import * as childProcess from "child_process"; +import { coerce, compare, valid } from "semver"; +import { fileDoesNotExistOrIsDirectory } from "./utils/file-utils"; + +const g2js = require("gradle-to-js/lib/parser"); + +export function isValidVersion(version: string): boolean { + return !!valid(version) || /^\d+\.\d+$/.test(version); +} + +export async function runHermesEmitBinaryCommand( + bundleName: string, + outputFolder: string, + sourcemapOutput: string, + extraHermesFlags: string[], + gradleFile: string +): Promise { + const hermesArgs: string[] = []; + const envNodeArgs: string = process.env.CODE_PUSH_NODE_ARGS; + + if (typeof envNodeArgs !== "undefined") { + Array.prototype.push.apply(hermesArgs, envNodeArgs.trim().split(/\s+/)); + } + + Array.prototype.push.apply(hermesArgs, [ + "-emit-binary", + "-out", + path.join(outputFolder, bundleName + ".hbc"), + path.join(outputFolder, bundleName), + ...extraHermesFlags, + ]); + + if (sourcemapOutput) { + hermesArgs.push("-output-source-map"); + } + + console.log(chalk.cyan("Converting JS bundle to byte code via Hermes, running command:\n")); + const hermesCommand = await getHermesCommand(gradleFile); + const hermesProcess = childProcess.spawn(hermesCommand, hermesArgs); + console.log(`${hermesCommand} ${hermesArgs.join(" ")}`); + + return new Promise((resolve, reject) => { + hermesProcess.stdout.on("data", (data: Buffer) => { + console.log(data.toString().trim()); + }); + + hermesProcess.stderr.on("data", (data: Buffer) => { + console.error(data.toString().trim()); + }); + + hermesProcess.on("close", (exitCode: number, signal: string) => { + if (exitCode !== 0) { + reject(new Error(`"hermes" command failed (exitCode=${exitCode}, signal=${signal}).`)); + } + // Copy HBC bundle to overwrite JS bundle + const source = path.join(outputFolder, bundleName + ".hbc"); + const destination = path.join(outputFolder, bundleName); + fs.copyFile(source, destination, (err) => { + if (err) { + console.error(err); + reject(new Error(`Copying file ${source} to ${destination} failed. "hermes" previously exited with code ${exitCode}.`)); + } + fs.unlink(source, (err) => { + if (err) { + console.error(err); + reject(err); + } + resolve(null as void); + }); + }); + }); + }).then(() => { + if (!sourcemapOutput) { + // skip source map compose if source map is not enabled + return; + } + + const composeSourceMapsPath = getComposeSourceMapsPath(); + if (!composeSourceMapsPath) { + throw new Error("react-native compose-source-maps.js scripts is not found"); + } + + const jsCompilerSourceMapFile = path.join(outputFolder, bundleName + ".hbc" + ".map"); + if (!fs.existsSync(jsCompilerSourceMapFile)) { + throw new Error(`sourcemap file ${jsCompilerSourceMapFile} is not found`); + } + + return new Promise((resolve, reject) => { + const composeSourceMapsArgs = [composeSourceMapsPath, sourcemapOutput, jsCompilerSourceMapFile, "-o", sourcemapOutput]; + + // https://github.com/facebook/react-native/blob/master/react.gradle#L211 + // https://github.com/facebook/react-native/blob/master/scripts/react-native-xcode.sh#L178 + // packager.sourcemap.map + hbc.sourcemap.map = sourcemap.map + const composeSourceMapsProcess = childProcess.spawn("node", composeSourceMapsArgs); + console.log(`${composeSourceMapsPath} ${composeSourceMapsArgs.join(" ")}`); + + composeSourceMapsProcess.stdout.on("data", (data: Buffer) => { + console.log(data.toString().trim()); + }); + + composeSourceMapsProcess.stderr.on("data", (data: Buffer) => { + console.error(data.toString().trim()); + }); + + composeSourceMapsProcess.on("close", (exitCode: number, signal: string) => { + if (exitCode !== 0) { + reject(new Error(`"compose-source-maps" command failed (exitCode=${exitCode}, signal=${signal}).`)); + } + + // Delete the HBC sourceMap, otherwise it will be included in 'code-push' bundle as well + fs.unlink(jsCompilerSourceMapFile, (err) => { + if (err) { + console.error(err); + reject(err); + } + + resolve(null); + }); + }); + }); + }); +} + +function parseBuildGradleFile(gradleFile: string) { + let buildGradlePath: string = path.join("android", "app"); + if (gradleFile) { + buildGradlePath = gradleFile; + } + if (fs.lstatSync(buildGradlePath).isDirectory()) { + buildGradlePath = path.join(buildGradlePath, "build.gradle"); + } + + if (fileDoesNotExistOrIsDirectory(buildGradlePath)) { + throw new Error(`Unable to find gradle file "${buildGradlePath}".`); + } + + return g2js.parseFile(buildGradlePath).catch(() => { + throw new Error(`Unable to parse the "${buildGradlePath}" file. Please ensure it is a well-formed Gradle file.`); + }); +} + +async function getHermesCommandFromGradle(gradleFile: string): Promise { + const buildGradle: any = await parseBuildGradleFile(gradleFile); + const hermesCommandProperty: any = Array.from(buildGradle["project.ext.react"] || []).find((prop: string) => + prop.trim().startsWith("hermesCommand:") + ); + if (hermesCommandProperty) { + return hermesCommandProperty.replace("hermesCommand:", "").trim().slice(1, -1); + } else { + return ""; + } +} + +export function getAndroidHermesEnabled(gradleFile: string): boolean { + return parseBuildGradleFile(gradleFile).then((buildGradle: any) => { + return Array.from(buildGradle["project.ext.react"] || []).some((line: string) => /^enableHermes\s{0,}:\s{0,}true/.test(line)); + }); +} + +export function getiOSHermesEnabled(podFile: string): boolean { + let podPath = path.join("ios", "Podfile"); + if (podFile) { + podPath = podFile; + } + if (fileDoesNotExistOrIsDirectory(podPath)) { + throw new Error(`Unable to find Podfile file "${podPath}".`); + } + + try { + const podFileContents = fs.readFileSync(podPath).toString(); + return /([^#\n]*:?hermes_enabled(\s+|\n+)?(=>|:)(\s+|\n+)?true)/.test(podFileContents); + } catch (error) { + throw error; + } +} + +function getHermesOSBin(): string { + switch (process.platform) { + case "win32": + return "win64-bin"; + case "darwin": + return "osx-bin"; + case "freebsd": + case "linux": + case "sunos": + default: + return "linux64-bin"; + } +} + +function getHermesOSExe(): string { + const react63orAbove = compare(coerce(getReactNativeVersion()).version, "0.63.0") !== -1; + const hermesExecutableName = react63orAbove ? "hermesc" : "hermes"; + switch (process.platform) { + case "win32": + return hermesExecutableName + ".exe"; + default: + return hermesExecutableName; + } +} + +async function getHermesCommand(gradleFile: string): Promise { + const fileExists = (file: string): boolean => { + try { + return fs.statSync(file).isFile(); + } catch (e) { + return false; + } + }; + // Hermes is bundled with react-native since 0.69 + const bundledHermesEngine = path.join(getReactNativePackagePath(), "sdks", "hermesc", getHermesOSBin(), getHermesOSExe()); + if (fileExists(bundledHermesEngine)) { + return bundledHermesEngine; + } + + const gradleHermesCommand = await getHermesCommandFromGradle(gradleFile); + if (gradleHermesCommand) { + return path.join("android", "app", gradleHermesCommand.replace("%OS-BIN%", getHermesOSBin())); + } else { + // assume if hermes-engine exists it should be used instead of hermesvm + const hermesEngine = path.join("node_modules", "hermes-engine", getHermesOSBin(), getHermesOSExe()); + if (fileExists(hermesEngine)) { + return hermesEngine; + } + return path.join("node_modules", "hermesvm", getHermesOSBin(), "hermes"); + } +} + +function getComposeSourceMapsPath(): string { + // detect if compose-source-maps.js script exists + const composeSourceMaps = path.join(getReactNativePackagePath(), "scripts", "compose-source-maps.js"); + if (fs.existsSync(composeSourceMaps)) { + return composeSourceMaps; + } + return null; +} + +function getReactNativePackagePath(): string { + const result = childProcess.spawnSync("node", ["--print", "require.resolve('react-native/package.json')"]); + const packagePath = path.dirname(result.stdout.toString()); + if (result.status === 0 && directoryExistsSync(packagePath)) { + return packagePath; + } + + return path.join("node_modules", "react-native"); +} + +export function directoryExistsSync(dirname: string): boolean { + try { + return fs.statSync(dirname).isDirectory(); + } catch (err) { + if (err.code !== "ENOENT") { + throw err; + } + } + return false; +} + +export function getReactNativeVersion(): string { + let packageJsonFilename; + let projectPackageJson; + try { + packageJsonFilename = path.join(process.cwd(), "package.json"); + projectPackageJson = JSON.parse(fs.readFileSync(packageJsonFilename, "utf-8")); + } catch (error) { + throw new Error( + `Unable to find or read "package.json" in the CWD. The "release-react" command must be executed in a React Native project folder.` + ); + } + + const projectName: string = projectPackageJson.name; + if (!projectName) { + throw new Error(`The "package.json" file in the CWD does not have the "name" field set.`); + } + + return ( + (projectPackageJson.dependencies && projectPackageJson.dependencies["react-native"]) || + (projectPackageJson.devDependencies && projectPackageJson.devDependencies["react-native"]) + ); +} \ No newline at end of file diff --git a/cli/script/sign.ts b/cli/script/sign.ts new file mode 100644 index 00000000..fd3b8fb5 --- /dev/null +++ b/cli/script/sign.ts @@ -0,0 +1,80 @@ +import * as fs from "fs/promises"; +import * as hashUtils from "./hash-utils"; +import * as path from "path"; +import * as jwt from "jsonwebtoken"; +import { copyFileToTmpDir, isDirectory } from "./utils/file-utils"; + +const CURRENT_CLAIM_VERSION: string = "1.0.0"; +const METADATA_FILE_NAME: string = ".codepushrelease"; + +interface CodeSigningClaims { + claimVersion: string; + contentHash: string; +} + +export default async function sign(privateKeyPath: string, updateContentsPath: string): Promise { + if (!privateKeyPath) { + return Promise.resolve(null); + } + + let privateKey: Buffer; + + try { + privateKey = await fs.readFile(privateKeyPath); + } catch (err) { + return Promise.reject(new Error(`The path specified for the signing key ("${privateKeyPath}") was not valid.`)); + } + + // If releasing a single file, copy the file to a temporary 'CodePush' directory in which to publish the release + try { + if (!isDirectory(updateContentsPath)) { + updateContentsPath = copyFileToTmpDir(updateContentsPath); + } + } catch (error) { + Promise.reject(error); + } + + const signatureFilePath: string = path.join(updateContentsPath, METADATA_FILE_NAME); + let prevSignatureExists = true; + try { + await fs.access(signatureFilePath, fs.constants.F_OK); + } catch (err) { + if (err.code === "ENOENT") { + prevSignatureExists = false; + } else { + return Promise.reject( + new Error( + `Could not delete previous release signature at ${signatureFilePath}. + Please, check your access rights.` + ) + ); + } + } + + if (prevSignatureExists) { + console.log(`Deleting previous release signature at ${signatureFilePath}`); + await fs.rmdir(signatureFilePath); + } + + const hash: string = await hashUtils.generatePackageHashFromDirectory(updateContentsPath, path.join(updateContentsPath, "..")); + const claims: CodeSigningClaims = { + claimVersion: CURRENT_CLAIM_VERSION, + contentHash: hash, + }; + + return new Promise((resolve, reject) => { + jwt.sign(claims, privateKey, { algorithm: "RS256" }, async (err: Error, signedJwt: string) => { + if (err) { + reject(new Error("The specified signing key file was not valid")); + } + + try { + await fs.writeFile(signatureFilePath, signedJwt); + console.log(`Generated a release signature and wrote it to ${signatureFilePath}`); + resolve(null); + } catch (error) { + reject(error); + } + }); + }); +} diff --git a/cli/script/types/cli.ts b/cli/script/types/cli.ts index cfd40596..fe555200 100644 --- a/cli/script/types/cli.ts +++ b/cli/script/types/cli.ts @@ -198,6 +198,12 @@ export interface IReleaseReactCommand extends IReleaseBaseCommand { sourcemapOutput?: string; outputDir?: string; config?: string; + useHermes?: boolean; + extraHermesFlags?: string[]; + podFile?: string; + xcodeProjectFile?: string; + xcodeTargetName?: string; + buildConfigurationName?: string; } export interface IRollbackCommand extends ICommand { diff --git a/cli/script/utils/file-utils.ts b/cli/script/utils/file-utils.ts new file mode 100644 index 00000000..7881dd16 --- /dev/null +++ b/cli/script/utils/file-utils.ts @@ -0,0 +1,46 @@ +import * as fs from "fs"; +import * as path from "path"; +import * as rimraf from "rimraf"; +import * as temp from "temp"; + +export function isBinaryOrZip(path: string): boolean { + return path.search(/\.zip$/i) !== -1 || path.search(/\.apk$/i) !== -1 || path.search(/\.ipa$/i) !== -1; +} + +export function isDirectory(path: string): boolean { + return fs.statSync(path).isDirectory(); +} + +export function fileExists(file: string): boolean { + try { + return fs.statSync(file).isFile(); + } catch (e) { + return false; + } +}; + +export function copyFileToTmpDir(filePath: string): string { + if (!isDirectory(filePath)) { + const outputFolderPath: string = temp.mkdirSync("code-push"); + rimraf.sync(outputFolderPath); + fs.mkdirSync(outputFolderPath); + + const outputFilePath: string = path.join(outputFolderPath, path.basename(filePath)); + fs.writeFileSync(outputFilePath, fs.readFileSync(filePath)); + + return outputFolderPath; + } +} + +export function fileDoesNotExistOrIsDirectory(path: string): boolean { + try { + return isDirectory(path); + } catch (error) { + return true; + } +} + +export function normalizePath(filePath: string): string { + //replace all backslashes coming from cli running on windows machines by slashes + return filePath.replace(/\\/g, "/"); +}