From fb8cbcd37d8bdccd53f8e2de55329790312d343f Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:35:49 -0400 Subject: [PATCH 01/60] unity-cli v4: wrap the official unity cli --- .github/workflows/integration-tests.yml | 8 +- .github/workflows/publish.yml | 4 +- .github/workflows/unity-build.yml | 90 +- .github/workflows/unity-release-discovery.yml | 4 +- README.md | 326 +--- docs/unity-version-support.md | 2 +- jest.config.mjs | 3 - package-lock.json | 93 +- package.json | 12 +- src/cli.ts | 850 +-------- src/github-actions-ci.ts | 7 + src/index.ts | 4 +- src/license-client.ts | 265 ++- src/logger-provider.ts | 5 + src/logging.ts | 15 + src/unity-cli.ts | 595 ++++++ src/unity-editor.ts | 211 +-- src/unity-hub.ts | 1595 ----------------- src/unity-installer.ts | 806 +++++++++ src/upm-cli.ts | 531 ------ tests/hub-cdn-urls.test.ts | 41 - tests/linux-hub-path.test.ts | 34 - tests/mocks/electron-asar.js | 10 - tests/unity-cli.test.ts | 166 ++ tests/unity-editor.test.ts | 246 ++- tests/unity-hub.test.ts | 74 - ...nity-installer-release-api-filter.test.ts} | 16 +- tests/upm-cli.test.ts | 34 - 28 files changed, 2222 insertions(+), 3825 deletions(-) create mode 100644 src/unity-cli.ts delete mode 100644 src/unity-hub.ts create mode 100644 src/unity-installer.ts delete mode 100644 src/upm-cli.ts delete mode 100644 tests/hub-cdn-urls.test.ts delete mode 100644 tests/linux-hub-path.test.ts delete mode 100644 tests/mocks/electron-asar.js create mode 100644 tests/unity-cli.test.ts delete mode 100644 tests/unity-hub.test.ts rename tests/{unity-hub-release-api-filter.test.ts => unity-installer-release-api-filter.test.ts} (82%) delete mode 100644 tests/upm-cli.test.ts diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 469d69bc..872fef21 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -16,8 +16,8 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 with: node-version: '24' cache: npm @@ -32,7 +32,7 @@ jobs: contents: read checks: write # to publish unit test results via checks github api steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: sparse-checkout: .github/ - uses: RageAgainstThePixel/job-builder@v1 @@ -76,5 +76,5 @@ jobs: permissions: contents: read steps: - - uses: Kesin11/actions-timeline@44c9c178ffb2fb1d9859614a3ffa79ccfb77565e # v3.1.0 + - uses: Kesin11/actions-timeline@460b1f8a750077029ad3589824d371b200e91dcf # v3.2.1 continue-on-error: true diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7e6a2694..87715600 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -10,8 +10,8 @@ jobs: publish: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 with: node-version: 24.x registry-url: "https://registry.npmjs.org" diff --git a/.github/workflows/unity-build.yml b/.github/workflows/unity-build.yml index 1f5192b3..f84e15c1 100644 --- a/.github/workflows/unity-build.yml +++ b/.github/workflows/unity-build.yml @@ -33,13 +33,13 @@ jobs: steps: - name: Free Disk Space if: ${{ matrix.os == 'ubuntu-latest' && (matrix.unity-version != '2018' && matrix.unity-version != '2017.4.40f1') }} - uses: endersonmenezes/free-disk-space@7901478139cff6e9d44df5972fd8ab8fcade4db1 # v3.2.2 + uses: endersonmenezes/free-disk-space@8b8cd4e2277836962939a8f3b4ea0ee4bc006156 # v4.0.1 with: remove_android: true remove_dotnet: false remove_tool_cache: false - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 with: node-version: 24.x - name: Setup unity-cli @@ -51,20 +51,7 @@ jobs: - name: Setup Unity timeout-minutes: 30 run: | - if [ "${{ matrix.os }}" = "windows-11-arm" ]; then - unity-cli hub-install - else - unity-cli hub-install --hub-version 3.12.0 - unity-cli hub-install --auto-update - fi - unity-cli hub-version - unity-cli upm-install --json - UPM_VER=$(unity-cli upm-version | tr -d '\n\r') - echo "UPM CLI version: ${UPM_VER}" - if ! [[ "${UPM_VER}" =~ ^v[0-9] ]]; then - echo "::error::upm-version expected a v-prefixed release (e.g. v9.0.0); got: ${UPM_VER}" - exit 1 - fi + unity-cli setup-cli --json if [ "${{ matrix.unity-version }}" != "none" ]; then setup_cmd=( unity-cli setup-unity @@ -82,19 +69,21 @@ jobs: fi "${setup_cmd[@]}" fi - - name: Verify UNITY_HUB_PATH and UNITY_EDITOR_PATH variables + - name: Verify UNITY_CLI_PATH and UNITY_EDITOR_PATH variables run: | - echo "UNITY_HUB_PATH: ${UNITY_HUB_PATH}" + echo "UNITY_CLI_PATH: ${UNITY_CLI_PATH}" + echo "UNITY_CLI_VERSION: ${UNITY_CLI_VERSION:-}" echo "UNITY_EDITOR_PATH: ${UNITY_EDITOR_PATH}" echo "UNITY_PROJECT_PATH: ${UNITY_PROJECT_PATH}" # Expected to be empty at this point echo "UNITY_EDITOR_VERSION: ${UNITY_EDITOR_VERSION:-}" echo "UNITY_EDITOR_CHANGESET: ${UNITY_EDITOR_CHANGESET:-}" echo "UNITY_EDITOR_CHANNELS: ${UNITY_EDITOR_CHANNELS:-}" - if [ -z "${UNITY_HUB_PATH}" ]; then - echo "::error::UNITY_HUB_PATH is not set" + if [ -z "${UNITY_CLI_PATH}" ]; then + echo "::error::UNITY_CLI_PATH is not set" exit 1 fi + unity --version if [ "${{ matrix.unity-version }}" != "none" ] && [ -z "${UNITY_EDITOR_PATH}" ]; then echo "::error::UNITY_EDITOR_PATH is not set" @@ -119,16 +108,34 @@ jobs: - name: Create Unity Project if: ${{ matrix.unity-version != 'none' }} run: | - unity-cli list-project-templates --unity-editor "${UNITY_EDITOR_PATH}" --json + project_root="${GITHUB_WORKSPACE}" + project_path="${project_root}/Unity Project" + major="${UNITY_EDITOR_VERSION%%.*}" + # project templates start with 2019; older editors create an empty project + create_project() { + if [ "${major}" -ge 2019 ]; then + arch="x86_64" + if [ "${RUNNER_ARCH}" = "ARM64" ] && [ "${major}" -ge 2021 ]; then + arch="arm64" + fi + unity projects create "Unity Project" --path "${project_root}" --editor-version "${UNITY_EDITOR_VERSION}" --architecture "${arch}" --template 3d --no-cloud + else + unity-cli run --unity-editor "${UNITY_EDITOR_PATH}" -quit -createProject "${project_path}" + fi + } + if [ "${major}" -ge 2019 ]; then + unity templates list --editor "${UNITY_EDITOR_VERSION}" --json || true + fi create_rc=1 for attempt in 1 2 3; do echo "Create Unity Project attempt ${attempt}/3" - if unity-cli create-project --name "Unity Project" --unity-editor "${UNITY_EDITOR_PATH}" --json; then + if create_project; then create_rc=0 break fi + rm -rf "${project_path}" if [ "$attempt" -lt 3 ]; then - echo "::warning::create-project failed on attempt ${attempt}; retrying after short backoff" + echo "::warning::Creating the project failed on attempt ${attempt}; retrying after short backoff" sleep 10 fi done @@ -136,6 +143,7 @@ jobs: echo "::error::Failed to create Unity project after 3 attempts" exit 1 fi + echo "UNITY_PROJECT_PATH=${project_path}" >> "$GITHUB_ENV" - name: Verify UNITY_PROJECT_PATH variable if: ${{ matrix.unity-version != 'none' }} id: verify-project-path @@ -260,41 +268,25 @@ jobs: timeout-minutes: 5 continue-on-error: true run: | - if [ -z "${UNITY_EDITOR_PATH}" ]; then - echo "UNITY_EDITOR_PATH is not set, skipping uninstall" + if [ -z "${UNITY_EDITOR_VERSION}" ]; then + echo "UNITY_EDITOR_VERSION is not set, skipping uninstall" exit 0 fi - unity-cli uninstall-unity --unity-editor "${UNITY_EDITOR_PATH}" + major="${UNITY_EDITOR_VERSION%%.*}" + if [ "${major}" -lt 2017 ]; then + echo "Unity ${UNITY_EDITOR_VERSION} was not installed by the Unity CLI, skipping uninstall" + exit 0 + fi + unity uninstall "${UNITY_EDITOR_VERSION}" --yes - name: Print log paths if: ${{ matrix.unity-version != 'none' }} run: | - HUB_LOG_PATH=$(unity-cli hub-logs) - PACKAGE_MANAGER_LOG_PATH=$(unity-cli package-manager-logs) LICENSING_CLIENT_LOG_PATH=$(unity-cli licensing-client-logs) LICENSING_AUDIT_LOG_PATH=$(unity-cli licensing-audit-logs) - echo "Hub Log Path: ${HUB_LOG_PATH}" - echo "Package Manager Log Path: ${PACKAGE_MANAGER_LOG_PATH}" echo "Licensing Client Log Path: ${LICENSING_CLIENT_LOG_PATH}" echo "Licensing Audit Log Path: ${LICENSING_AUDIT_LOG_PATH}" - - if [ ! -f "${HUB_LOG_PATH}" ]; then - echo "::warning:: Hub log file does not exist at ${HUB_LOG_PATH}" - # find all info-log.json files in ~/.config/unity3d/ - print their paths - find ~/.config/unity3d/ -type f \( -name "info-log.json" \) -exec echo "{}" \; - # also try to find in UNITY_HUB_PATH parent directory if set - if [ -n "${UNITY_HUB_PATH}" ]; then - HUB_DIR=$(dirname "${UNITY_HUB_PATH}") - find "${HUB_DIR}" -type f \( -name "info-log.json" \) -exec echo "{}" \; - fi - # print all files found in ~/.config/ - find ~/.config/ -type f -exec echo "{}" \; - echo "::warning:: Hub log file does not exist at any known location" - fi - - if [ ! -f "${PACKAGE_MANAGER_LOG_PATH}" ]; then - echo "::warning::Package Manager log file does not exist at ${PACKAGE_MANAGER_LOG_PATH}" - fi + unity logs || true if [ ! -f "${LICENSING_CLIENT_LOG_PATH}" ]; then echo "::error::Licensing Client log file does not exist at ${LICENSING_CLIENT_LOG_PATH}" diff --git a/.github/workflows/unity-release-discovery.yml b/.github/workflows/unity-release-discovery.yml index 9a9991f0..0bf14264 100644 --- a/.github/workflows/unity-release-discovery.yml +++ b/.github/workflows/unity-release-discovery.yml @@ -10,8 +10,8 @@ jobs: discover: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 with: node-version: '24' - name: Discover Unity releases vs matrix diff --git a/README.md b/README.md index f3e70cf9..be19ecab 100644 --- a/README.md +++ b/README.md @@ -5,40 +5,40 @@ > [!WARNING] > This package is in maintenance mode. -A powerful all-in-one command line utility for the Unity Game Engine. Automate Unity project setup, editor installation, license management, building, upm package signing and more! Built specifially for CI/CD pipelines and developer workflows. +A thin companion to Unity's official CLI (`unity`) for CI/CD pipelines. v4 installs and wraps the official CLI and only bridges what it does not cover yet. ## Maintenance In unity's infinite wisdom, they decided to rip off yet another one of my open source projects with a clone of their own. which has blessed me with more free time and ability to work on other projects. As always, I build these kinds of tools because there is a gap in tooling or some need of mine to fulfil. I only wish that one day I get credit for the hard work I've been doing over the last 20 years to bring games and joy to people's lives. -Bug and security fixes only, and no new commands, through **January 1, 2027**, when this GitHub repository will be archived. After that date the repository is read-only: no new issues, pull requests, or fixes. `@rage-against-the-pixel/unity-cli` stays on npm and stays installable. +v4 is the last feature release. The repository stays open for security and crash fixes only, and there are no new commands. `@rage-against-the-pixel/unity-cli` v3 and v4 stay on npm. -Unity ships an official CLI named `unity`. See [Use the Unity CLI](https://docs.unity.com/en-us/unity-cli/use-unity-cli). Prefer it for Hub install, editor install and uninstall, modules, project create and open, templates, run, build, test, serial and floating licenses, and headless service-account auth. +Use Unity's official CLI for everything it covers. See [Use the Unity CLI](https://docs.unity.com/en-us/unity-cli/use-unity-cli). That includes Hub-free editor installs and uninstalls, modules, project create and open, templates, builds, tests, serial, floating and offline licenses, and service-account auth. Pack and sign UPM packages with Unity's [Unity Package Manager CLI](https://docs.unity3d.com/Manual/upm-cli.html). -Pack and sign UPM packages with Unity's [Unity Package Manager CLI](https://docs.unity3d.com/Manual/upm-cli.html). See [Install Unity Package Manager CLI](https://docs.unity3d.com/Manual/upm-cli-install.html) and [Pack and sign a package with Unity Package Manager CLI](https://docs.unity3d.com/Manual/upm-cli-pack.html). +unity-cli v4 still covers: -Stay on this package when you still need: +- Installing and pinning the official Unity CLI, with SHA-256 verification, for CI runners. +- License activation and return with a Unity ID email and password, used when the official CLI cannot activate, such as a personal license on a headless runner. +- Unity 4.x and 5.x editors: installs from Unity's archives and the license file those editors read. +- Android target SDK setup for a project. +- Live Editor log and Unity Test Protocol output turned into GitHub Actions annotations, log groups and job summaries. -- Unity 4.x, 5.x, 2017, and 2018 installs -- Personal license activation with `--email` and `--password` -- Live Unity Test Protocol output turned into GitHub Actions annotations and log groups - -Until January 1, 2027, bug and security issues and pull requests are still in scope. Send feature requests that the official CLI already covers to Unity. +Send feature requests that the official CLI already covers to Unity. > [!IMPORTANT] -> The documented commands can download, install, or run software from Unity (Hub, Editor, Package Manager CLI, licensing tools, and similar binaries from Unity CDNs or services). That use is covered by Unity’s [Terms of Service](https://unity.com/legal/terms-of-service), the [Unity Editor Software Additional Terms](https://unity.com/legal/terms-of-service/software), and any other [Additional Terms](https://unity.com/legal/additional-terms) that apply to the offerings you use. Keep your Unity account, seats, and subscriptions in order, and read the agreements that actually bind you before relying on automation in CI or production. The full legal index is at [Unity Legal](https://unity.com/legal). +> The documented commands can download, install, or run software from Unity (the Unity CLI, Editor, licensing tools, and similar binaries from Unity CDNs or services). That use is covered by Unity’s [Terms of Service](https://unity.com/legal/terms-of-service), the [Unity Editor Software Additional Terms](https://unity.com/legal/terms-of-service/software), and any other [Additional Terms](https://unity.com/legal/additional-terms) that apply to the offerings you use. Keep your Unity account, seats, and subscriptions in order, and read the agreements that actually bind you before relying on automation in CI or production. The full legal index is at [Unity Legal](https://unity.com/legal). > > Unity, Unity Hub, Unity Editor, and related names and logos are trademarks and other intellectual property of Unity Technologies Inc. and its affiliates. This project is independent and not affiliated with Unity. Names are used here only to describe what the commands talk to. If you ship Unity marks, artwork, or binaries, use Unity’s guidance, including their [IP policy](https://unity.com/legal/ip-policy-takedown-requests). ## Table of Contents - [Maintenance](#maintenance) -- [Features](#features) +- [Upgrading from v3](#upgrading-from-v3) - [Unity version support](docs/unity-version-support.md) - [Installation](#installation) - [Usage](#usage) - [Common Commands](#common-commands) - - [Install all tools](#install-all-tools) + - [Setup Unity CLI](#setup-unity-cli) - [Auth](#auth) - [License Version](#license-version) - [Activate License](#activate-license) @@ -46,41 +46,29 @@ Until January 1, 2027, bug and security issues and pull requests are still in sc - [License Context](#license-context) - [Licensing Client Logs](#licensing-client-logs) - [Licensing Audit Logs](#licensing-audit-logs) - - [Unity Hub](#unity-hub) - - [Hub Version](#hub-version) - - [Hub Path](#hub-path) - - [Hub Logs](#hub-logs) - - [Package Manager Logs](#package-manager-logs) - - [Unity Hub Install](#unity-hub-install) - - [Run Unity Hub Commands](#run-unity-hub-commands) - - [Setup Unity Editor](#setup-unity-editor) - - [Uninstall Unity Editor](#uninstall-unity-editor) - [Unity Editor](#unity-editor) + - [Setup Unity Editor](#setup-unity-editor) - [Run Unity Editor Commands](#run-unity-editor-commands) - - [List Project Templates](#list-project-templates) - - [Create Unity Project](#create-unity-project) - - [Open Unity Project](#open-unity-project) - [Unity Editor Logs](#unity-editor-logs) - - [Unity Package Manager](#unity-package-manager) - - [Install Unity Package Manager](#install-unity-package-manager) - - [UPM Version](#upm-version) - - [Pack a Unity Package](#pack-a-unity-package) - - [Deprecated Sign Package Command](#deprecated-sign-package-command) - [Logging](#logging) - [Local cli](#local-cli) - [Github Actions](#github-actions) -## Features +## Upgrading from v3 -See [Unity version support](docs/unity-version-support.md) for how CI tiers (supported / preview / untested) map to `setup-unity --channel` and the blocking matrix. +v4 removes the commands the official CLI covers. Call `unity` for them instead. -- Install and manage Unity Hub and Unity Editors (multi-platform) -- Activate and return Unity licenses (personal, professional, floating) -- Create new Unity projects from templates -- Run Unity Editor commands and builds from the CLI -- Supports all modules, architectures, and build targets -- Works on Windows, macOS, and Linux -- Designed for automation and CI/CD +| v3 command | v4 replacement | +| --- | --- | +| `install-all-tools`, `hub-install` | `unity-cli setup-cli` (no Hub needed) | +| `hub`, `hub-path`, `hub-version`, `hub-logs`, `package-manager-logs` | `unity env`, `unity logs`, `unity doctor` | +| `uninstall-unity` | `unity uninstall --yes` | +| `list-project-templates` | `unity templates list --editor ` | +| `create-project` | `unity projects create --editor-version --template --no-cloud` | +| `open-project` | `unity open` | +| `upm-install`, `upm-version`, `upm-pack`, `sign-package` | Unity's [Package Manager CLI](https://docs.unity3d.com/Manual/upm-cli.html) | + +`setup-unity` exports `UNITY_CLI_PATH` instead of `UNITY_HUB_PATH`. Unknown commands are not forwarded to `unity`. ## Installation @@ -89,7 +77,7 @@ npm install -g @rage-against-the-pixel/unity-cli ``` > [!IMPORTANT] -> Requires Node v22.12 or higher. +> Requires Node v24 or higher. ## Usage @@ -99,7 +87,7 @@ In general, the command structure is: unity-cli [command] {options} ``` -With options always using double dashes (`--option`) and arguments passed directly to Unity or Unity Hub commands as they normally would with single dashes (`-arg`). Each option typically has a short alias using a single dash (`-o`), except for commands where we pass through arguments, as those get confused by the command parser. +With options always using double dashes (`--option`) and arguments passed directly to Unity commands as they normally would with single dashes (`-arg`). Each option typically has a short alias using a single dash (`-o`), except for commands where we pass through arguments, as those get confused by the command parser. ### Common Commands @@ -115,22 +103,22 @@ With options always using double dashes (`--option`) and arguments passed direct unity-cli --help ``` -### Install all tools +### Setup Unity CLI -`install-all-tools`: Install the Unity Hub and the Unity Package Manager cli (pack/sign). Runs `hub-install` and `upm-install` in parallel. Use `unity-cli install-all-tools --help` for all options. +`setup-cli [options]`: Install the official Unity CLI, or reuse it when the requested version is already installed. It downloads the exact release from Unity's CDN, checks its SHA-256, caches it in the runner tool cache (`unity-cli//`), adds it to the `PATH`, and exports `UNITY_CLI_PATH` and `UNITY_CLI_VERSION`. It also sets `UNITY_NON_INTERACTIVE`, `UNITY_NO_UPDATE_CHECK` and `UNITY_NO_CONSENT_PROMPT` so the CLI never prompts or updates itself mid-job. +- `--unity-cli-version `: An exact official Unity CLI version (e.g. `1.0.0-beta.14`) or `latest`. Defaults to an installed CLI, then the version unity-cli is tested against. - `--verbose`: Enable verbose logging. -- `--auto-update`: If any tools are installed, they're automatically updated to the latest versions. Cannot be used with `--hub-version` or `--upm-version`. -- `--hub-version `: Specify to install a specific version of Unity Hub. Cannot be used with `--auto-update`. -- `--upm-version `: Specify to install a specific version of the Unity Package Manager cli. Cannot be used with `--auto-update`. -- `--json`: Print hub path, UPM CLI version, and resolved UPM CLI path as JSON. +- `--json`: Prints the last line of output as JSON string. ```bash -unity-cli install-all-tools --auto-update +unity-cli setup-cli --unity-cli-version 1.0.0-beta.14 ``` ### Auth +Every license command tries the official Unity CLI first. When it fails, unity-cli falls back to `Unity.Licensing.Client` (the copy the official CLI downloads) with your Unity ID email and password. Unity 4.x and 5.x editors read their own license file, so when `UNITY_EDITOR_PATH` points at one of them, unity-cli uses the licensing client directly and patches it for that editor. + #### License Version `license-version`: Print the Unity License Client version. @@ -144,8 +132,8 @@ unity-cli license-version `activate-license [options]`: Activate a Unity license. - `-l`, `--license`: License type (personal, professional, floating). Required. -- `-e`, `--email`: Email associated with the Unity account. Required when activating a personal or professional license. -- `-p`, `--password`: Password for the Unity account. Required when activating a personal or professional license. +- `-e`, `--email`: Email associated with the Unity account. Used when the official CLI cannot activate a personal or professional license. +- `-p`, `--password`: Password for the Unity account. Used when the official CLI cannot activate a personal or professional license. - `-s`, `--serial`: License serial number. Required when activating a professional license. - `-c`, `--config`: Path to the configuration file, raw JSON, or base64 encoded JSON string. Required when activating a floating license. - `--json`: Prints the last line of output as JSON string. @@ -160,7 +148,7 @@ unity-cli activate-license --license personal --email --password `: Run commands directly to the Unity Hub. (You need not to pass `--headless` or `--` to this command). - -- `--verbose`: Enable verbose logging. -- `--json`: Prints the last line of output as a json string, which contains the operation results. -- ``: Arguments to pass directly to the Unity Hub executable. - -Lists available Unity Hub commands: - -```bash -unity-cli --verbose hub help -``` - -Gets a list of installed editors: - -```bash -unity-cli hub editors --installed -``` +### Unity Editor #### Setup Unity Editor -`setup-unity [options]`: Find or install the Unity Editor for a project or specific version. +`setup-unity [options]`: Find or install the Unity Editor for a project or specific version. 2017.4 and newer install with `unity install`. 5.x installs from Unity's download archive, because the official CLI reads one-digit majors as Unity 6 shorthand. 4.x installs from Unity's legacy installers on Windows and macOS. - `-p`, `--unity-project ` The path to a Unity project or `none` to skip project detection. - `-u`, `--unity-version ` The Unity version to get (e.g. `2020.3.1f1`, `2021.x`, `2022.1.*`, `6000`). If specified, it will override the version read from the project. @@ -271,35 +192,24 @@ unity-cli hub editors --installed - `-a`, `--arch ` The Unity architecture to get (e.g. `x86_64`, `arm64`). Defaults to the architecture of the current process. - `-b`, `--build-targets ` The Unity build target to get/install as comma-separated values (e.g. `iOS,Android`). - `-m`, `--modules ` The Unity module to get/install as comma-separated values (e.g. `ios,android`). -- `-i`, `--install-path ` The path to install the Unity Editor to. By default, it will be installed to the default Unity Hub location. +- `-i`, `--install-path ` The path to install the Unity Editor to. By default, it will be installed to the official CLI's install path. +- `--unity-cli-version ` The official Unity CLI version to install when it is missing. - `--verbose` Enable verbose logging. - `--json` Prints the last line of output as JSON string. +It exports `UNITY_CLI_PATH`, `UNITY_EDITOR_PATH`, `UNITY_EDITOR_VERSION`, `UNITY_EDITOR_CHANGESET`, `UNITY_EDITOR_CHANNELS` and, with a project, `UNITY_PROJECT_PATH`. With a project and the `android` module, it also installs the project's Android target SDK. + Installs the latest Unity 6 version with Android and iOS modules: ```bash unity-cli setup-unity --unity-version 6000 --modules android,ios ``` -#### Uninstall Unity Editor - -`uninstall-unity [options]`: Uninstall a Unity Editor version. - -- `-e`, `--unity-editor ` The path to the Unity Editor executable. If unspecified, `-u`, `--unity-version` or the `UNITY_EDITOR_PATH` environment variable must be set. -- `-u`, `--unity-version ` The Unity version to get (e.g. `2020.3.1f1`, `2021.x`, `2022.1.*`, `6000`). If unspecified, then `--unity-editor` must be specified. -- `-c`, `--changeset ` The Unity changeset to get (e.g. `1234567890ab`). -- `-a`, `--arch ` The Unity architecture to get (e.g. `x86_64`, `arm64`). Defaults to the architecture of the current process. -- `--verbose` Enable verbose logging. - -```bash -unity-cli uninstall-unity --unity-version 6000 -``` - -### Unity Editor - #### Run Unity Editor Commands -`run [options] `: Run Unity Editor command line arguments (passes args directly to the editor). +`run [options] `: Run Unity Editor command line arguments with live Editor log and Unity Test Protocol annotations. + +Runs with a project go through the official CLI's `unity run` for 2019.1 and newer editors. Runs without a project, older editors, Linux runs that need `xvfb`, and x86_64 editors under Rosetta launch the editor directly. Set `UNITY_CLI_DIRECT_LAUNCH=true` to always launch the editor directly. - `--unity-editor ` The path to the Unity Editor executable. If unspecified, `--unity-project` or the `UNITY_EDITOR_PATH` environment variable must be set. - `--unity-project ` The path to a Unity project. If unspecified, the `UNITY_PROJECT_PATH` environment variable will be used, otherwise no project will be specified. @@ -317,65 +227,6 @@ unity-cli uninstall-unity --unity-version 6000 unity-cli run --unity-project -quit -batchmode -executeMethod StartCommandLineBuild ``` -#### List Project Templates - -> [!NOTE] -> Regex patterns are supported for the `--template` option. For example, to create a 3D project with either the standard or cross-platform template, you can use `com.unity.template.3d(-cross-platform)?`. - -`list-project-templates [options]`: List available Unity project templates for an editor. - -- `-u`, `--unity-version ` The Unity version to get (e.g. `2020.3.1f1`, `2021.x`, `2022.1.*`, `6000`). If unspecified, then `--unity-editor` must be specified. -- `-e`, `--unity-editor ` The path to the Unity Editor executable. If unspecified, `-u`, `--unity-version` or the `UNITY_EDITOR_PATH` environment variable must be set. -- `--verbose` Enable verbose logging. -- `--json` Prints the last line of output as JSON string. - -Lists available project templates for Unity 6: - -```bash -unity-cli list-project-templates --unity-version 6000 -``` - -#### Create Unity Project - -`create-project [options]`: Create a new Unity project. - -- `-n`, `--name ` The name of the new Unity project. If unspecified, the project will be created in the specified path or the current working directory. -- `-p`, `--path ` The path to create the new Unity project. If unspecified, the current working directory will be used. -- `-t`, `--template ` The name of the template package to use for creating the unity project. Supports regex patterns. (default: - `com.unity.template.3d(-cross-platform)?`) -- `-u`, `--unity-version ` The Unity version to get (e.g. `2020.3.1f1`, `2021.x`, `2022.1.*`, `6000`). If unspecified, then `--unity-editor` must be specified. -- `-e`, `--unity-editor ` The path to the Unity Editor executable. If unspecified, `-u`, `--unity-version`, or the `UNITY_EDITOR_PATH` environment variable must be set. -- `--verbose` Enable verbose logging. -- `--json` Prints the last line of output as JSON string. - -Creates a new Unity project named "MyGame" using the latest version of Unity 6 and the 3D template: - -```bash -unity-cli create-project --name "MyGame" --template com.unity.template.3d(-cross-platform)? --unity-version 6000 -``` - -#### Open Unity Project - -`open-project [options]`: Open a Unity project in the Unity Editor. - -- `-p`, `--unity-project ` The path to a Unity project. If unspecified, the `UNITY_PROJECT_PATH` environment variable or the current working directory will be used. -- `-u`, `--unity-version ` The Unity version to get (e.g. `2020.3.1f1`, `2021.x`, `2022.1.*`, `6000`). If specified, it will override the version read from the project. -- `-t`, `--build-target ` The Unity build target to switch the project to (e.g. `StandaloneWindows64`, `StandaloneOSX`, `iOS`, `Android`, etc). -- `--verbose` Enable verbose logging. - -Opens a specific Unity project with the latest Unity 6 version and switches the active platform to Android: - -```bash -unity-cli open-project --unity-project --unity-version 6000 --build-target Android -``` - -> [!TIP] -> If you run this command in the same directory as your Unity project, you can omit the `--unity-project`, `--unity-version`, and `--unity-editor` options. - -```bash -unity-cli open-project -``` - #### Unity Editor Logs `editor-logs`: Prints the path to the Unity Editor log files. @@ -384,69 +235,6 @@ unity-cli open-project unity-cli editor-logs ``` -### Unity Package Manager - -Unity publishes the pack and sign tool as its own CLI. See [Unity Package Manager CLI workflow](https://docs.unity3d.com/Manual/upm-cli.html). The commands below call that same `upm` binary. - -#### Install Unity Package Manager - -`upm-install [options]`: Download and install the Unity Package Manager cli (pack/sign). - -- `--auto-update`: Automatically updates the upm cli if it is already installed and a newer release is available. Cannot be used with `--version`. -- `--version `: Specify to install a specific version of the Unity Package Manager cli. Cannot be used with `--auto-update`. -- `--json`: Print version and managed paths as JSON. -- `--verbose`: Enable verbose logging. - -```bash -unity-cli upm-install --auto-update -``` - -#### UPM Version - -`upm-version`: Print the Unity Package Manager cli version. - -```bash -unity-cli upm-version -``` - -#### Pack a Unity Package - -**Prerequisites:** In the [Unity Cloud Dashboard](https://cloud.unity.com/), create a **service account** on the organization you use for signing. When you assign **organization** access, open **Manage organization roles**, set the **Package Manager** role to **Package Manager Package Signer**, and save. Put the generated key id and secret in `UPM_SERVICE_ACCOUNT_KEY_ID` and `UPM_SERVICE_ACCOUNT_KEY_SECRET` (or your CI secret store). Copy **Organization ID** from **Administration** → **Settings** in that same org. If you have multiple orgs, switch to the correct one in the dashboard before creating keys or copying the id. - -`upm-pack [options]`: Sign and pack a Unity package. - -- `--source `: An absolute or relative path to the root folder of the custom package to pack. This is the folder that contains the package manifest file (package.json). (optional; defaults to the current working directory). -- `--destination `: The output path for the signed tarball. If you specify a folder that doesn’t exist, it will be created for you. Note: If you omit this parameter, the tarball will be placed in the current working directory. -- `--verbose`: Enable verbose logging. - -> [!NOTE] -> Set `UNITY_ORGANIZATION_ID` or `UNITY_ORG_ID`, `UPM_SERVICE_ACCOUNT_KEY_ID`, and `UPM_SERVICE_ACCOUNT_KEY_SECRET`, or leave them unset in an interactive terminal to be prompted securely. - -```bash -unity-cli upm-pack --source --destination -``` - -#### Deprecated Sign Package Command - -> [!WARNING] -> **Deprecated:** The `sign-package` command is deprecated. Use `unity-cli upm-pack --source --destination ` with `UNITY_ORGANIZATION_ID` or `UNITY_ORG_ID`, `UPM_SERVICE_ACCOUNT_KEY_ID`, and `UPM_SERVICE_ACCOUNT_KEY_SECRET` (or secure prompts). - -`sign-package [options]`: [Deprecated] Sign a Unity package using Unity Editor 6000.3+ batch mode (`-upmPack`). Use `unity-cli upm-pack` with organization and service account credentials for new workflows. - -- `--package ` Required. The fully qualified path to the folder that contains the package.json file for the package you want to sign. Note: Don't include package.json in this parameter value. -- `--output ` Optional. The output directory where you want to save the signed tarball file (.tgz). If unspecified, the package contents will be updated in place with the signed .attestation.p7m file. -- `--email ` Email associated with the Unity account. If unspecified, the `UNITY_USERNAME` environment variable will be used. -- `--password ` The password of the Unity account. If unspecified, the `UNITY_PASSWORD` environment variable will be used. -- `--organization ` The Organization ID you copied from the Unity Cloud Dashboard. If unspecified, the `UNITY_ORGANIZATION_ID` environment variable will be used. -- `--verbose` Enable verbose logging. - -> [!NOTE] -> The `--output` option is optional. If not specified, the package contents will be updated in place with the signed `.attestation.p7m` file. Otherwise a signed `.tgz` file will be created in the specified output directory. - -```bash -unity-cli sign-package --package --email --password --organization -``` - ## Logging ### Local cli @@ -463,21 +251,7 @@ When `GITHUB_ACTIONS=true`, the logger emits GitHub workflow commands automatica - Defaults to `info` level; add `--verbose` (or temporarily set `ACTIONS_STEP_DEBUG=true`) to surface `debug` lines. - `Logger.annotate(...)` escapes `%`, `\r`, and `\n`, then includes `file`, `line`, `endLine`, `col`, `endColumn`, and `title` metadata so annotations are clickable in the Checks UI. - `startGroup`/`endGroup` become `::group::` / `::endgroup::` blocks. -- `CI_mask`, `CI_setEnvironmentVariable`, and `CI_setOutput` write to the corresponding GitHub-provided files when those features are configured. +- `CI_mask`, `CI_setEnvironmentVariable`, `CI_setOutput` and `CI_addPath` write to the corresponding GitHub-provided files when those features are configured. - **Job summary (`GITHUB_STEP_SUMMARY`) is opt-in:** set `UNITY_CLI_WORKFLOW_SUMMARY` to `1`, `true`, `yes`, or `on` (case-insensitive) so `CI_appendWorkflowSummary` can append the rich markdown block from Unity log / UTP parsing. If unset, summary output is skipped (annotations and stdout behavior are unchanged). The same command line you run locally therefore produces colorized console output on your machine and rich annotations once it runs inside Actions. - -### Additional CI Environments - -At the moment, only GitHub Actions is supported for enhanced logging. If you would like to see support for additional CI environments, please open a pull request or feature request on the GitHub repository. - -#### Roadmap - -- [ ] Support Azure DevOps logging commands -- [ ] Support GitLab CI logging commands -- [ ] Support Bitbucket Pipelines logging commands -- [ ] Support Jenkins logging commands -- [ ] Support CircleCI logging commands -- [ ] Support Travis CI logging commands -- [ ] Support TeamCity logging commands diff --git a/docs/unity-version-support.md b/docs/unity-version-support.md index 6083d687..ad3ba20f 100644 --- a/docs/unity-version-support.md +++ b/docs/unity-version-support.md @@ -11,7 +11,7 @@ This repo tests Unity editors on CI in three tiers: ## Resolving versions - Partial versions (`6000.5`, `2021.3.x`) resolve only within the requested channel(s). Default is **stable (`f`)**. -- If no matching stable release exists (e.g. `6000.6` while only betas ship), `setup-unity` **fails closed** instead of letting Hub guess a beta. +- If no matching stable release exists (e.g. `6000.6` while only betas ship), `setup-unity` **fails closed** instead of letting the installer guess a beta. - Use `--channel b` (or `a`) when you intentionally want pre-release streams. - Weekly [unity-release-discovery](../.github/workflows/unity-release-discovery.yml) compares the Releases API to the matrix/canary pin and opens/updates a `unity-release-drift` issue when tips move. diff --git a/jest.config.mjs b/jest.config.mjs index 87a4c094..7dcab062 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -5,8 +5,5 @@ export default { transform: { '^.+\\.m?tsx?$': ['ts-jest', { useESM: true, tsconfig: '/tsconfig.jest.json' }], }, - moduleNameMapper: { - '^@electron/asar$': '/tests/mocks/electron-asar.js', - }, testMatch: ['**/?(*.)+(test).ts'], }; diff --git a/package-lock.json b/package-lock.json index 944cfebb..e55318b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,23 +1,20 @@ { "name": "@rage-against-the-pixel/unity-cli", - "version": "3.0.2", + "version": "4.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@rage-against-the-pixel/unity-cli", - "version": "3.0.2", + "version": "4.0.0", "license": "MIT", "dependencies": { - "@electron/asar": "^4.2.1", "@rage-against-the-pixel/unity-releases-api": "^1.0.5", "commander": "^14.0.3", "glob": "^13.0.6", "semver": "^7.8.5", "source-map-support": "^0.5.21", - "tar": "^7.5.22", - "update-notifier": "^7.3.1", - "yaml": "^2.9.0" + "update-notifier": "^7.3.1" }, "bin": { "unity-cli": "dist/cli.js" @@ -30,7 +27,8 @@ "jest": "^30.4.2", "ts-jest": "^29.4.12", "ts-node": "^10.9.2", - "typescript": "^6.0.3" + "typescript": "^6.0.3", + "yaml": "^2.9.1" }, "engines": { "node": ">=24.0.0" @@ -576,22 +574,6 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, - "node_modules/@electron/asar": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-4.2.1.tgz", - "integrity": "sha512-rGyEe7iy52zxiWuihV/h/AqQrFkx7YnUUJKW1bdufVDHCzIMP/XDrDI/NOzv/LLtzt3NduAzNa475WRmRnmswQ==", - "license": "MIT", - "dependencies": { - "glob": "^13.0.2", - "minimatch": "^10.0.1" - }, - "bin": { - "asar": "bin/asar.mjs" - }, - "engines": { - "node": ">=22.12.0" - } - }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -626,18 +608,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@isaacs/fs-minipass": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", - "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", - "license": "ISC", - "dependencies": { - "minipass": "^7.0.4" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -2355,15 +2325,6 @@ "node": ">=10" } }, - "node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, "node_modules/ci-info": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", @@ -4493,18 +4454,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/minizlib": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", - "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", - "license": "MIT", - "dependencies": { - "minipass": "^7.1.2" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -5179,31 +5128,6 @@ "url": "https://opencollective.com/synckit" } }, - "node_modules/tar": { - "version": "7.5.22", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", - "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.1.0", - "yallist": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/tar/node_modules/yallist": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, "node_modules/test-exclude": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", @@ -5829,9 +5753,10 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "dev": true, "license": "ISC", "bin": { "yaml": "bin.mjs" diff --git a/package.json b/package.json index 127cc1c0..c6b5c900 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@rage-against-the-pixel/unity-cli", - "version": "3.0.2", - "description": "A command line utility for the Unity Game Engine.", + "version": "4.0.0", + "description": "Bridges the gaps in the official Unity CLI for CI: Unity ID licensing, Unity 4.x and 5.x editors, Android SDK setup and Editor log annotations. Maintenance mode: security fixes only.", "author": "RageAgainstThePixel", "license": "MIT", "repository": { @@ -53,15 +53,12 @@ "unlink": "npm unlink @rage-against-the-pixel/unity-cli" }, "dependencies": { - "@electron/asar": "^4.2.1", "@rage-against-the-pixel/unity-releases-api": "^1.0.5", "commander": "^14.0.3", "glob": "^13.0.6", "semver": "^7.8.5", "source-map-support": "^0.5.21", - "tar": "^7.5.22", - "update-notifier": "^7.3.1", - "yaml": "^2.9.0" + "update-notifier": "^7.3.1" }, "devDependencies": { "@types/jest": "^30.0.0", @@ -71,7 +68,8 @@ "jest": "^30.4.2", "ts-jest": "^29.4.12", "ts-node": "^10.9.2", - "typescript": "^6.0.3" + "typescript": "^6.0.3", + "yaml": "^2.9.1" }, "overrides": { "babel-plugin-istanbul": { diff --git a/src/cli.ts b/src/cli.ts index a5fb9fe7..5f8d9d56 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,20 +3,24 @@ import 'source-map-support/register'; import * as fs from 'fs'; import * as os from 'os'; -import * as tar from 'tar'; import * as path from 'path'; import { Command } from 'commander'; -import { UnityHub } from './unity-hub'; -import { UnityEditor } from './unity-editor'; import updateNotifier from "update-notifier"; import { Logger, LogLevel } from './logging'; +import { UnityCli } from './unity-cli'; +import { UnityEditor } from './unity-editor'; +import { UnityInstaller } from './unity-installer'; import { UnityVersion } from './unity-version'; import { UnityProject } from './unity-project'; -import { ChildProcess, spawn } from 'child_process'; import { CheckAndroidSdkInstalled } from './android-sdk'; -import { LicenseType, LicensingClient } from './license-client'; -import { PromptForSecretInput, ResolveGlobToPath } from './utilities'; -import { UpmCli, UpmPackOptions } from './upm-cli'; +import { + ActivateLicense, + LicenseType, + LicensingClient, + PrintLicenseContext, + ReturnLicense, +} from './license-client'; +import { PromptForSecretInput } from './utilities'; const pkgPath = path.join(__dirname, '..', 'package.json'); const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); @@ -24,87 +28,29 @@ updateNotifier({ pkg }).notify(); const program = new Command(); program.name('unity-cli') - .description('A powerful all-in-one command line utility for the Unity Game Engine.') + .description('Bridges the gaps in the official Unity CLI (unity) for CI: Unity ID licensing, Unity 4.x and 5.x editors, Android SDK setup and Editor log annotations.') .version(pkg.version); -program.command('install-all-tools') - .description('Install the Unity Hub and the Unity Package Manager cli (pack/sign). Runs hub-install and upm-install in parallel.') +program.command('setup-cli') + .description('Install the official Unity CLI (unity), or reuse it when the requested version is already installed.') + .option('--unity-cli-version ', 'An exact official Unity CLI version (e.g. 1.0.0-beta.14) or latest. Defaults to an installed CLI, then the version unity-cli is tested against.') .option('--verbose', 'Enable verbose logging.') - .option('--auto-update', 'If any tools are installed, they\'re automatically updated to the latest versions. Cannot be used with --hub-version or --upm-version.') - .option('--hub-version ', 'Specify to install a specific version of Unity Hub. Cannot be used with --auto-update.') - .option('--upm-version ', 'Specify to install a specific version of the Unity Package Manager cli. Cannot be used with --auto-update.') - .option('--json', 'Print hub path, UPM CLI version, and resolved UPM CLI path as JSON.') + .option('--json', 'Prints the last line of output as JSON string.') .action(async (options) => { if (options.verbose) { Logger.instance.logLevel = LogLevel.DEBUG; } Logger.instance.debugOptions(options); + const cli = await UnityCli.Get(options.unityCliVersion?.toString()?.trim() || undefined); - if (options.autoUpdate === true && options.hubVersion) { - Logger.instance.error('Cannot use --auto-update with --hub-version.'); - process.exit(1); - } - - if (options.autoUpdate === true && options.upmVersion) { - Logger.instance.error('Cannot use --auto-update with --upm-version.'); - process.exit(1); + if (options.json) { + process.stdout.write(`\n${JSON.stringify({ UNITY_CLI_PATH: cli.executable, UNITY_CLI_VERSION: cli.version })}\n`); + } else { + process.stdout.write(`${cli.executable}\n`); } - try { - const unityHub = new UnityHub(); - const upm = new UpmCli(); - let upmRequestedVersion = options.upmVersion?.toString()?.trim(); - if (options.autoUpdate === true) { - const currentVersion = upm.GetInstalledReleaseTag(); - const latestVersion = await upm.GetLatestReleaseTag(); - if (currentVersion && !upm.IsUpdateAvailable(latestVersion)) { - Logger.instance.info(`Upm cli is already up to date (${currentVersion}).`); - upmRequestedVersion = currentVersion; - } else { - if (currentVersion) { - Logger.instance.info(`Updating upm cli ${currentVersion} -> ${latestVersion}...`); - } - upmRequestedVersion = latestVersion; - } - } - - const [hubPath, upmVer] = await Promise.all([ - unityHub.Install(options.autoUpdate === true, options.hubVersion), - upm.Install({ - version: upmRequestedVersion, - skipIfInstalled: true - }), - ]); - Logger.instance.CI_setEnvironmentVariable('UNITY_HUB_PATH', hubPath); - let upmCliPath: string | undefined; - try { - await upm.Version(upmVer); - upmCliPath = upm.executable; - } catch (verifyError) { - Logger.instance.warn(`Upm cli version check failed after install: ${verifyError}`); - if (process.env.CI === 'true') { - Logger.instance.error('Failing in CI because the installed UPM CLI did not match the expected release.'); - process.exit(1); - } - upmCliPath = upm.ResolveManagedPrimaryPath(); - } - - if (options.json) { - process.stdout.write(`\n${JSON.stringify({ - UNITY_HUB_PATH: hubPath, - UPM_VERSION: upmVer, - UPM_CLI_PATH: upmCliPath, - })}\n`); - } else { - process.stdout.write(`Unity Hub: ${hubPath}\nUpm cli: ${upmVer}\n${upmCliPath ?? ''}\n`); - } - - process.exit(0); - } catch (error) { - Logger.instance.error(`${error}`); - process.exit(1); - } + process.exit(0); }); program.commandsGroup('Auth:'); @@ -118,10 +64,10 @@ program.command('license-version') }); program.command('activate-license') - .description('Activate a Unity license.') + .description('Activate a Unity license. Tries the official Unity CLI first, then the Unity Licensing Client with your Unity ID email and password.') .option('-l, --license ', 'License type (personal, professional, floating). Required.') - .option('-e, --email ', 'Email associated with the Unity account. Required when activating a personal or professional license.') - .option('-p, --password ', 'Password for the Unity account. Required when activating a personal or professional license.') + .option('-e, --email ', 'Email associated with the Unity account. Used when the Unity CLI cannot activate a personal or professional license.') + .option('-p, --password ', 'Password for the Unity account. Used when the Unity CLI cannot activate a personal or professional license.') .option('-s, --serial ', 'License serial number. Required when activating a professional license.') .option('-c, --config ', 'Path to the configuration file, raw JSON, or base64 encoded JSON string. Required when activating a floating license.') .option('--verbose', 'Enable verbose logging.') @@ -133,7 +79,6 @@ program.command('activate-license') Logger.instance.debugOptions(options); - const client = new LicensingClient(); const licenseStr: string = options.license?.toString()?.trim(); if (!licenseStr || licenseStr.length === 0) { @@ -141,33 +86,23 @@ program.command('activate-license') process.exit(1); } - const licenseType: LicenseType = options.license.toLowerCase() as LicenseType; + const licenseType: LicenseType = licenseStr.toLowerCase() as LicenseType; if (![LicenseType.personal, LicenseType.professional, LicenseType.floating].includes(licenseType)) { Logger.instance.error(`Invalid license type: ${licenseType}`); process.exit(1); } - if (licenseType !== LicenseType.floating) { - if (!options.email) { - options.email = await PromptForSecretInput('Email: '); - } - - if (!options.password) { - options.password = await PromptForSecretInput('Password: '); - } - - if (licenseType === LicenseType.professional && !options.serial) { - options.serial = await PromptForSecretInput('Serial: '); - } - - // Mask credentials in CI environments before any potential logging - Logger.instance.maskCredential(options.email); - Logger.instance.maskCredential(options.password); - Logger.instance.maskCredential(options.serial); + if (licenseType === LicenseType.professional && !options.serial) { + options.serial = await PromptForSecretInput('Serial: '); } - const token = await client.Activate({ + // Mask credentials in CI environments before any potential logging + Logger.instance.maskCredential(options.email); + Logger.instance.maskCredential(options.password); + Logger.instance.maskCredential(options.serial); + + const token = await ActivateLicense({ licenseType, servicesConfig: options.config, serial: options.serial, @@ -183,9 +118,9 @@ program.command('activate-license') }); program.command('return-license') - .description('Return a Unity license.') + .description('Return a Unity license. Tries the official Unity CLI first, then the Unity Licensing Client.') .option('-l, --license ', 'License type (personal, professional, floating)') - .option('-t, --token ', 'Floating license token. Required when returning a floating license.') + .option('-t, --token ', 'Floating license token from activation. When given, the floating license is returned with the Unity Licensing Client.') .option('--verbose', 'Enable verbose logging.') .action(async (options) => { if (options.verbose) { @@ -194,7 +129,6 @@ program.command('return-license') Logger.instance.debugOptions(options); - const client = new LicensingClient(); const licenseStr: string = options.license?.toString()?.trim(); if (!licenseStr || licenseStr.length === 0) { @@ -209,31 +143,19 @@ program.command('return-license') process.exit(1); } - let token: string | undefined = options.token; + const token: string | undefined = options.token?.toString()?.trim() || undefined; - if (licenseType === LicenseType.floating) { - if (!token || token.length === 0) { - token = await PromptForSecretInput('Token: '); - } - - if (!token || token.length === 0) { - Logger.instance.error('Token is required when returning a floating license. Use -t or --token to specify it.'); - process.exit(1); - } - - // Mask token in CI environments before any potential logging - Logger.instance.maskCredential(token); - } + // Mask token in CI environments before any potential logging + Logger.instance.maskCredential(token); - await client.Deactivate(licenseType, token); + await ReturnLicense(licenseType, token); process.exit(0); }); program.command('license-context') - .description('Print the current license context information.') + .description('Print the current license state from the Unity CLI and the Unity Licensing Client.') .action(async () => { - const client = new LicensingClient(); - await client.Context(); + await PrintLicenseContext(); process.exit(0); }); @@ -251,110 +173,10 @@ program.command('licensing-audit-logs') process.exit(0); }); -program.commandsGroup('Unity Hub:'); - -program.command('hub-version') - .description('Print the Unity Hub version.') - .action(async () => { - const unityHub = new UnityHub(); - try { - const version = await unityHub.Version(); - process.stdout.write(`${version}\n`); - } catch (error) { - process.stdout.write(`${error}\n`); - } finally { - process.exit(0); - } - }); - -program.command('hub-path') - .description('Print the path to the Unity Hub executable.') - .option('--json', 'Prints the last line of output as JSON string.') - .action(async (options) => { - const hub = new UnityHub(); - - Logger.instance.CI_setEnvironmentVariable('UNITY_HUB_PATH', hub.executable); - - if (options.json) { - process.stdout.write(`\n${JSON.stringify({ UNITY_HUB_PATH: hub.executable })}\n`); - } else { - process.stdout.write(`${hub.executable}\n`); - } - - process.exit(0); - }); - -program.command('hub-logs') - .description('Prints the path to the Unity Hub log file.') - .action(async () => { - process.stdout.write(`${UnityHub.LogPath()}\n`); - process.exit(0); - }); - -program.command('package-manager-logs') - .description('Prints the path to the Unity Package Manager log file.') - .action(async () => { - process.stdout.write(`${UnityHub.PackageManagerLogsPath()}\n`); - process.exit(0); - }); - -program.command('hub-install') - .description('Install or update the Unity Hub.') - .option('--verbose', 'Enable verbose logging.') - .option('--auto-update', 'Automatically updates the Unity Hub if it is already installed. Cannot be used with --hub-version.') - .option('--hub-version ', 'Specify to install a specific version of Unity Hub. Cannot be used with --auto-update.') - .option('--json', 'Prints the last line of output as JSON string.') - .action(async (options) => { - if (options.verbose) { - Logger.instance.logLevel = LogLevel.DEBUG; - } - - Logger.instance.debugOptions(options); - - if (options.autoUpdate === true && options.hubVersion) { - Logger.instance.error('Cannot use --auto-update with --hub-version.'); - process.exit(1); - } - - const unityHub = new UnityHub(); - const hubPath = await unityHub.Install(options.autoUpdate === true, options.hubVersion); - - Logger.instance.CI_setEnvironmentVariable('UNITY_HUB_PATH', hubPath); - - if (options.json) { - process.stdout.write(`\n${JSON.stringify({ UNITY_HUB_PATH: hubPath })}\n`); - } else { - process.stdout.write(`${hubPath}\n`); - } - - process.exit(0); - }); - -program.command('hub') - .description('Run commands directly to the Unity Hub. (You need not to pass --headless or -- to this command).') - .option('--verbose', 'Enable verbose logging.') - .option('--json', 'Prints the last line of output as a json string, which contains the operation results.') - .allowUnknownOption(true) - .argument('', 'Arguments to pass to the Unity Hub executable.') - .action(async (args: string[], options) => { - if (options.verbose) { - Logger.instance.logLevel = LogLevel.DEBUG; - } - - Logger.instance.debugOptions({ args, options }); - - const unityHub = new UnityHub(); - const output = await unityHub.Exec(args, { silent: false, showCommand: Logger.instance.logLevel === LogLevel.DEBUG }); - - if (options.json) { - process.stdout.write(`\n${JSON.stringify({ output })}\n`); - } - - process.exit(0); - }); +program.commandsGroup('Unity Editor:'); program.command('setup-unity') - .description('Find or install the Unity Editor for a project or specific version.') + .description('Find or install the Unity Editor for a project or specific version. 2017.4 and newer install with the official Unity CLI; 4.x and 5.x install from Unity\'s archives.') .option('-p, --unity-project ', 'The path to a Unity project or "none" to skip project detection.') .option('-u, --unity-version ', 'The Unity version to get (e.g. 2020.3.1f1, 2021.x, 2022.1.*, 6000). If specified, it will override the version read from the project.') .option('-c, --changeset ', 'The Unity changeset to get (e.g. 1234567890ab).') @@ -362,7 +184,8 @@ program.command('setup-unity') .option('-a, --arch ', 'The Unity architecture to get (e.g. x86_64, arm64). Defaults to the architecture of the current process.') .option('-b, --build-targets ', 'The Unity build target to get/install as comma-separated values (e.g. iOS,Android).') .option('-m, --modules ', 'The Unity module to get/install as comma-separated values (e.g. ios,android).') - .option('-i, --install-path ', 'The path to install the Unity Editor to. By default, it will be installed to the default Unity Hub location.') + .option('-i, --install-path ', 'The path to install the Unity Editor to. By default, it will be installed to the Unity CLI install path.') + .option('--unity-cli-version ', 'The official Unity CLI version to install when it is missing (e.g. 1.0.0-beta.14, latest).') .option('--verbose', 'Enable verbose logging.') .option('--json', 'Prints the last line of output as JSON string.') .action(async (options) => { @@ -386,14 +209,16 @@ program.command('setup-unity') let unityVersion: UnityVersion; if (options.unityVersion) { - unityVersion = new UnityVersion(options.unityVersion, options.changeset, options.arch); + const arch = options.arch?.toString()?.trim().toLowerCase(); + const architecture = arch === 'arm64' ? 'ARM64' : arch === 'x86_64' || arch === 'x64' ? 'X86_64' : undefined; + unityVersion = new UnityVersion(options.unityVersion, options.changeset, architecture); } else { unityVersion = unityProject!.version; } const modules: string[] = options.modules ? options.modules.split(/[ ,]+/).filter(Boolean) : []; const buildTargets: string[] = options.buildTargets ? options.buildTargets.split(/[ ,]+/).filter(Boolean) : []; - const moduleBuildTargetMap = UnityHub.GetPlatformTargetModuleMap(); + const moduleBuildTargetMap = UnityInstaller.GetPlatformTargetModuleMap(); for (const target of buildTargets) { const module = moduleBuildTargetMap[target]; @@ -416,7 +241,6 @@ program.command('setup-unity') modules.length = 0; } - const unityHub = new UnityHub(); const channels: string[] = options.channel ? String(options.channel).split(/[ ,]+/).map((c: string) => c.trim().toLowerCase()).filter(Boolean) : ['f']; @@ -427,9 +251,17 @@ program.command('setup-unity') process.exit(1); } } - const unityEditor = await unityHub.GetEditor(unityVersion, modules, channels); + + const unityCli = await UnityCli.Get(options.unityCliVersion?.toString()?.trim() || undefined); + const installer = new UnityInstaller(unityCli); + + if (options.installPath) { + await installer.SetInstallPath(options.installPath); + } + + const unityEditor = await installer.GetEditor(unityVersion, modules, channels); const output: { [key: string]: string } = { - 'UNITY_HUB_PATH': unityHub.executable, + 'UNITY_CLI_PATH': unityCli.executable, 'UNITY_EDITOR_PATH': unityEditor.editorPath, 'UNITY_EDITOR_VERSION': unityEditor.version.version, 'UNITY_EDITOR_CHANGESET': unityEditor.version.changeset ?? '', @@ -464,63 +296,8 @@ program.command('setup-unity') process.exit(0); }); -program.command('uninstall-unity') - .description('Uninstall a Unity Editor version.') - .option('-e, --unity-editor ', 'The path to the Unity Editor executable. If unspecified, -u, --unity-version or the UNITY_EDITOR_PATH environment variable must be set.') - .option('-u, --unity-version ', 'The Unity version to get (e.g. 2020.3.1f1, 2021.x, 2022.1.*, 6000). If unspecified, then --unity-editor must be specified.') - .option('-c, --changeset ', 'The Unity changeset to get (e.g. 1234567890ab).') - .option('-a, --arch ', 'The Unity architecture to get (e.g. x86_64, arm64). Defaults to the architecture of the current process.') - .option('--verbose', 'Enable verbose logging.') - .action(async (options) => { - if (options.verbose) { - Logger.instance.logLevel = LogLevel.DEBUG; - } - - Logger.instance.debugOptions(options); - - let unityEditor: UnityEditor | undefined; - const unityVersionStr = options.unityVersion?.toString()?.trim(); - - if (unityVersionStr) { - const unityVersion = new UnityVersion(unityVersionStr, options.changeset, options.arch); - const unityHub = new UnityHub(); - const installedEditors = await unityHub.ListInstalledEditors(); - - if (unityVersion.isLegacy()) { - const installPath = await unityHub.GetInstallPath(); - unityEditor = new UnityEditor(path.join(installPath, `Unity ${unityVersion.toString()}`, 'Unity.exe')); - } else { - unityEditor = installedEditors.find(e => e.version.equals(unityVersion)); - } - } else { - const editorPath = options.unityEditor?.toString()?.trim() || process.env.UNITY_EDITOR_PATH || undefined; - - if (!editorPath || editorPath.length === 0) { - Logger.instance.error('You must specify a Unity version or editor path with -u, --unity-version, -e, --unity-editor.'); - process.exit(1); - } - - try { - unityEditor = new UnityEditor(editorPath); - } catch { - // ignored - } - } - - if (!unityEditor) { - Logger.instance.info('The specified Unity Editor was not found.'); - } - else { - await unityEditor.Uninstall(); - } - - process.exit(0); - }); - -program.commandsGroup('Unity Editor:'); - program.command('run') - .description('Run Unity Editor command line arguments (passes args directly to the editor).') + .description('Run Unity Editor command line arguments with live Editor log and UTP annotations. Runs with a project go through the official CLI\'s unity run.') .option('--unity-editor ', 'The path to the Unity Editor executable. If unspecified, --unity-project or the UNITY_EDITOR_PATH environment variable must be set.') .option('--unity-project ', 'The path to a Unity project. If unspecified, the UNITY_PROJECT_PATH environment variable will be used, otherwise no project will be specified.') .option('--log-name ', 'The name of the log file.') @@ -605,9 +382,8 @@ program.command('run') } if (!unityEditor) { - const unityHub = new UnityHub(); try { - unityEditor = await unityHub.GetEditor(unityProject.version); + unityEditor = await new UnityInstaller().GetEditor(unityProject.version); } catch { Logger.instance.error(`Could not find Unity Editor version ${unityProject.version.version} installed for project at ${unityProject.projectPath}. Please specify the editor path with --unity-editor or set the UNITY_EDITOR_PATH environment variable.`); process.exit(1); @@ -632,205 +408,6 @@ program.command('run') process.exit(0); }); -program.command('list-project-templates') - .description('List available Unity project templates for an editor.') - .option('-u, --unity-version ', 'The Unity version to get (e.g. 2020.3.1f1, 2021.x, 2022.1.*, 6000). If unspecified, then --unity-editor must be specified.') - .option('-e, --unity-editor ', 'The path to the Unity Editor executable. If unspecified, -u, --unity-version or the UNITY_EDITOR_PATH environment variable must be set.') - .option('--verbose', 'Enable verbose logging.') - .option('--json', 'Prints the last line of output as JSON string.') - .action(async (options) => { - if (options.verbose) { - Logger.instance.logLevel = LogLevel.DEBUG; - } - - Logger.instance.debugOptions(options); - - const unityVersionStr = options.unityVersion?.toString()?.trim(); - const editorFromEnv = process.env.UNITY_EDITOR_PATH?.trim(); - - if (!unityVersionStr && !options.unityEditor?.toString()?.trim() && !editorFromEnv) { - Logger.instance.error('You must specify a Unity version or editor path with -u, --unity-version, -e, --unity-editor, or set UNITY_EDITOR_PATH.'); - process.exit(1); - } - - let unityEditor: UnityEditor; - - if (unityVersionStr) { - const unityVersion = new UnityVersion(unityVersionStr); - unityEditor = await new UnityHub().GetEditor(unityVersion); - } else { - const editorPath = options.unityEditor?.toString()?.trim() || editorFromEnv; - - if (!editorPath || editorPath.length === 0) { - throw new Error('The Unity Editor path was not specified. Use -e or --unity-editor to specify it, or set the UNITY_EDITOR_PATH environment variable.'); - } - - unityEditor = new UnityEditor(editorPath); - } - - const templates = unityEditor.GetAvailableTemplates(); - - if (templates.length > 0) { - if (options.json) { - process.stdout.write(`\n${JSON.stringify({ templates })}\n`); - } else { - process.stdout.write(`Available project templates:\n${templates.map(t => ` - ${path.basename(t)}`).join('\n')}\n`); - } - } else { - process.stdout.write('No project templates found for this Unity Editor.\n'); - } - - process.exit(0); - }); - -program.command('create-project') - .description('Create a new Unity project.') - .option('-n, --name ', 'The name of the new Unity project. If unspecified, the project will be created in the specified path or the current working directory.') - .option('-p, --path ', 'The path to create the new Unity project. If unspecified, the current working directory will be used.') - .option('-t, --template ', 'The name of the template package to use for creating the unity project. Supports regex patterns.', 'com.unity.template.3d(-cross-platform)?') - .option('-u, --unity-version ', 'The Unity version to get (e.g. 2020.3.1f1, 2021.x, 2022.1.*, 6000). If unspecified, then --unity-editor must be specified.') - .option('-e, --unity-editor ', 'The path to the Unity Editor executable. If unspecified, -u, --unity-version, or the UNITY_EDITOR_PATH environment variable must be set.') - .option('--verbose', 'Enable verbose logging.') - .option('--json', 'Prints the last line of output as JSON string.') - .action(async (options) => { - if (options.verbose) { - Logger.instance.logLevel = LogLevel.DEBUG; - } - - Logger.instance.debugOptions(options); - - const unityVersionStr = options.unityVersion?.toString()?.trim(); - const editorFromEnv = process.env.UNITY_EDITOR_PATH?.trim(); - - if (!unityVersionStr && !options.unityEditor?.toString()?.trim() && !editorFromEnv) { - Logger.instance.error('You must specify a Unity version or editor path with -u, --unity-version, -e, --unity-editor, or set UNITY_EDITOR_PATH.'); - process.exit(1); - } - - let unityEditor: UnityEditor; - - if (unityVersionStr) { - const unityVersion = new UnityVersion(unityVersionStr); - unityEditor = await new UnityHub().GetEditor(unityVersion); - } else { - const editorPath = options.unityEditor?.toString()?.trim() || editorFromEnv; - - if (!editorPath || editorPath.length === 0) { - Logger.instance.error('The Unity Editor path was not specified. Use -e or --unity-editor to specify it, or set the UNITY_EDITOR_PATH environment variable.'); - process.exit(1); - } - - unityEditor = new UnityEditor(editorPath); - } - - let args: string[] = [ - '-quit', - '-nographics', - '-batchmode' - ]; - - const projectName = options.name?.toString()?.trim(); - let projectPath = options.path?.toString()?.trim() || process.cwd(); - - if (projectName && projectName.length > 0) { - projectPath = path.join(projectPath, projectName); - } - - args.push('-createProject', projectPath); - - if (options.template && options.template.length > 0) { - const availableTemplates = unityEditor.GetAvailableTemplates(); - - if (availableTemplates.length === 0) { - if (unityEditor.version.isLessThan('2019.0.0')) { - Logger.instance.info( - `Skipping template selection for Unity ${unityEditor.version.toString()} because project templates are not supported.` - ); - } else { - throw new Error(`No Unity project templates found for ${unityEditor.version.toString()}`); - } - } else { - const templatePath = unityEditor.GetTemplatePath(options.template) ?? - unityEditor.GetTemplatePath('com\\.unity\\.template\\.urp-[\\w-]+') ?? - availableTemplates[0]; - - if (!templatePath) { - throw new Error(`No Unity project template found for ${options.template}`); - } - - args.push('-cloneFromTemplate', templatePath); - } - } - - await unityEditor.Run({ projectPath, args }); - - Logger.instance.CI_setEnvironmentVariable('UNITY_PROJECT_PATH', projectPath); - - if (options.json) { - process.stdout.write(`\n${JSON.stringify({ UNITY_PROJECT_PATH: projectPath })}\n`); - } else { - process.stdout.write(`Unity project created at: ${projectPath}\n`); - } - - process.exit(0); - }); - -program.command('open-project') - .description('Open a Unity project in the Unity Editor.') - .option('-p, --unity-project ', 'The path to a Unity project. If unspecified, the UNITY_PROJECT_PATH environment variable or the current working directory will be used.') - .option('-u, --unity-version ', 'The Unity version to get (e.g. 2020.3.1f1, 2021.x, 2022.1.*, 6000). If specified, it will override the version read from the project.') - .option('-t, --build-target ', 'The Unity build target to switch the project to (e.g. StandaloneWindows64, StandaloneOSX, iOS, Android).') - .option('--verbose', 'Enable verbose logging.') - .action(async (options) => { - if (options.verbose) { - Logger.instance.logLevel = LogLevel.DEBUG; - } - - Logger.instance.debugOptions(options); - const projectPath = options.unityProject?.toString()?.trim() || - process.env.UNITY_PROJECT_PATH || - undefined; - const unityProject = await UnityProject.GetProject(projectPath); - - if (!unityProject) { - Logger.instance.error(`The specified path is not a valid Unity project: ${projectPath}`); - process.exit(1); - } - - let unityVersion: UnityVersion | undefined = unityProject?.version; - - if (options.unityVersion) { - unityVersion = new UnityVersion(options.unityVersion); - } - - const buildTarget = options.buildTarget?.toString()?.trim(); - let module: string[] | undefined = undefined; - - if (buildTarget && buildTarget.length > 0) { - const moduleBuildTargetMap = UnityHub.GetPlatformTargetModuleMap(); - module = moduleBuildTargetMap[buildTarget] ? [moduleBuildTargetMap[buildTarget]] : undefined; - } - - const unityHub = new UnityHub(); - const unityEditor = await unityHub.GetEditor(unityVersion, module); - - Logger.instance.info(`Opening "${unityProject.projectPath}" with Unity ${unityEditor.version}${buildTarget ? ` - ${buildTarget}` : ''}...`); - - const openArgs = ['-projectPath', unityProject.projectPath]; - - if (buildTarget && buildTarget.length > 0) { - openArgs.push('-buildTarget', buildTarget); - } - - let child: ChildProcess | null = null; - try { - child = spawn(unityEditor.editorPath, openArgs, { detached: true }); - child.unref(); - } finally { - process.exit(child?.pid !== undefined ? 0 : 1); - } - }); - program.command('editor-logs') .description('Prints the path to the Unity Editor log files.') .action(async () => { @@ -839,309 +416,6 @@ program.command('editor-logs') process.exit(0); }); -program.commandsGroup("Unity Package Manager:"); - -program.command('upm-install') - .description('Download and install the Unity Package Manager cli (pack/sign).') - .option('--verbose', 'Enable verbose logging.') - .option('--auto-update', 'Automatically updates the upm cli if it is already installed and a newer release is available. Cannot be used with --version.') - .option('--version ', 'Specify to install a specific version of the Unity Package Manager cli. Cannot be used with --auto-update.') - .option('--json', 'Print version and managed paths as JSON.') - .action(async (options) => { - if (options.verbose) { - Logger.instance.logLevel = LogLevel.DEBUG; - } - - Logger.instance.debugOptions(options); - - try { - if (options.autoUpdate === true && options.version) { - Logger.instance.error('Cannot use --auto-update with --version.'); - process.exit(1); - } - - const upm = new UpmCli(); - let requestedVersion = options.version?.toString()?.trim(); - if (options.autoUpdate === true) { - const currentVersion = upm.GetInstalledReleaseTag(); - const latestVersion = await upm.GetLatestReleaseTag(); - - if (currentVersion && !upm.IsUpdateAvailable(latestVersion)) { - Logger.instance.info(`Upm cli is already up to date (${currentVersion}).`); - requestedVersion = currentVersion; - } else { - if (currentVersion) { - Logger.instance.info(`Updating upm cli ${currentVersion} -> ${latestVersion}...`); - } - requestedVersion = latestVersion; - } - } - - const ver = await upm.Install({ - version: requestedVersion, - skipIfInstalled: true - }); - - await upm.Version(ver); - const exe = upm.executable; - - if (options.json) { - process.stdout.write(`\n${JSON.stringify({ - UPM_VERSION: ver, - UPM_CLI_PATH: exe, - UPM_MANAGED_ROOT: upm.managedRoot, - })}\n`); - } else { - process.stdout.write(`Upm cli ${ver} installed.\n`); - process.stdout.write(`${exe}\n`); - } - - process.exit(0); - } catch (error) { - Logger.instance.error(`${error}`); - process.exit(1); - } - }); - -program.command('upm-version') - .description('Print the Unity Package Manager cli version.') - .action(async () => { - try { - const upmCli = new UpmCli(); - const version = await upmCli.Version(); - process.stdout.write(`v${version.version}\n`); - process.exit(0); - } catch (error) { - Logger.instance.error(`${error}`); - process.exit(1); - } - }); - -interface UpmPackCliOptions { - source?: string; - destination?: string; - verbose?: boolean; -} - -program.command('upm-pack') - .description('Sign and pack a Unity package.') - .option('--source ', 'An absolute or relative path to the root folder of the custom package to pack. This is the folder that contains the package manifest file (package.json). (optional; defaults to the current working directory).') - .option('--destination ', 'The output path for the signed tarball. If you specify a folder that doesn\'t exist, it will be created for you. Note: If you omit this parameter, the tarball will be placed in the current working directory.') - .option('--verbose', 'Enable verbose logging.') - .action(async (options: UpmPackCliOptions) => { - if (options.verbose) { - Logger.instance.logLevel = LogLevel.DEBUG; - } - - Logger.instance.debugOptions({ options }); - - try { - const upm = new UpmCli(); - await upm.PromptInstallOrUpdateWhenInteractive(); - - let serviceAccountKeyId = process.env.UPM_SERVICE_ACCOUNT_KEY_ID?.trim(); - - if (!serviceAccountKeyId) { - serviceAccountKeyId = (await PromptForSecretInput('UPM_SERVICE_ACCOUNT_KEY_ID: ')).trim(); - } - - if (!serviceAccountKeyId) { - Logger.instance.error( - 'UPM_SERVICE_ACCOUNT_KEY_ID is required. Set the environment variable or enter a value when prompted.' - ); - process.exit(1); - } - - let serviceAccountKeySecret = process.env.UPM_SERVICE_ACCOUNT_KEY_SECRET?.trim(); - - if (!serviceAccountKeySecret) { - serviceAccountKeySecret = (await PromptForSecretInput('UPM_SERVICE_ACCOUNT_KEY_SECRET: ')).trim(); - } - - if (!serviceAccountKeySecret) { - Logger.instance.error( - 'UPM_SERVICE_ACCOUNT_KEY_SECRET is required. Set the environment variable or enter a value when prompted.' - ); - process.exit(1); - } - - process.env.UPM_SERVICE_ACCOUNT_KEY_ID = serviceAccountKeyId; - Logger.instance.maskCredential(serviceAccountKeyId); - process.env.UPM_SERVICE_ACCOUNT_KEY_SECRET = serviceAccountKeySecret; - Logger.instance.maskCredential(serviceAccountKeySecret); - - let orgId = process.env.UNITY_ORGANIZATION_ID?.trim() || process.env.UNITY_ORG_ID?.trim(); - const dest = options.destination?.toString()?.trim(); - - if (!orgId) { - orgId = (await PromptForSecretInput('UNITY_ORGANIZATION_ID: ')).trim(); - } - - if (!orgId) { - Logger.instance.error( - 'Organization ID is required. Set UNITY_ORGANIZATION_ID or UNITY_ORG_ID, or enter a value when prompted.' - ); - process.exit(1); - } - - Logger.instance.maskCredential(orgId); - - const redactLiterals = [orgId, serviceAccountKeyId, serviceAccountKeySecret].filter( - (s): s is string => typeof s === 'string' && s.trim().length > 0 - ); - - const packOptions: UpmPackOptions = { - organizationId: orgId, - }; - - if (dest) { - packOptions.destination = dest; - } - - const sourceArg = options.source?.toString()?.trim(); - packOptions.packageDirectory = - sourceArg && sourceArg.length > 0 ? path.resolve(sourceArg) : process.cwd(); - - await upm.Pack(packOptions, { - silent: false, - showCommand: Logger.instance.logLevel === LogLevel.DEBUG, - redactLiterals, - }); - - process.exit(0); - } catch (error) { - Logger.instance.error(`${error}`); - process.exit(1); - } - }); - -program.command('sign-package') - .description('[Deprecated] Sign a Unity package using Unity Editor 6000.3+ batch mode (-upmPack). Use unity-cli upm-pack with organization and service account credentials for new workflows.') - .option('--package ', 'Required. The fully qualified path to the folder that contains the package.json file for the package you want to sign. Note: Don\'t include package.json in this parameter value.') - .option('--output ', 'Optional. The output directory where you want to save the signed tarball file (.tgz). If unspecified, the package contents will be updated in place with the signed .attestation.p7m file.') - .option('--email ', 'Email associated with the Unity account. If unspecified, the UNITY_USERNAME environment variable will be used.') - .option('--password ', 'The password of the Unity account. If unspecified, the UNITY_PASSWORD environment variable will be used.') - .option('--organization ', 'The Organization ID you copied from the Unity Cloud Dashboard. If unspecified, the UNITY_ORGANIZATION_ID environment variable will be used.') - .option('--verbose', 'Enable verbose logging.') - .action(async (options) => { - Logger.instance.warn('The sign-package command is deprecated. Use unity-cli upm-pack --source --destination with UNITY_ORGANIZATION_ID or UNITY_ORG_ID, UPM_SERVICE_ACCOUNT_KEY_ID, and UPM_SERVICE_ACCOUNT_KEY_SECRET (or secure prompts).'); - - if (options.verbose) { - Logger.instance.logLevel = LogLevel.DEBUG; - } - - Logger.instance.debugOptions(options); - - const packagePath = path.normalize(options.package?.toString()?.trim()); - - if (!packagePath || packagePath.length === 0) { - Logger.instance.error('The package path is required. Use --package to specify it.'); - process.exit(1); - } - - const packageJsonPath = path.join(packagePath, 'package.json'); - try { - await fs.promises.access(packageJsonPath, fs.constants.R_OK); - } catch { - Logger.instance.error(`Failed to find a valid package.json file at: ${packageJsonPath}`); - process.exit(1); - } - - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); - - let outputPath = options.output?.toString()?.trim(); - - if (outputPath && outputPath.length > 0) { - outputPath = path.resolve(outputPath); - - if (outputPath.endsWith('.tgz')) { - // remove .tgz if present - outputPath = outputPath.substring(0, outputPath.length - 4); - } - } else { - outputPath = path.join(path.resolve(packagePath, '..')); - } - - let username = options.email?.toString()?.trim() || process.env.UNITY_USERNAME || undefined; - - if (!username || username.length === 0) { - username = await PromptForSecretInput('Email: '); - } - - if (!username || username.length === 0) { - Logger.instance.error('The email is required. Use --email to specify it.'); - process.exit(1); - } - - let password = options.password?.toString()?.trim() || process.env.UNITY_PASSWORD || undefined; - - if (!password || password.length === 0) { - password = await PromptForSecretInput('Password: '); - } - - if (!password || password.length === 0) { - Logger.instance.error('The password is required. Use --password to specify it.'); - process.exit(1); - } - - let organization = options.organization?.toString()?.trim() || process.env.UNITY_ORGANIZATION_ID || undefined; - - if (!organization || organization.length === 0) { - organization = await PromptForSecretInput('Organization ID: '); - } - - if (!organization || organization.length === 0) { - Logger.instance.error('The organization ID is required. Use --organization to specify it.'); - process.exit(1); - } - - // Mask credentials in CI environments before any potential logging - Logger.instance.maskCredential(username); - Logger.instance.maskCredential(password); - Logger.instance.maskCredential(organization); - - // must use a unity editor 6000.3 or newer - const unityVersion = new UnityVersion('6000.3'); - const unityHub = new UnityHub(); - const unityEditor = await unityHub.GetEditor(unityVersion, undefined, ['f', 'b']); - try { - await unityEditor.Run({ - args: [ - '-batchmode', - '-username', username, - '-password', password, - '-upmPack', packagePath, path.normalize(outputPath), - '-cloudOrganization', organization - ] - }); - } catch (error) { - // currently the editor returns exit code 1 even on success - } finally { - if (fs.existsSync(outputPath)) { - const pkg = await ResolveGlobToPath([outputPath, `${path.basename(packageJson.name)}*.tgz`]); - Logger.instance.info(`Package signed successfully: ${pkg}`); - - // if the output directory was not specified in the command options, - // then unpack the .tgz file and overwrite the package contents. - if (!options.output || options.output.length === 0) { - await tar.x({ - file: pkg, - cwd: packagePath, - strip: 1 - }); - - Logger.instance.info(`Package contents extracted to: ${packagePath}`); - fs.unlinkSync(pkg); - } - - process.exit(0); - } else { - Logger.instance.error('Failed to sign the package.'); - process.exit(1); - } - } - }); - void program.parseAsync(process.argv).catch((err: unknown) => { Logger.instance.error(err instanceof Error ? err.message : String(err)); process.exit(1); diff --git a/src/github-actions-ci.ts b/src/github-actions-ci.ts index a3aa05de..464e3846 100644 --- a/src/github-actions-ci.ts +++ b/src/github-actions-ci.ts @@ -86,6 +86,13 @@ export class GitHubActionsLoggerProvider implements ILoggerProvider { } } + public addPath(directory: string): void { + const githubPath = process.env.GITHUB_PATH; + if (githubPath) { + fs.appendFileSync(githubPath, `${directory}\n`, { encoding: 'utf8' }); + } + } + public appendStepSummary(summary: string): void { const githubSummary = process.env.GITHUB_STEP_SUMMARY; if (!githubSummary) { return; } diff --git a/src/index.ts b/src/index.ts index e87c89df..83e26fb7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,10 +2,10 @@ export * from './android-sdk'; export * from './license-client'; export * from './logging'; +export * from './unity-cli'; export * from './unity-editor'; -export * from './unity-hub'; +export * from './unity-installer'; export * from './unity-project'; export * from './unity-version'; export * from './utilities'; export * from './unity-logging'; -export * from './upm-cli'; diff --git a/src/license-client.ts b/src/license-client.ts index 8d898ce9..41aefc48 100644 --- a/src/license-client.ts +++ b/src/license-client.ts @@ -3,9 +3,10 @@ import * as fs from 'fs'; import * as path from 'path'; import { spawn } from 'child_process'; import { Logger } from './logging'; -import { UnityHub } from './unity-hub'; +import { UnityCli } from './unity-cli'; import { ResolveGlobToPath, + ResolvePathCandidates, tryParseJson } from './utilities'; @@ -28,8 +29,12 @@ export interface ActivateOptions { password?: string; } +/** + * The v3 licensing client, which drives Unity.Licensing.Client directly. + * v4 uses it as the fallback when the official Unity CLI cannot activate or return a license, + * for example a personal license on a headless runner, and for Unity 4.x and 5.x editors. + */ export class LicensingClient { - private readonly unityHub: UnityHub = new UnityHub(); private readonly logger: Logger = Logger.instance; private licenseClientPath: string | undefined; @@ -43,21 +48,65 @@ export class LicensingClient { this.licenseVersion = licenseVersion; } + /** + * Finds Unity.Licensing.Client: UNITY_LICENSING_CLIENT_PATH, then the copy the official Unity CLI manages, + * then the Hub's external modules folder, then the client bundled with the editor in UNITY_EDITOR_PATH. + * @returns The licensing client executable. + */ async init() { - try { - await fs.promises.access(this.unityHub.executable, fs.constants.R_OK); - await fs.promises.access(this.unityHub.rootDirectory, fs.constants.R_OK); - } catch (error) { - throw new Error('Unity Hub is not installed or not accessible. Please install Unity Hub before using the Licensing Client.'); + const licensingClientExecutable = process.platform === 'win32' ? 'Unity.Licensing.Client.exe' : 'Unity.Licensing.Client'; + let licenseClientPath: string | undefined = process.env.UNITY_LICENSING_CLIENT_PATH; + + if (!licenseClientPath) { + try { + const cli = await UnityCli.Get(); + licenseClientPath = await cli.GetLicensingClientPath(); + } catch (error) { + this.logger.debug(`The Unity CLI did not report a licensing client: ${error}`); + } + } + + if (!licenseClientPath) { + const candidates: string[][] = LicensingClient.externalModulesDirectories() + .map(directory => [directory, '**', licensingClientExecutable]); + const editorPath = process.env.UNITY_EDITOR_PATH; + + if (editorPath && editorPath.length > 0) { + candidates.push([path.dirname(editorPath), '**', 'Licensing', 'Client', licensingClientExecutable]); + } + + licenseClientPath = await ResolvePathCandidates(candidates); + } + + if (!licenseClientPath) { + throw new Error('Failed to find Unity.Licensing.Client. Install the Unity CLI with "unity-cli setup-cli", or set UNITY_LICENSING_CLIENT_PATH.'); } - const licensingClientExecutable = process.platform === 'win32' ? 'Unity.Licensing.Client.exe' : 'Unity.Licensing.Client'; - const licenseClientPath = await ResolveGlobToPath([this.unityHub.rootDirectory, '**', licensingClientExecutable]); this.licenseClientPath = licenseClientPath; await fs.promises.access(this.licenseClientPath, fs.constants.X_OK); + this.logger.debug(`Using Unity Licensing Client at ${this.licenseClientPath}`); return this.licenseClientPath; } + /** + * The folders the Unity Hub and the official Unity CLI download Unity.Licensing.Client to. + */ + private static externalModulesDirectories(): string[] { + switch (process.platform) { + case 'win32': + return [path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'UnityHub', 'external-modules')]; + case 'darwin': + return [path.join(os.homedir(), 'Library', 'Application Support', 'UnityHub', 'external-modules')]; + default: { + const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'); + return [ + path.join(configHome, 'UnityHub', 'external-modules'), + path.join(configHome, 'unityhub', 'external-modules'), + ]; + } + } + } + private getUnityCommonDir() { const result = process.env['UNITY_COMMON_DIR']; @@ -156,7 +205,12 @@ export class LicensingClient { throw new Error('Services config value is not a valid JSON string or base64 encoded JSON string.'); } - private async setupServicesConfig(configSource: string): Promise { + /** + * Writes the floating license server's services-config.json where Unity's licensing reads it. + * @param configSource A path to the configuration file, raw JSON, or base64 encoded JSON. + * @returns The path the configuration was written to. + */ + public async setupServicesConfig(configSource: string): Promise { const servicesConfigPath = this.servicesConfigPath(); if (fs.existsSync(configSource)) { @@ -352,7 +406,7 @@ export class LicensingClient { } if (!found) { - throw new Error('Could not find Unity.Licensing.EntitlementResolver.dll in the unityhub installation'); + throw new Error(`Could not find Unity.Licensing.EntitlementResolver.dll in ${clientDirectory}`); } } @@ -726,4 +780,191 @@ export class LicensingClient { this.logger.info(`Successfully returned license of type '${licenseType}'`); } -} \ No newline at end of file +} +/** + * Gets the license version a 4.x or 5.x editor in UNITY_EDITOR_PATH reads, or undefined for newer editors. + * @param editorPath The editor executable. Defaults to UNITY_EDITOR_PATH. + */ +export function GetLegacyLicenseVersion(editorPath: string | undefined = process.env.UNITY_EDITOR_PATH): '4.x' | '5.x' | undefined { + const versionMatch = editorPath?.match(/(\d+)\.(\d+)\.(\d+)/); + + switch (versionMatch?.[1]) { + case '4': + return '4.x'; + case '5': + return '5.x'; + default: + return undefined; + } +} + +/** How long one official CLI license command may run before it falls back. */ +const UNITY_CLI_LICENSE_TIMEOUT_MS = 3 * 60 * 1000; + +/** + * Activates a Unity license through the official Unity CLI first. + * Falls back to the Unity Licensing Client with the Unity ID email and password when the CLI fails, + * for example a personal license on a headless runner with no browser sign-in. + * 4.x and 5.x editors read their own license file, so they always use the Unity Licensing Client. + * @param options The activation options. + * @returns The floating license token, when one is known. + */ +export async function ActivateLicense(options: ActivateOptions): Promise { + const logger = Logger.instance; + const legacyLicenseVersion = GetLegacyLicenseVersion(); + + if (legacyLicenseVersion) { + logger.info(`Unity ${legacyLicenseVersion} editors read their own license file, activating with the Unity Licensing Client...`); + return await new LicensingClient(legacyLicenseVersion).Activate(options); + } + + let cliError: unknown; + + try { + const result = await activateWithUnityCli(options); + + if (result.activated) { + return result.token; + } + + cliError = result.reason; + } catch (error) { + cliError = error; + } + + logger.warn(`Unity CLI license activation failed, falling back to the Unity Licensing Client.\n${cliError}`); + + if (options.licenseType !== LicenseType.floating && + !options.username && !process.env.UNITY_USERNAME_BASE64) { + throw new Error(`Unity CLI license activation failed, and no Unity ID email and password were given for the fallback.\n${cliError}`); + } + + return await new LicensingClient().Activate(options); +} + +/** + * Returns a Unity license through the official Unity CLI first, then the Unity Licensing Client. + * @param licenseType The type of license to return. + * @param token The floating license token from activation. A floating license with a token is returned by the Unity Licensing Client. + */ +export async function ReturnLicense(licenseType: LicenseType, token: string | undefined = undefined): Promise { + const logger = Logger.instance; + const legacyLicenseVersion = GetLegacyLicenseVersion(); + + if (legacyLicenseVersion || (licenseType === LicenseType.floating && token)) { + await new LicensingClient(legacyLicenseVersion).Deactivate(licenseType, token); + return; + } + + try { + const cli = await UnityCli.Get(); + const { exitCode, envelope } = await cli.RunJson(['license', 'return', '--yes'], { timeoutMs: UNITY_CLI_LICENSE_TIMEOUT_MS }); + + if (exitCode === 0 && envelope?.success !== false && !(await isLicenseActive(cli))) { + logger.info(`Successfully returned license of type '${licenseType}'`); + return; + } + + logger.warn(`unity license return exited with code ${exitCode}${formatEnvelopeErrors(envelope)}, falling back to the Unity Licensing Client...`); + } catch (error) { + logger.warn(`Unity CLI license return failed, falling back to the Unity Licensing Client.\n${error}`); + } + + if (licenseType === LicenseType.floating) { + throw new Error('The Unity CLI could not return the floating license. Pass the token from activation to return it with the Unity Licensing Client.'); + } + + await new LicensingClient().Deactivate(licenseType); +} + +/** + * Prints the license state from the official Unity CLI, then the Unity Licensing Client context. + */ +export async function PrintLicenseContext(): Promise { + try { + const cli = await UnityCli.Get(); + await cli.Run(['license', 'status'], { stream: true }); + } catch (error) { + Logger.instance.warn(`Failed to read the Unity CLI license status: ${error}`); + } + + await new LicensingClient(GetLegacyLicenseVersion()).Context(); +} + +async function activateWithUnityCli(options: ActivateOptions): Promise<{ activated: boolean; token?: string | undefined; reason?: string | undefined }> { + const args = ['license', 'activate']; + const redactLiterals: string[] = []; + + switch (options.licenseType) { + case LicenseType.personal: + args.push('--personal', '--accept-eula'); + break; + case LicenseType.professional: + if (!options.serial) { + return { activated: false, reason: 'A serial is required to activate a professional license with the Unity CLI.' }; + } + + args.push('--serial', options.serial.trim()); + redactLiterals.push(options.serial.trim()); + break; + case LicenseType.floating: + if (!options.servicesConfig) { + throw new Error('Services config path is required for floating license activation'); + } + + await new LicensingClient().setupServicesConfig(options.servicesConfig); + args.push('--floating'); + break; + } + + const cli = await UnityCli.Get(); + Logger.instance.info(`Activating a ${options.licenseType} license with the Unity CLI...`); + const { exitCode, envelope } = await cli.RunJson(args, { timeoutMs: UNITY_CLI_LICENSE_TIMEOUT_MS, redactLiterals }); + + if (exitCode !== 0 || envelope?.success === false) { + return { activated: false, reason: `unity license activate exited with code ${exitCode}${formatEnvelopeErrors(envelope)}` }; + } + + if (!(await isLicenseActive(cli))) { + return { activated: false, reason: 'unity license activate succeeded, but unity license status reports no active license' }; + } + + const token = findToken(envelope?.data); + + if (token) { + Logger.instance.CI_mask(token); + } + + Logger.instance.info(`Successfully activated license of type '${options.licenseType}' with the Unity CLI`); + return { activated: true, token }; +} + +async function isLicenseActive(cli: UnityCli): Promise { + const { envelope } = await cli.RunJson<{ active?: boolean }>(['license', 'status'], { timeoutMs: UNITY_CLI_LICENSE_TIMEOUT_MS }); + return envelope?.data?.active === true; +} + +function findToken(value: unknown): string | undefined { + if (!value || typeof value !== 'object') { + return undefined; + } + + for (const [key, item] of Object.entries(value as { [key: string]: unknown })) { + if (typeof item === 'string' && /token/i.test(key) && item.length > 0) { + return item; + } + + const nested = findToken(item); + + if (nested) { + return nested; + } + } + + return undefined; +} + +function formatEnvelopeErrors(envelope: { errors?: { code: string; message: string }[] } | undefined): string { + const errors = envelope?.errors ?? []; + return errors.length > 0 ? `\n${errors.map(e => ` > ${e.code}: ${e.message}`).join('\n')}` : ''; +} diff --git a/src/logger-provider.ts b/src/logger-provider.ts index 70f42e3f..a9aa0da5 100644 --- a/src/logger-provider.ts +++ b/src/logger-provider.ts @@ -20,6 +20,7 @@ export interface ILoggerProvider { mask(message: string): void; setEnvironmentVariable(name: string, value: string): void; setOutput(name: string, value: string): void; + addPath(directory: string): void; appendStepSummary(summary: string): void; getMarkdownByteLimit(target: MarkdownTarget): number; } @@ -68,6 +69,10 @@ export class LocalCliLoggerProvider implements ILoggerProvider { // no-op for local terminal } + public addPath(_directory: string): void { + // no-op for local terminal + } + public appendStepSummary(_summary: string): void { // no-op for local terminal } diff --git a/src/logging.ts b/src/logging.ts index 5d127950..5129627a 100644 --- a/src/logging.ts +++ b/src/logging.ts @@ -1,3 +1,4 @@ +import * as path from 'path'; import { UTP, Severity } from './utp'; import { UTP_BENIGN_SEVERITY_REMAPS } from './utp-benign'; import { GitHubActionsLoggerProvider, GitHubAnnotationLevel } from './github-actions-ci'; @@ -748,6 +749,20 @@ export class Logger { this._provider.setOutput(name, value); } + /** + * Prepends a directory to the PATH for this process and, in CI environments that support it, later steps. + * @param directory The directory to add. + */ + public CI_addPath(directory: string): void { + const entries = (process.env.PATH ?? '').split(path.delimiter); + + if (!entries.includes(directory)) { + process.env.PATH = [directory, ...entries].filter(Boolean).join(path.delimiter); + } + + this._provider.addPath(directory); + } + private static formatDurationMs(ms: number | undefined): string { if (ms === undefined || !Number.isFinite(ms)) { return '-'; } if (ms < 1000) { return `${Math.round(ms)}ms`; } diff --git a/src/unity-cli.ts b/src/unity-cli.ts new file mode 100644 index 00000000..df9de532 --- /dev/null +++ b/src/unity-cli.ts @@ -0,0 +1,595 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { spawn } from 'child_process'; +import { Logger } from './logging'; +import { + DownloadFile, + GetTempDir, + HttpsGetText, + Sha256FileHex, + redactSensitiveLiterals, +} from './utilities'; + +/** The official Unity CLI version unity-cli is tested against. Callers own the version they install. */ +export const DEFAULT_UNITY_CLI_VERSION = '1.0.0-beta.14'; + +const CDN_BASE = 'https://public-cdn.cloud.unity3d.com/hub/prod/cli/'; +const TOOL_NAME = 'unity-cli'; +const EXE_NAME = process.platform === 'win32' ? 'unity.exe' : 'unity'; +const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; + +export interface UnityCliResult { + exitCode: number; + stdout: string; + stderr: string; +} + +/** The envelope every `--format json` result of the official CLI uses. */ +export interface UnityCliEnvelope { + success: boolean; + command?: string; + data?: T; + errors?: { code: string; message: string }[]; + warnings?: unknown[]; +} + +export interface UnityCliRunOptions { + /** Stream output to the console. Defaults to false. */ + stream?: boolean; + /** Kill the CLI after this many milliseconds. */ + timeoutMs?: number; + /** Values replaced with `*****` in logged output and the logged command. */ + redactLiterals?: readonly string[]; +} + +interface UnityCliManifest { + version: string; + binaries: { [platform: string]: { filename: string; sha256: string } }; +} + +/** + * A thin wrapper over Unity's official CLI (`unity`). + * @see https://docs.unity.com/en-us/cli + */ +export class UnityCli { + private readonly logger: Logger = Logger.instance; + + constructor( + public readonly executable: string, + public readonly version: string + ) { } + + /** + * Returns the official Unity CLI, installing it when it is missing or is not the requested version. + * @param version An exact version such as 1.0.0-beta.14, or `latest`. Defaults to an already installed CLI, then {@link DEFAULT_UNITY_CLI_VERSION}. + */ + public static async Get(version: string | undefined = undefined): Promise { + configureEnvironment(); + const requested = version ? normalizeVersion(version) : undefined; + const existing = await UnityCli.Find(); + + if (existing && (!requested || requested === 'latest' || existing.version === requested)) { + Logger.instance.debug(`Using Unity CLI ${existing.version} at ${existing.executable}`); + return exportCli(existing); + } + + if (existing) { + Logger.instance.info(`Found Unity CLI ${existing.version} at ${existing.executable}, but ${requested} was requested`); + } + + return await UnityCli.Install(requested ?? DEFAULT_UNITY_CLI_VERSION); + } + + /** + * Finds an installed official Unity CLI from UNITY_CLI_PATH, the PATH, the tool cache, or the install script's default location. + */ + public static async Find(): Promise { + const candidates: string[] = []; + + if (process.env.UNITY_CLI_PATH) { + candidates.push(process.env.UNITY_CLI_PATH); + } + + for (const dir of (process.env.PATH ?? '').split(path.delimiter)) { + if (dir && dir.length > 0) { + candidates.push(path.join(dir, EXE_NAME)); + } + } + + candidates.push(...findCachedExecutables()); + candidates.push(...defaultInstallLocations()); + + for (const candidate of candidates) { + if (!isExecutableFile(candidate)) { + continue; + } + + try { + const version = await getCliVersion(candidate); + return new UnityCli(candidate, version); + } catch (error) { + Logger.instance.debug(`Skipping ${candidate}: ${error}`); + } + } + + return undefined; + } + + /** + * Downloads an exact official Unity CLI release from Unity's CDN, verifies its SHA-256 and caches it. + * @param version An exact version such as 1.0.0-beta.14, or `latest`. + */ + public static async Install(version: string = DEFAULT_UNITY_CLI_VERSION): Promise { + configureEnvironment(); + const requested = normalizeVersion(version); + const manifest = await fetchManifest(requested); + const platformKey = `${process.platform}-${process.arch}`; + const binary = manifest.binaries[platformKey]; + + if (!binary) { + throw new Error(`Unity CLI ${manifest.version} has no binary for ${platformKey}. Available: ${Object.keys(manifest.binaries).join(', ')}`); + } + + const cached = getToolCachePath(manifest.version); + const cachedExecutable = path.join(cached, EXE_NAME); + + if (isExecutableFile(cachedExecutable) && fs.existsSync(`${cached}.complete`)) { + const cachedVersion = await getCliVersion(cachedExecutable); + + if (cachedVersion === manifest.version) { + Logger.instance.info(`Using cached Unity CLI ${cachedVersion}`); + return exportCli(new UnityCli(cachedExecutable, cachedVersion)); + } + } + + Logger.instance.ci(`Installing Unity CLI ${manifest.version}...`); + const downloadPath = path.join(GetTempDir(), `${TOOL_NAME}-${Date.now()}`, binary.filename); + await DownloadFile(`${CDN_BASE}${manifest.version}/${binary.filename}`, downloadPath); + + try { + const actualHash = await Sha256FileHex(downloadPath); + + if (actualHash.toLowerCase() !== binary.sha256.toLowerCase()) { + throw new Error(`SHA-256 mismatch for ${binary.filename}: expected ${binary.sha256}, got ${actualHash}`); + } + + await fs.promises.rm(cached, { recursive: true, force: true }); + await fs.promises.rm(`${cached}.complete`, { force: true }); + await fs.promises.mkdir(cached, { recursive: true }); + await fs.promises.copyFile(downloadPath, cachedExecutable); + + if (process.platform !== 'win32') { + await fs.promises.chmod(cachedExecutable, 0o755); + } + + await fs.promises.writeFile(`${cached}.complete`, ''); + } finally { + await fs.promises.rm(path.dirname(downloadPath), { recursive: true, force: true }); + } + + const installedVersion = await getCliVersion(cachedExecutable); + + if (requested !== 'latest' && installedVersion !== requested) { + throw new Error(`Failed to install Unity CLI ${requested}, found ${installedVersion}`); + } + + Logger.instance.info(`Installed Unity CLI ${installedVersion} to ${cachedExecutable}`); + return exportCli(new UnityCli(cachedExecutable, installedVersion)); + } + + /** + * Runs the CLI and returns its exit code and output without throwing on a non-zero exit code. + * @param args The arguments to pass to `unity`. + * @param options Run options. + */ + public async Run(args: string[], options: UnityCliRunOptions = {}): Promise { + const redact = (text: string) => redactSensitiveLiterals(text, options.redactLiterals); + const commandStr = redact(`\x1b[34m${this.executable} ${args.join(' ')}\x1b[0m`); + + if (options.stream) { + this.logger.startGroup(commandStr); + } else { + this.logger.debug(commandStr); + } + + try { + return await new Promise((resolve, reject) => { + let stdout = ''; + let stderr = ''; + let timedOut = false; + const child = spawn(this.executable, args, { + shell: false, + stdio: ['ignore', 'pipe', 'pipe'], + env: process.env, + }); + const timer = options.timeoutMs + ? setTimeout(() => { + timedOut = true; + child.kill('SIGTERM'); + }, options.timeoutMs) + : undefined; + const onSignal = () => child.kill('SIGTERM'); + process.once('SIGINT', onSignal); + process.once('SIGTERM', onSignal); + const writeLines = (chunk: string) => { + if (!options.stream) { return; } + for (const line of chunk.split(/\r?\n/)) { + // the CLI prints a plugin hint to stderr on every run + if (line.length > 0 && !line.includes(' { + const chunk = data.toString(); + stdout += chunk; + writeLines(chunk); + }); + child.stderr.on('data', (data: Buffer) => { + const chunk = data.toString(); + stderr += chunk; + writeLines(chunk); + }); + const cleanup = () => { + if (timer) { clearTimeout(timer); } + process.removeListener('SIGINT', onSignal); + process.removeListener('SIGTERM', onSignal); + }; + child.on('error', (error) => { + cleanup(); + reject(error); + }); + child.on('close', (code) => { + cleanup(); + const exitCode = timedOut ? 14 : (code ?? 1); + this.logger.debug(`unity exited with code ${exitCode}`); + resolve({ exitCode, stdout: redact(stdout), stderr: redact(stderr) }); + }); + }); + } finally { + if (options.stream) { + this.logger.endGroup(); + } + } + } + + /** + * Runs the CLI and throws when it exits with a non-zero exit code. + * @param args The arguments to pass to `unity`. + * @param options Run options. Output streams to the console unless `stream` is false. + */ + public async Exec(args: string[], options: UnityCliRunOptions = {}): Promise { + const result = await this.Run(args, { stream: true, ...options }); + + if (result.exitCode !== 0) { + const tail = options.stream === false ? `\n${result.stdout}${stripHint(result.stderr)}` : ''; + throw new Error(`unity ${redactSensitiveLiterals(args.join(' '), options.redactLiterals)} failed with exit code ${result.exitCode}${tail}`); + } + + return result.stdout; + } + + /** + * Runs the CLI with `--format json` and parses the result envelope from stdout. + * @param args The arguments to pass to `unity`. + * @param options Run options. + * @returns The exit code and the parsed envelope, if stdout held one. + */ + public async RunJson(args: string[], options: UnityCliRunOptions = {}): Promise<{ exitCode: number; envelope: UnityCliEnvelope | undefined; stdout: string; stderr: string }> { + const result = await this.Run([...args, '--format', 'json'], { ...options, stream: false }); + const envelope = parseEnvelope(result.stdout); + this.logger.debug(`unity ${args.join(' ')} [${result.exitCode}]:\n${result.stdout}${stripHint(result.stderr)}`); + return { exitCode: result.exitCode, envelope, stdout: result.stdout, stderr: result.stderr }; + } + + /** + * Runs the CLI with `--format json` and returns `data`, throwing when the command fails. + * @param args The arguments to pass to `unity`. + * @param options Run options. + */ + public async ExecJson(args: string[], options: UnityCliRunOptions = {}): Promise { + const { exitCode, envelope, stdout, stderr } = await this.RunJson(args, options); + + if (exitCode !== 0 || !envelope || envelope.success === false) { + const errors = envelope?.errors?.map(e => `${e.code}: ${e.message}`).join('\n') || `${stdout}${stripHint(stderr)}`.trim(); + throw new Error(`unity ${redactSensitiveLiterals(args.join(' '), options.redactLiterals)} failed with exit code ${exitCode}\n${errors}`); + } + + return envelope.data as T; + } + + /** Gets the directory the CLI installs editors to. */ + public async GetInstallPath(): Promise { + const data = await this.ExecJson(['install-path', '--get']); + const installPath = FindAbsolutePath(data); + + if (!installPath) { + throw new Error(`Unable to read the editor install path from: ${JSON.stringify(data)}`); + } + + return installPath; + } + + /** + * Sets the directory the CLI installs editors to. + * @param installPath The directory to install editors to. + */ + public async SetInstallPath(installPath: string): Promise { + await fs.promises.mkdir(installPath, { recursive: true }); + await this.Exec(['install-path', '--set', installPath]); + } + + /** + * Gets the path to the Unity.Licensing.Client the CLI manages, downloading it first if needed. + * @returns The licensing client executable, or undefined when the CLI does not report one. + */ + public async GetLicensingClientPath(): Promise { + // the first license command makes the CLI download the licensing client + await this.RunJson(['license', 'list']); + const { envelope } = await this.RunJson<{ externalComponents?: unknown }>(['doctor']); + const components = envelope?.data?.externalComponents; + const executableName = process.platform === 'win32' ? 'Unity.Licensing.Client.exe' : 'Unity.Licensing.Client'; + const candidates: string[] = []; + collectStrings(components, candidates); + + for (const candidate of candidates) { + if (!path.isAbsolute(candidate) || !candidate.toLowerCase().includes('licensing')) { + continue; + } + + const resolved = resolveLicensingClient(candidate, executableName); + + if (resolved) { + return resolved; + } + } + + return undefined; + } +} + +/** + * Finds the first absolute path in a CLI result, preferring well-known keys. + * @param value A parsed CLI JSON value. + */ +export function FindAbsolutePath(value: unknown): string | undefined { + const pathKeys = ['path', 'editorPath', 'installPath', 'location', 'directory', 'value']; + + if (typeof value === 'string') { + return path.isAbsolute(value) ? value : undefined; + } + + if (Array.isArray(value)) { + for (const item of value) { + const found = FindAbsolutePath(item); + if (found) { return found; } + } + return undefined; + } + + if (value && typeof value === 'object') { + const record = value as { [key: string]: unknown }; + + for (const key of pathKeys) { + const found = FindAbsolutePath(record[key]); + if (found) { return found; } + } + + for (const [key, item] of Object.entries(record)) { + if (key === 'errors' || key === 'warnings') { continue; } + const found = FindAbsolutePath(item); + if (found) { return found; } + } + } + + return undefined; +} + +function parseEnvelope(stdout: string): UnityCliEnvelope | undefined { + const trimmed = stdout.trim(); + + if (trimmed.length === 0) { + return undefined; + } + + try { + return JSON.parse(trimmed) as UnityCliEnvelope; + } catch { + // fall back to the last line that parses, in case anything else was printed first + const lines = trimmed.split(/\r?\n/).reverse(); + + for (const line of lines) { + try { + const parsed = JSON.parse(line); + if (parsed && typeof parsed === 'object' && 'success' in parsed) { + return parsed as UnityCliEnvelope; + } + } catch { + // keep looking + } + } + } + + return undefined; +} + +function stripHint(text: string): string { + return text.split(/\r?\n/).filter(line => !line.includes(' collectStrings(item, results)); + } else if (value && typeof value === 'object') { + Object.values(value).forEach(item => collectStrings(item, results)); + } +} + +function resolveLicensingClient(candidate: string, executableName: string): string | undefined { + if (!fs.existsSync(candidate)) { + return undefined; + } + + if (fs.statSync(candidate).isFile()) { + return path.basename(candidate) === executableName ? candidate : undefined; + } + + const queue = [candidate]; + + // the client sits a few folders down, e.g. licensingClient//Unity.Licensing.Client + for (let depth = 0; depth < 4 && queue.length > 0; depth++) { + const next: string[] = []; + + for (const dir of queue) { + const direct = path.join(dir, executableName); + + if (isExecutableFile(direct)) { + return direct; + } + + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + next.push(path.join(dir, entry.name)); + } + } + } + + queue.splice(0, queue.length, ...next); + } + + return undefined; +} + +async function getCliVersion(cliPath: string): Promise { + const cli = new UnityCli(cliPath, ''); + const { exitCode, stdout, stderr } = await cli.Run(['--version'], { timeoutMs: 60_000 }); + + if (exitCode !== 0) { + throw new Error(`${cliPath} --version failed with exit code ${exitCode}: ${stripHint(stderr).trim()}`); + } + + const match = stdout.match(/v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/); + + if (!match || !match[1]) { + throw new Error(`Unable to parse Unity CLI version from: ${stdout.trim()}`); + } + + return match[1]; +} + +async function fetchManifest(version: string): Promise { + const urls = version === 'latest' + ? [`${CDN_BASE}latest.json`, `${CDN_BASE}latest-beta.json`] + : [`${CDN_BASE}${version}/latest.json`]; + let lastError: unknown; + + for (const url of urls) { + try { + const manifest = JSON.parse(await HttpsGetText(url)) as UnityCliManifest; + + if (version !== 'latest' && manifest.version !== version) { + throw new Error(`Manifest mismatch: requested ${version} but ${url} reports ${manifest.version}`); + } + + return manifest; + } catch (error) { + lastError = error; + Logger.instance.debug(`Failed to read ${url}: ${error}`); + } + } + + throw new Error(`Failed to find Unity CLI ${version}: ${lastError}`); +} + +function normalizeVersion(version: string): string { + const trimmed = version.trim(); + + if (trimmed.toLowerCase() === 'latest') { + return 'latest'; + } + + const normalized = trimmed.replace(/^v/i, ''); + + if (!VERSION_PATTERN.test(normalized)) { + throw new Error(`Invalid Unity CLI version: ${version}. Use an exact version such as ${DEFAULT_UNITY_CLI_VERSION}, or latest.`); + } + + return normalized; +} + +function getToolCacheRoot(): string { + return process.env.RUNNER_TOOL_CACHE || path.join(os.homedir(), '.unity-cli', 'tools'); +} + +/** Same layout as @actions/tool-cache, so actions can find the CLI with tc.find('unity-cli', version). */ +function getToolCachePath(version: string): string { + return path.join(getToolCacheRoot(), TOOL_NAME, version, os.arch()); +} + +function findCachedExecutables(): string[] { + const toolDir = path.join(getToolCacheRoot(), TOOL_NAME); + + if (!fs.existsSync(toolDir)) { + return []; + } + + return fs.readdirSync(toolDir) + .filter(version => fs.existsSync(path.join(toolDir, version, `${os.arch()}.complete`))) + .sort() + .reverse() + .map(version => path.join(toolDir, version, os.arch(), EXE_NAME)); +} + +function defaultInstallLocations(): string[] { + if (process.platform === 'win32') { + return [path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'Unity', 'bin', EXE_NAME)]; + } + + return [ + path.join(os.homedir(), '.local', 'bin', EXE_NAME), + path.join(os.homedir(), '.unity', 'bin', EXE_NAME), + ]; +} + +function isExecutableFile(filePath: string): boolean { + try { + if (!fs.statSync(filePath).isFile()) { + return false; + } + + fs.accessSync(filePath, fs.constants.X_OK); + return true; + } catch { + return false; + } +} + +/** Keeps the CLI from prompting or updating itself mid-job. */ +function configureEnvironment(): void { + const vars: { [key: string]: string } = { + UNITY_NON_INTERACTIVE: '1', + UNITY_NO_UPDATE_CHECK: '1', + UNITY_NO_CONSENT_PROMPT: '1', + UNITY_NO_BANNER: '1', + UNITY_NO_PAGER: '1', + }; + + for (const [name, value] of Object.entries(vars)) { + if (!process.env[name]) { + process.env[name] = value; + Logger.instance.CI_setEnvironmentVariable(name, value); + } + } +} + +function exportCli(cli: UnityCli): UnityCli { + process.env.UNITY_CLI_PATH = cli.executable; + process.env.UNITY_CLI_VERSION = cli.version; + Logger.instance.CI_setEnvironmentVariable('UNITY_CLI_PATH', cli.executable); + Logger.instance.CI_setEnvironmentVariable('UNITY_CLI_VERSION', cli.version); + Logger.instance.CI_addPath(path.dirname(cli.executable)); + return cli; +} diff --git a/src/unity-editor.ts b/src/unity-editor.ts index ba69081b..572207dc 100644 --- a/src/unity-editor.ts +++ b/src/unity-editor.ts @@ -5,7 +5,7 @@ import { Logger } from './logging'; import { UnityVersion } from './unity-version'; import { spawn, - ChildProcessByStdio + ChildProcess } from 'child_process'; import { GetArgumentValueAsString, @@ -13,8 +13,8 @@ import { KillProcess, KillChildProcesses, Exec, - DeleteDirectory, } from './utilities'; +import { UnityCli } from './unity-cli'; import { TailLogFile, LogTailResult, @@ -109,89 +109,6 @@ export class UnityEditor { } } - /** - * Get the full path to a Unity project template based on the provided template name or regex pattern. - * @param template The name or regex pattern of the template to find. - * @returns The full path to the matching template file. - * @throws If no templates are found, or no matching template is found. - */ - public GetTemplatePath(template: string): string | undefined { - const templates: string[] = this.GetAvailableTemplates(); - - if (templates.length === 0) { - this.logger.warn(`No Unity templates found for ${this.version.toString()}`); - return undefined; - } - - // Build a regex to match the template name, an optional numeric version suffix, and required file extension - // Example input: com.unity.template.3d(-cross-platform)?.* - // Example match: com.unity.template.3d-cross-platform-1.2.3.tar.gz or com.unity.template.3d-1.2.3.tgz - let regex: RegExp; - try { - regex = new RegExp(`^${template}(?:-\\d+\\.\\d+\\.\\d+)?(?:\\.tgz|\\.tar\\.gz)$`); - } catch (e) { - throw new Error(`Invalid template regex: ${template}`); - } - - // Filter files by regex - const matches = templates.filter(t => regex.test(path.basename(t))); - - if (matches.length === 0) { - this.logger.warn(`No matching template path found for ${template}`); - return undefined; - } - - // Pick the longest match (as in the shell script: sort by length descending) - matches.sort((a, b) => b.length - a.length); - const templatePath = matches[0]; - - if (!templatePath) { - this.logger.warn(`No matching template path found for ${template}`); - return undefined; - } - - return path.normalize(templatePath); - } - - /** - * Get a list of available Unity project templates. - * @returns An array of available template file names. - */ - public GetAvailableTemplates(): string[] { - if (this.version.isLessThan('2019.0.0')) { - this.logger.warn(`Unity version ${this.version.toString()} does not support project templates.`); - return []; - } - - let templateDir: string; - let editorRoot = path.dirname(this.editorPath); - - if (process.platform === 'darwin') { - templateDir = path.join(path.dirname(editorRoot), 'Resources', 'PackageManager', 'ProjectTemplates'); - } else { - templateDir = path.join(editorRoot, 'Data', 'Resources', 'PackageManager', 'ProjectTemplates'); - } - - this.logger.debug(`Looking for templates in: ${templateDir}`); - - if (!fs.existsSync(templateDir) || - !fs.statSync(templateDir).isDirectory()) { - return []; - } - - const templates: string[] = []; - const entries = fs.readdirSync(templateDir, { withFileTypes: true }); - - for (const entry of entries) { - if (entry.isFile() && (entry.name.endsWith('.tgz') || entry.name.endsWith('.tar.gz'))) { - templates.push(path.join(templateDir, entry.name)); - } - } - - this.logger.debug(`Found ${templates.length} templates:\n${templates.map(t => ` - ${t}`).join('\n')}`); - return templates; - } - /** * Scrubs sensitive command-line arguments for safe logging. * Replaces values for sensitive flags like -username, -password, etc. with [REDACTED]. @@ -228,7 +145,8 @@ export class UnityEditor { let exitCode: number | undefined = undefined; let procInfo: ProcInfo | null = null; let logTail: LogTailResult | null = null; - let unityProcess: ChildProcessByStdio; + let unityProcess: ChildProcess; + let unityRunOutput: string | undefined = undefined; async function tryKillEditorProcesses(): Promise { try { @@ -311,7 +229,23 @@ export class UnityEditor { ...(linuxEnvOverrides ?? {}) }; - if (process.platform === 'linux' && + const unityCli = await this.getUnityCliForRun(command); + + if (unityCli) { + const runArgs = UnityEditor.GetUnityRunArgs(this.editorPath, command.projectPath!, logPath, command.args); + this.logger.debug(`Launching through the Unity CLI: ${unityCli.executable} ${this.scrubSensitiveArgs(runArgs).join(' ')}`); + unityRunOutput = ''; + const child = spawn( + unityCli.executable, + runArgs, { + stdio: ['ignore', 'pipe', 'pipe'], + env: baseEditorEnv + }); + const collect = (data: Buffer) => { unityRunOutput += data.toString(); }; + child.stdout.on('data', collect); + child.stderr.on('data', collect); + unityProcess = child; + } else if (process.platform === 'linux' && !command.args.includes('-nographics') ) { unityProcess = spawn( @@ -377,6 +311,14 @@ export class UnityEditor { if (!isCancelled) { await tryKillEditorProcesses(); + if (exitCode !== undefined && exitCode !== 0 && unityRunOutput) { + const output = unityRunOutput.split(/\r?\n/).filter(line => line.length > 0 && !line.includes(' 0) { + this.logger.info(`unity run output:\n${output}`); + } + } + if (exitCode === undefined) { throw Error('Failed to start Unity!'); } else if (exitCode !== 0) { @@ -386,6 +328,67 @@ export class UnityEditor { } } + /** + * Decides whether a run goes through the official CLI's `unity run`. + * It needs a project, and is skipped for editors and hosts it is not verified with: + * editors older than 2019.1, Linux runs that need xvfb, and x86_64 editors under Rosetta. + * Set UNITY_CLI_DIRECT_LAUNCH=true to always launch the editor directly. + */ + private async getUnityCliForRun(command: EditorCommand): Promise { + const directLaunch = process.env.UNITY_CLI_DIRECT_LAUNCH?.trim().toLowerCase(); + + if (!command.projectPath || + directLaunch === 'true' || directLaunch === '1' || + this.version.isLessThan('2019.1.0') || + (process.platform === 'linux' && !command.args.includes('-nographics')) || + (process.platform === 'darwin' && process.arch === 'arm64' && this.version.architecture === 'X86_64')) { + return undefined; + } + + try { + return await UnityCli.Find(); + } catch (error) { + this.logger.debug(`Launching the editor directly, the Unity CLI was not found: ${error}`); + return undefined; + } + } + + /** + * Builds the `unity run` arguments for an editor command. + * `unity run` adds -batchmode, -projectPath and -logFile itself, so those are not forwarded. + * @param editorPath The editor executable. + * @param projectPath The Unity project. + * @param logPath The editor log file, which unity-cli tails itself. + * @param args The editor arguments. + */ + public static GetUnityRunArgs(editorPath: string, projectPath: string, logPath: string, args: string[]): string[] { + const forwarded: string[] = []; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]!; + + if (arg === '-projectPath' || arg === '-logFile') { + i++; + continue; + } + + if (arg === '-batchmode') { + continue; + } + + forwarded.push(arg); + } + + return [ + 'run', projectPath, + '--editor-path', editorPath, + '--log-file', logPath, + '--no-tail', + '--', + ...forwarded + ]; + } + /** * Get or create the Logs directory within the Unity project or current working directory. * @param projectPath The path to the Unity project. If undefined, uses the current working directory. @@ -537,36 +540,4 @@ export class UnityEditor { throw new Error(`Unsupported platform: ${process.platform}`); } } - - /** - * Uninstall the Unity Editor. - */ - public async Uninstall(): Promise { - switch (process.platform) { - case 'darwin': - case 'linux': - await Exec('sudo', [ - 'rm', '-rf', this.editorRootPath - ], { silent: true, showCommand: true }); - break; - case 'win32': - const editorDir = path.dirname(this.editorPath); - const uninstallPath = path.join(editorDir, 'Uninstall.exe'); - await fs.promises.access(uninstallPath, fs.constants.R_OK | fs.constants.X_OK); - await Exec('powershell', [ - '-NoProfile', - '-Command', - `Start-Process -FilePath "${uninstallPath}" -ArgumentList "/S" -Wait` - ], { silent: true, showCommand: true }); - // delete the editor root directory if it still exists - await DeleteDirectory(editorDir); - - if (this.version.isLegacy()) { - // delete the MonoDevelop that is a sibling of the Unity editor directory - const monoDevelopDir = path.join(path.dirname(editorDir), 'MonoDevelop'); - await DeleteDirectory(monoDevelopDir); - } - break; - } - } -} \ No newline at end of file +} diff --git a/src/unity-hub.ts b/src/unity-hub.ts deleted file mode 100644 index c1373d3d..00000000 --- a/src/unity-hub.ts +++ /dev/null @@ -1,1595 +0,0 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import * as yaml from 'yaml'; -import * as asar from '@electron/asar'; -import { spawn } from 'child_process'; -import { Logger, LogLevel } from './logging'; -import { UnityEditor } from './unity-editor'; -import { UnityVersion } from './unity-version'; -import { - SemVer, - coerce, - compare, - valid -} from 'semver'; -import { - DeleteDirectory, - DownloadFile, - Exec, - ExecOptions, - GetTempDir, - HttpsGetText, - ReadFileContents, -} from './utilities'; -import { - UnityReleasesClient, - GetUnityReleasesData, - UnityRelease, -} from '@rage-against-the-pixel/unity-releases-api'; - -interface ReleaseInfo { - unityRelease: UnityRelease; - unityVersion: UnityVersion; -} - -/** First Unity Hub line with native Windows ARM64 installers on the public CDN. */ -const MIN_NATIVE_WINDOWS_ARM64_HUB_VERSION = coerce('3.17.0')!; - -/** Allowed characters in a Debian package version (no shell metacharacters). */ -const LINUX_HUB_DEB_VERSION_RE = /^[0-9A-Za-z.+~:-]+$/; - -/** Hub 3.20+ Electron Forge deb layout. */ -export const LINUX_HUB_EXECUTABLE_MODERN = '/usr/lib/unityhub/unityhub'; -/** Hub ≤3.19 fpm / electron-builder layout. */ -export const LINUX_HUB_EXECUTABLE_LEGACY = '/opt/unityhub/unityhub'; - -/** - * Resolves the Unity Hub binary on Linux. - * Prefers UNITY_HUB_PATH, then the Hub 3.20+ path, then the legacy /opt path. - * When neither is present (pre-install), defaults to the modern path. - */ -export function resolveLinuxHubExecutable( - envPath: string | undefined = process.env.UNITY_HUB_PATH, - existsSync: (p: string) => boolean = fs.existsSync -): string { - if (envPath !== undefined && envPath.length > 0) { - return envPath; - } - if (existsSync(LINUX_HUB_EXECUTABLE_MODERN)) { - return LINUX_HUB_EXECUTABLE_MODERN; - } - if (existsSync(LINUX_HUB_EXECUTABLE_LEGACY)) { - return LINUX_HUB_EXECUTABLE_LEGACY; - } - return LINUX_HUB_EXECUTABLE_MODERN; -} - -/** - * Fixed bootstrap for Linux Hub apt repo + update index. No user-controlled interpolation (CodeQL). - * Uses DEB822 .sources (Hub 3.20+) and removes legacy .list to avoid duplicate-source warnings. - */ -const LINUX_HUB_LINUX_UPDATE_REPO_BOOTSTRAP = `#!/bin/sh -set -e -wget -qO - https://hub.unity3d.com/linux/keys/public | gpg --dearmor | sudo tee /usr/share/keyrings/Unity_Technologies_ApS.gpg >/dev/null -sudo rm -f /etc/apt/sources.list.d/unityhub.list -sudo tee /etc/apt/sources.list.d/unityhub.sources >/dev/null <<'EOF' -Types: deb -URIs: https://hub.unity3d.com/linux/repos/deb -Suites: stable -Components: main -Signed-By: /usr/share/keyrings/Unity_Technologies_ApS.gpg -EOF -sudo apt-get update --allow-releaseinfo-change -`; - -/** - * First phase of fresh Linux Hub install: machine-id, repo keys, jammy mirror, apt-get update. - * No user-controlled interpolation. Uses DEB822 .sources (Hub 3.20+). - */ -const LINUX_HUB_LINUX_INSTALL_BOOTSTRAP = `#!/bin/sh -set -e -dbus-uuidgen >/etc/machine-id && mkdir -p /var/lib/dbus/ && ln -sf /etc/machine-id /var/lib/dbus/machine-id -wget -qO - https://hub.unity3d.com/linux/keys/public | gpg --dearmor | tee /usr/share/keyrings/Unity_Technologies_ApS.gpg >/dev/null -rm -f /etc/apt/sources.list.d/unityhub.list -tee /etc/apt/sources.list.d/unityhub.sources >/dev/null <<'EOF' -Types: deb -URIs: https://hub.unity3d.com/linux/repos/deb -Suites: stable -Components: main -Signed-By: /usr/share/keyrings/Unity_Technologies_ApS.gpg -EOF -echo "deb https://archive.ubuntu.com/ubuntu jammy main universe" | tee /etc/apt/sources.list.d/jammy.list -apt-get update -`; - -/** - * Post-install cleanup and xvfb / unity-hub wrapper setup. Runs as root; no user interpolation. - * Wrapper resolves Hub 3.20+ (/usr/lib/unityhub) vs legacy (/opt/unityhub) at runtime so apt - * upgrades that move the binary do not leave a stale path (exit 127). - */ -const LINUX_HUB_LINUX_INSTALL_POST = `#!/bin/sh -set -e -apt-get clean -sed -i 's/^\\(.*DISPLAY=:.*XAUTHORITY=.*\\)\\( "\\$@" \\)2>&1$/\\1\\2/' /usr/bin/xvfb-run -command -v unityhub >/dev/null || { echo "Unity Hub installation failed"; exit 1; } -hubPath=$(readlink -f "$(command -v unityhub)" 2>/dev/null || true) -if [ -z "$hubPath" ] || [ ! -x "$hubPath" ]; then - if [ -x /usr/lib/unityhub/unityhub ]; then - hubPath=/usr/lib/unityhub/unityhub - elif [ -x /opt/unityhub/unityhub ]; then - hubPath=/opt/unityhub/unityhub - else - echo "Failed to install Unity Hub" - exit 1 - fi -fi -tee /usr/bin/unity-hub >/dev/null <<'WRAPPER' -#!/bin/bash -if [ -x /usr/lib/unityhub/unityhub ]; then - hubBin=/usr/lib/unityhub/unityhub -elif [ -x /opt/unityhub/unityhub ]; then - hubBin=/opt/unityhub/unityhub -else - hubBin=$(readlink -f "$(command -v unityhub)" 2>/dev/null || true) -fi -if [ -z "$hubBin" ] || [ ! -x "$hubBin" ]; then - echo "Unity Hub binary not found" >&2 - exit 127 -fi -exec xvfb-run --auto-servernum "$hubBin" "$@" 2>/dev/null -WRAPPER -chmod 777 /usr/bin/unity-hub -chmod -R 777 "$(dirname "$hubPath")" -`; - -const LINUX_HUB_LINUX_APT_EXTRAS = [ - 'xvfb', - 'ffmpeg', - 'libgtk2.0-0', - 'libglu1-mesa', - 'libgconf-2-4', - 'libncurses5', - 'pulseaudio', -] as const; - -export class UnityHub { - /** The path to the Unity Hub executable. */ - public executable!: string; - /** The root directory of the Unity Hub installation. */ - public rootDirectory!: string; - /** The file extension for the Unity editor executable. */ - public readonly editorFileExtension: string; - - private readonly logger: Logger = Logger.instance; - - constructor() { - switch (process.platform) { - case 'win32': { - const hubPath = process.env.UNITY_HUB_PATH ?? undefined; - if (hubPath !== undefined && hubPath.length > 0) { - this.executable = hubPath; - } else { - const pf64 = path.join(process.env.ProgramFiles || 'C:\\Program Files', 'Unity Hub', 'Unity Hub.exe'); - const pfx86 = path.join( - process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', - 'Unity Hub', - 'Unity Hub.exe' - ); - // Windows ARM64 images install Hub under WOW64; x64 prefers 64-bit path when present. - if (process.arch === 'arm64') { - this.executable = pfx86; - } else { - this.executable = fs.existsSync(pf64) ? pf64 : fs.existsSync(pfx86) ? pfx86 : pf64; - } - } - this.rootDirectory = path.join(this.executable, '../'); - this.editorFileExtension = '\\Editor\\Unity.exe'; - break; - } - case 'darwin': - this.executable = process.env.UNITY_HUB_PATH || '/Applications/Unity Hub.app/Contents/MacOS/Unity Hub'; - this.rootDirectory = path.join(this.executable, '../../../'); - this.editorFileExtension = '/Unity.app/Contents/MacOS/Unity'; - break; - case 'linux': - this.refreshLinuxHubPaths(); - this.editorFileExtension = '/Editor/Unity'; - break; - default: - throw new Error(`Unsupported platform: ${process.platform}`); - } - } - - /** Re-resolve Linux Hub executable + root after install/upgrade (Hub 3.20 moved under /usr/lib). */ - private refreshLinuxHubPaths(): void { - this.executable = resolveLinuxHubExecutable(); - this.rootDirectory = path.join(this.executable, '../'); - } - - /** - * Some Hub builds (notably Windows headless) occasionally exit non-zero after streaming usable - * `editors --releases` / `editors -i` data. Tolerate only when the captured output parses the same - * way {@link ListAvailableReleases} / {@link ListInstalledEditors} would (avoids regex false positives). - */ - private hubListingExitTolerable(args: string[], hubOutput: string): boolean { - if (!this.isHubEditorListingArgs(args)) { - return false; - } - - if (args.includes('--releases')) { - return this.parseAvailableReleasesFromHubText(hubOutput).length > 0; - } - - if (args.includes('-i') || args.includes('--installed')) { - return hubOutput.includes('installed at'); - } - - return false; - } - - private isHubEditorListingArgs(args: string[]): boolean { - return args.length > 0 && args[0] === 'editors' && - (args.includes('--releases') || args.includes('-i') || args.includes('--installed')); - } - - private async delayMs(ms: number): Promise { - await new Promise((resolve) => setTimeout(resolve, ms)); - } - - /** Same parsing rules as {@link ListAvailableReleases}; must stay in sync. */ - private parseAvailableReleasesFromHubText(output: string): UnityVersion[] { - return output.split('\n') - .map(line => line.trim()) - .map(line => { - const match = line.match(/^(\d{1,4}\.\d+\.\d+[abcfpx]?\d*)/); - return match ? match[1] : undefined; - }) - .filter((line): line is string => !!line && /^\d{1,4}\.\d+\.\d+[abcfpx]?\d*/.test(line)) - .map(line => new UnityVersion(line!)) - .sort((a, b) => UnityVersion.compare(b, a)); - } - - /** Same parsing rules as {@link ListInstalledEditors}; must stay in sync. */ - private parseInstalledEditorsFromHubText(output: string): UnityEditor[] { - const paths = output.split('\n') - .filter(line => /installed at/.test(line)) - .map(line => line.trim()); - const editors: UnityEditor[] = []; - const pattern = /(?\d+\.\d+\.\d+[abcfpx]?\d*)\s*(?:\((?Apple silicon|Intel)\))?\s*,? installed at (?.*)/; - const matches = paths.map((line) => line.match(pattern)).filter(match => match && match.groups); - - if (paths.length !== matches.length) { - throw new Error(`Failed to parse all installed Unity Editors!\n > paths: ${JSON.stringify(paths)}\n > matches: ${JSON.stringify(matches)}`); - } - - for (const match of matches) { - if (match && match.groups && match.groups.version && match.groups.editorPath) { - const version = new UnityVersion(match.groups.version, null, match.groups.arch === 'Apple silicon' ? 'ARM64' : match.groups.arch === 'Intel' ? 'X86_64' : undefined); - editors.push(new UnityEditor(path.normalize(match.groups.editorPath), version)); - } - } - - editors.sort((a, b) => { - if (!a.version && !b.version) { return 0; } - if (!a.version) { return 1; } - if (!b.version) { return -1; } - return UnityVersion.compare(b.version!, a.version!); - }); - - return editors; - } - - /** - * Executes the Unity Hub command with the specified arguments. - * @param args Arguments to pass to the Unity Hub executable. - * @param options Logging and spawn options for this invocation. - * @returns The output from the command. - */ - public async Exec( - args: string[], - options: ExecOptions = { silent: this.logger.logLevel > LogLevel.CI, showCommand: this.logger.logLevel <= LogLevel.CI }, - ): Promise { - return this.execImpl(args, options, 0); - } - - /** - * @param listingRetryDepth 0 on first attempt; 1 after one listing-only retry (flaky Hub exits on Windows CI). - */ - private async execImpl( - args: string[], - options: ExecOptions, - listingRetryDepth: number, - ): Promise { - let output: string = ''; - let exitCode: number = 0; - - const filteredArgs = args.filter(arg => arg !== '--headless' && arg !== '--'); - const executable = process.platform === 'linux' ? 'unity-hub' : this.executable; - const execArgs = process.platform === 'linux' ? ['--headless', ...filteredArgs] : ['--', '--headless', ...filteredArgs]; - - if (options.showCommand) { - this.logger.startGroup(`\x1b[34m${executable} ${execArgs.join(' ')}\x1b[0m`); - } - - if (this.executable.includes(path.sep)) { - fs.accessSync(this.executable, fs.constants.R_OK | fs.constants.X_OK); - } - - const ignoredLines = [ - 'This error originated either by throwing inside of an async function without a catch block', - 'Unexpected error attempting to determine if executable file exists', - 'dri3 extension not supported', - 'Failed to connect to the bus:', - 'Error: No modules found to install.', - 'Checking for beta autoupdate feature for deb/rpm distributions', - 'Found package-type: deb', - 'XPC error for connection com.apple.backupd.sandbox.xpc: Connection invalid', - 'Failed to execute the command due the following, please see \'-- --headless help\' for assistance.', - 'Unable to move the cache: Access is denied.', - 'Entities without keys will be embedded directly on the parent entity. If this is intentional, create a `keys` config for `UnityReleaseLabel` that always returns null.', - 'https://bit.ly/2XbVrpR#15', - 'Interaction is not allowed with the Security Server." (-25308)', - 'Network service crashed, restarting service.', - 'Invalid key: The GraphQL query at the field at', - 'You have to request `id` or `_id` fields for all selection sets or create a custom `keys` config for `UnityReleaseLabel`.', - ]; - - try { - exitCode = await new Promise((resolve, reject) => { - let isSettled: boolean = false; // Has the promise been settled (resolved or rejected)? - let isHubTaskCompleteSuccess: boolean = false; // Has the Unity Hub tasks completed successfully? - let isHubTaskCompleteFailed: boolean = false; // Has the Unity Hub tasks completed with failure? - let lineBuffer = ''; // Buffer for incomplete lines - const tasksCompleteMessages: string[] = [ - 'All Tasks Completed Successfully.', - 'Completed with errors.' - ]; - const child = spawn(executable, execArgs, { - stdio: ['ignore', 'pipe', 'pipe'], - ...(process.platform === 'win32' ? { windowsHide: true } : {}), - }); - const sigintHandler = () => child.kill('SIGINT'); - const sigtermHandler = () => child.kill('SIGTERM'); - process.once('SIGINT', sigintHandler); - process.once('SIGTERM', sigtermHandler); - - let hasCleanedUpListeners = false; - function removeListeners(): void { - if (hasCleanedUpListeners) { return; } - hasCleanedUpListeners = true; - process.removeListener('SIGINT', sigintHandler); - process.removeListener('SIGTERM', sigtermHandler); - } - - function processOutput(data: Buffer): void { - try { - const chunk = data.toString(); - const fullChunk = lineBuffer + chunk; - const lines = fullChunk.split(/\r?\n/) // split by newline - .filter(line => line.length > 0); // filter out empty lines - - if (!chunk.endsWith('\n')) { - lineBuffer = lines.pop() || ''; - } else { - lineBuffer = ''; - } - - if (lines.some(line => tasksCompleteMessages.includes(line))) { - isHubTaskCompleteSuccess = lines.includes('All Tasks Completed Successfully.'); - isHubTaskCompleteFailed = lines.includes('Completed with errors.'); - - if (child?.pid) { - try { - child.kill('SIGTERM'); - - setTimeout(() => { - try { - if (child?.pid && !child.killed) { - child.kill('SIGKILL'); - } - } catch { - // Ignore, process may have already exited - } - }, 1000); - } catch { - // Ignore, process may have already exited - } finally { - if (isHubTaskCompleteSuccess) { - settle(0); - } else if (isHubTaskCompleteFailed) { - settle(1); - } else { - settle(null); - } - } - } - } - - for (const line of lines) { - output += `${line}\n`; - - if (!options.silent && !ignoredLines.some(ignored => line.includes(ignored))) { - process.stdout.write(`${line}\n`); - } - } - } catch (error: any) { - if (error.code !== 'EPIPE') { - throw error; - } - } - } - - function flushOutput(): void { - try { - if (lineBuffer.length > 0) { - const lines = lineBuffer.split(/\r?\n/) // split by newline - .filter(line => line.length > 0); // filter out empty lines - lineBuffer = ''; - const outputLines = lines.filter(line => !ignoredLines.some(ignored => line.includes(ignored))); - - if (outputLines.some(line => tasksCompleteMessages.includes(line))) { - isHubTaskCompleteSuccess = outputLines.includes('All Tasks Completed Successfully.'); - isHubTaskCompleteFailed = outputLines.includes('Completed with errors.'); - } - - for (const line of outputLines) { - output += `${line}\n`; - - if (!options.silent) { - process.stdout.write(`${line}\n`); - } - } - } - } catch (error: any) { - if (error.code !== 'EPIPE') { - Logger.instance.error(`Failed to process buffered output: ${error}`); - } - } - } - - function settle(code: number | null): void { - if (isSettled) { return; } - isSettled = true; - removeListeners(); - flushOutput(); - resolve(code === null ? 0 : code); - } - - child.stdout.on('data', processOutput); - child.stderr.on('data', processOutput); - child.on('error', (error) => { - isSettled = true; - removeListeners(); - flushOutput(); - reject(error); - }); - child.on('close', settle); - }); - } finally { - this.logger.endGroup(); - const match = output.match(/Assertion (?.+) failed/g); - const retryConditions = [ - 'async hook stack has become corrupted', - 'failed to download' - ]; - - if (match || - retryConditions.some(s => output.includes(s))) { - this.logger.warn(`Install failed, retrying...`); - return await this.execImpl(args, options, 0); - } - - if (exitCode > 0) { - if (this.hubListingExitTolerable(args, output)) { - this.logger.warn(`Unity Hub exited with code ${exitCode} but produced usable listing output; continuing.`); - } else { - const error = output.match(/Error(?: given)?:\s*(.+)/); - const errorMessage = error && error[1] ? error[1] : 'Unknown Error'; - - switch (errorMessage) { - case 'No modules found to install.': - break; - default: - if (this.isHubEditorListingArgs(args) && listingRetryDepth < 1) { - this.logger.warn(`Unity Hub listing command failed (exit code ${exitCode}); retrying once after 2s...`); - await this.delayMs(2000); - return await this.execImpl(args, options, listingRetryDepth + 1); - } - this.logger.debug(output); - throw new Error(`Failed to execute Unity Hub (exit code: ${exitCode}) ${errorMessage}`); - } - } - } - - output = output.split('\n') - .filter(line => line.trim().length > 0) - .filter(line => !ignoredLines.some(ignored => line.includes(ignored))) - .join('\n'); - } - - return output; - } - - /** - * Prints the installed Unity Hub version. - * @returns The installed Unity Hub version. - */ - public async Version(): Promise { - const version = await this.getInstalledHubVersion(); - return version.version; - } - - /** - * Installs or updates the Unity Hub. - * If the Unity Hub is already installed, it will be updated to the latest version. - * @param autoUpdate If true, automatically updates the Unity Hub if it is already installed. Default is true. - * @returns The path to the Unity Hub executable. - */ - public async Install(autoUpdate: boolean = true, version: string | undefined): Promise { - if (autoUpdate && version) { - throw new Error('Cannot use autoUpdate with version.'); - } - - let isInstalled = false; - let installedVersion: SemVer | undefined = undefined; - - try { - await fs.promises.access(this.executable, fs.constants.X_OK); - installedVersion = await this.getInstalledHubVersion(); - isInstalled = true; - } catch { - await this.installHub(version); - } - - if (isInstalled && autoUpdate) { - if (!installedVersion) { - installedVersion = await this.getInstalledHubVersion(); - } - - this.logger.ci(`Installed Unity Hub version: ${installedVersion.version}`); - let versionToInstall: SemVer | null = null; - - if (!version) { - try { - versionToInstall = await this.getLatestHubVersion(); - if (versionToInstall) { - this.logger.ci(`Latest Unity Hub version: ${versionToInstall.version}`); - } - } catch (error) { - this.logger.warn(`Failed to get latest Unity Hub version: ${error}`); - } - } else { - versionToInstall = coerce(version); - } - - if (versionToInstall === null || - !versionToInstall || - !valid(versionToInstall)) { - if (version || process.platform !== 'linux') { - throw new Error(`Invalid Unity Hub version to install: ${versionToInstall}`); - } - } - - const mustInstall = - Boolean(version) || - (process.platform === 'linux' && !version) || - (versionToInstall !== null && compare(installedVersion, versionToInstall) < 0); - - if (mustInstall) { - this.logger.info( - `Updating Unity Hub from ${installedVersion.version} to ${versionToInstall?.version ?? 'latest'}...` - ); - - if (process.platform === 'darwin') { - await Exec('sudo', ['rm', '-rf', this.rootDirectory], { silent: true, showCommand: true }); - await this.installHub(version); - } else if (process.platform === 'win32') { - const uninstaller = path.join(this.rootDirectory, 'Uninstall Unity Hub.exe'); - await Exec('powershell', [ - '-NoProfile', - '-Command', - `Start-Process -FilePath '${uninstaller}' -ArgumentList '/S' -Verb RunAs -Wait` - ], { silent: true, showCommand: true }); - await DeleteDirectory(this.rootDirectory); - await this.installHub(version); - } else if (process.platform === 'linux') { - const hubPkg = this.unityHubAptPackageSpec(version); - const linuxExecOpts = { silent: true, showCommand: true }; - await Exec('sudo', ['sh', '-c', LINUX_HUB_LINUX_UPDATE_REPO_BOOTSTRAP], linuxExecOpts); - await Exec( - 'sudo', - ['apt-get', 'install', '-y', '--no-install-recommends', '--only-upgrade', hubPkg], - linuxExecOpts - ); - // Refresh xvfb wrapper after upgrades that move /opt → /usr/lib (Hub 3.20+). - await Exec('sudo', ['sh', '-c', LINUX_HUB_LINUX_INSTALL_POST], linuxExecOpts); - this.refreshLinuxHubPaths(); - this.logger.info(`Unity Hub updated successfully.`); - } else { - throw new Error(`Unsupported platform: ${process.platform}`); - } - } else { - this.logger.info(`Unity Hub is already installed and up to date.`); - } - } - - if (process.platform === 'linux') { - this.refreshLinuxHubPaths(); - } - await fs.promises.access(this.executable, fs.constants.X_OK); - return this.executable; - } - - /** - * APT package spec for unityhub (e.g. `unityhub` or `unityhub=3.6.0`). Validated; passed as argv, not shell-embedded. - */ - private unityHubAptPackageSpec(version: SemVer | string | undefined): string { - if (version === undefined || version === null) { - return 'unityhub'; - } - if (typeof version === 'object' && 'version' in version) { - const deb = (version as SemVer).version; - if (!LINUX_HUB_DEB_VERSION_RE.test(deb)) { - throw new Error(`Invalid Unity Hub apt version: ${deb}`); - } - return `unityhub=${deb}`; - } - const raw = String(version).trim(); - if (raw.length === 0) { - return 'unityhub'; - } - const pinned = coerce(raw); - if (!pinned || !valid(pinned)) { - throw new Error(`Invalid Unity Hub version for apt: ${raw}`); - } - const deb = pinned.version; - if (!LINUX_HUB_DEB_VERSION_RE.test(deb)) { - throw new Error(`Invalid Unity Hub apt version: ${deb}`); - } - return `unityhub=${deb}`; - } - - private async installHub(version: SemVer | string | undefined): Promise { - this.logger.ci(`Installing Unity Hub${version ? ' ' + version : ''}...`); - - if (!version) { - switch (process.platform) { - case 'win32': - case 'darwin': - version = 'prod'; - break; - } - } - - switch (process.platform) { - case 'win32': { - if (process.arch === 'arm64' && version !== 'prod') { - const pinned = coerce(version as string | SemVer); - if (!pinned || !valid(pinned)) { - throw new Error(`Invalid Unity Hub version: ${String(version)}`); - } - if (compare(pinned, MIN_NATIVE_WINDOWS_ARM64_HUB_VERSION) < 0) { - throw new Error( - `Unity Hub ${pinned.version} does not ship a native Windows ARM64 installer ` + - `(minimum ${MIN_NATIVE_WINDOWS_ARM64_HUB_VERSION.version}). ` + - 'Install without --hub-version to use the latest Hub, or use Windows x64 for older Hub versions.' - ); - } - } - const winArch = process.arch === 'arm64' ? 'arm64' : 'x64'; - const setupFileName = `UnityHubSetup-${winArch}.exe`; - const url = `https://public-cdn.cloud.unity3d.com/hub/${version}/${setupFileName}`; - const downloadPath = path.join(GetTempDir(), setupFileName); - await DownloadFile(url, downloadPath); - - this.logger.info(`Running Unity Hub installer...`); - - try { - await Exec('powershell', [ - '-NoProfile', - '-Command', - `Start-Process -FilePath '${downloadPath}' -ArgumentList '/S' -Verb RunAs -Wait` - ], { silent: true, showCommand: true }); - } finally { - if (fs.statSync(downloadPath).isFile()) { - await fs.promises.unlink(downloadPath); - } - } - - break; - } - case 'darwin': { - const baseUrl = `https://public-cdn.cloud.unity3d.com/hub/${version}`; - const url = `${baseUrl}/UnityHubSetup-${process.arch}.dmg`; - const downloadPath = path.join(GetTempDir(), `UnityHubSetup-${process.arch}.dmg`); - - await DownloadFile(url, downloadPath); - await fs.promises.chmod(downloadPath, 0o777); - - let mountPoint = ''; - this.logger.debug(`Mounting DMG...`); - - try { - const output = await Exec('hdiutil', ['attach', downloadPath, '-nobrowse'], { silent: true, showCommand: true }); - // can be "/Volumes/Unity Hub 3.13.1-arm64" or "/Volumes/Unity Hub 3.13.1" - const mountPointMatch = output.match(/\/Volumes\/Unity Hub.*$/m); - - if (!mountPointMatch || mountPointMatch.length === 0) { - throw new Error(`Failed to find mount point in hdiutil output: ${output}`); - } - - mountPoint = mountPointMatch[0]; - this.logger.debug(`Mounted Unity Hub at ${mountPoint}`); - - const appPath = path.join(mountPoint, 'Unity Hub.app'); - this.logger.debug(`Copying ${appPath} to /Applications...`); - - await fs.promises.access(appPath, fs.constants.R_OK | fs.constants.X_OK); - if (fs.existsSync('/Applications/Unity Hub.app')) { - await Exec('sudo', ['rm', '-rf', '/Applications/Unity Hub.app'], { silent: true, showCommand: true }); - } - await Exec('sudo', ['cp', '-R', appPath, '/Applications/Unity Hub.app'], { silent: true, showCommand: true }); - await Exec('sudo', ['chmod', '777', '/Applications/Unity Hub.app/Contents/MacOS/Unity Hub'], { silent: true, showCommand: true }); - await Exec('sudo', ['mkdir', '-p', '/Library/Application Support/Unity'], { silent: true, showCommand: true }); - await Exec('sudo', ['chmod', '777', '/Library/Application Support/Unity'], { silent: true, showCommand: true }); - } finally { - try { - if (mountPoint && mountPoint.length > 0) { - await Exec('hdiutil', ['detach', mountPoint, '-quiet'], { silent: true, showCommand: true }); - } - } finally { - if (fs.statSync(downloadPath).isFile()) { - await fs.promises.unlink(downloadPath); - } - } - } - break; - } - case 'linux': { - const hubPkg = this.unityHubAptPackageSpec(version); - const linuxExecOpts = { silent: true, showCommand: true }; - await Exec('sudo', ['sh', '-c', LINUX_HUB_LINUX_INSTALL_BOOTSTRAP], linuxExecOpts); - await Exec( - 'sudo', - ['apt-get', 'install', '-y', '--no-install-recommends', hubPkg, ...LINUX_HUB_LINUX_APT_EXTRAS], - linuxExecOpts - ); - await Exec('sudo', ['sh', '-c', LINUX_HUB_LINUX_INSTALL_POST], linuxExecOpts); - this.refreshLinuxHubPaths(); - break; - } - default: - throw new Error(`Unsupported platform: ${process.platform}`); - } - - await fs.promises.access(this.executable, fs.constants.X_OK); - const installedVersion = await this.getInstalledHubVersion(); - this.logger.info(`Unity Hub ${installedVersion} installed successfully.`); - } - - private async getInstalledHubVersion(): Promise { - let asarPath: string | undefined = undefined; - - switch (process.platform) { - case 'darwin': { - asarPath = path.join(this.rootDirectory, 'Contents', 'Resources', 'app.asar'); - break; - } - default: { - asarPath = path.join(this.rootDirectory, 'resources', 'app.asar'); - break; - } - } - - try { - await fs.promises.access(asarPath, fs.constants.R_OK); - } catch { - throw new Error('Unity Hub is not installed.'); - } - - asar.uncacheAll(); - const fileBuffer = asar.extractFile(asarPath, 'package.json').toString('utf-8'); - let packageJson: any; - - try { - packageJson = JSON.parse(fileBuffer); - } catch (error) { - - throw new Error(`Failed to parse Unity Hub package.json: ${error}\n${fileBuffer}`); - } - - const version = coerce(packageJson.version); - - if (!version || !valid(version)) { - throw new Error(`Failed to parse Unity Hub version: ${packageJson.version}`); - } - - return version; - } - - private async getLatestHubVersion(): Promise { - let url: string | undefined = undefined; - - switch (process.platform) { - case 'win32': - url = 'https://public-cdn.cloud.unity3d.com/hub/prod/latest.yml'; - break; - case 'darwin': - url = 'https://public-cdn.cloud.unity3d.com/hub/prod/latest-mac.yml'; - break; - case 'linux': - return null; - default: - throw new Error(`Unsupported platform: ${process.platform}`); - } - - const data = await HttpsGetText(url); - const parsed = yaml.parse(data); - const version = coerce(parsed.version); - - if (!version || !valid(version)) { - throw new Error(`Failed to parse latest Unity Hub version: ${parsed.version}`); - } - - return version; - } - - /** - * Returns the path where the Unity editors will be installed. - * @returns The editor install path. - */ - public async GetInstallPath(): Promise { - const result = (await this.Exec(['install-path', '--get'])).trim(); - - if (!result || result.length === 0) { - throw new Error('Failed to get Unity Hub install path.'); - } - - return result; - } - - /** - * Sets the path where Unity editors will be installed. - * @param installPath The install path to set when installing Unity editors. - */ - public async SetInstallPath(installPath: string): Promise { - await fs.promises.mkdir(installPath, { recursive: true }); - await this.Exec(['install-path', '--set', installPath]); - } - - /** - * Locate and associate an installed editor from a stipulated path. - * @param editorPath The path to the Unity Editor installation. - */ - public async AddEditor(editorPath: string): Promise { - await fs.promises.access(editorPath, fs.constants.R_OK | fs.constants.X_OK); - await this.Exec(['editors', '--add', editorPath]); - } - - /** - * Attempts to find or install the specified Unity Editor version with the requested modules. - * @param unityVersion The Unity version to find or install. - * @param modules The modules to install alongside the editor. - * @param channels The channels to consider when searching for the editor. Can be 'f' (final), 'p' (patch), 'b' (beta), 'a' (alpha), or 'x' (experimental). Default is ['f']. - * @returns The path to the Unity Editor executable. - */ - public async GetEditor(unityVersion: UnityVersion, modules: string[] = [], channels: string[] = ['f']): Promise { - const retryErrorMessages = [ - 'Editor already installed in this location', - 'failed to download. Error given: Request timeout' - ]; - - this.logger.ci(`Getting release info for Unity ${unityVersion.toString()}...`); - let resolvedVersion = unityVersion; - - if (!resolvedVersion.isLegacy()) { - // Hub list is a fast path only. Misses must fall through to the Releases API — - // do not fail-closed until both Hub match and API resolution have failed. - if (!resolvedVersion.isFullyQualified()) { - try { - const releases = await this.ListAvailableReleases(); - Logger.instance.debug(`Found ${releases.length} available Unity releases, searching channels: ${channels.join(', ')}`); - resolvedVersion = resolvedVersion.findMatch(releases, channels); - } catch (hubMatchError) { - this.logger.debug( - `No Hub list match for ${resolvedVersion.toString()} (channels: ${channels.join(', ')}); trying Releases API...\n${hubMatchError}` - ); - } - } - - if (!resolvedVersion.changeset) { - try { - const unityReleaseInfo: UnityRelease = await this.GetEditorReleaseInfo(resolvedVersion, channels); - resolvedVersion = new UnityVersion(unityReleaseInfo.version, unityReleaseInfo.shortRevision, resolvedVersion.architecture); - } catch (error) { - // Fail closed for partial versions: never Hub-install "6000.6" and hope it picks a beta. - if (!resolvedVersion.isFullyQualified()) { - const msg = error instanceof Error ? error.message : String(error); - throw new Error( - `Failed to resolve Unity ${unityVersion.toString()} for channel(s) [${channels.join(', ')}]: ${msg}` - ); - } - this.logger.warn(`Failed to get Unity release info for ${resolvedVersion.toString()}! falling back to legacy search...\n${error}`); - try { - resolvedVersion = await this.fallbackVersionLookup(resolvedVersion); - } catch (fallbackError) { - this.logger.warn(`Failed to lookup changeset for Unity ${resolvedVersion.toString()}!\n${fallbackError}`); - } - } - } - } - - if (!resolvedVersion.isLegacy() && !resolvedVersion.isFullyQualified()) { - throw new Error( - `Refusing to install non-fully-qualified Unity version ${resolvedVersion.toString()} without a resolved release. ` + - `Use a fully-qualified version or --channel matching an available stream.` - ); - } - - const allowPartialMatches = !resolvedVersion.isFullyQualified(); - let editorPath = await this.checkInstalledEditors(resolvedVersion, false, undefined, allowPartialMatches); - unityVersion = resolvedVersion; - - let installDir: string | undefined = undefined; - - if (!editorPath) { - try { - installDir = await this.installUnity(unityVersion, modules); - } catch (error: unknown) { - const errMessage = error instanceof Error ? error.message : String(error); - if (retryErrorMessages.some((msg) => errMessage.includes(msg))) { - if (editorPath) { - await DeleteDirectory(editorPath); - } - - if (installDir) { - await DeleteDirectory(installDir); - } - - installDir = await this.installUnity(unityVersion, modules); - } else { - throw error; - } - } - - editorPath = await this.checkInstalledEditors(unityVersion, true, installDir); - } - - if (!editorPath) { - throw new Error(`Failed to find or install Unity Editor: ${unityVersion.toString()}`); - } - - await fs.promises.access(editorPath, fs.constants.X_OK); - await this.patchBeeBackend(editorPath); - - if (unityVersion.isLegacy() || modules.length === 0) { - return new UnityEditor(path.normalize(editorPath), unityVersion); - } - - try { - this.logger.info(`Validating installed modules for Unity ${unityVersion.toString()}...`); - const [installedModules, additionalModules] = await this.checkEditorModules(editorPath, unityVersion, modules); - - if (installedModules && installedModules.length > 0) { - this.logger.info(`Installed Modules:`); - - for (const module of installedModules) { - this.logger.info(` > ${module}`); - } - } - - if (additionalModules && additionalModules.length > 0) { - this.logger.info(`Additional Modules:`); - - for (const module of additionalModules) { - this.logger.info(` > ${module}`); - } - } - } catch (error: unknown) { - const errMessage = error instanceof Error ? error.message : String(error); - if (errMessage.includes(`No modules found`)) { - await DeleteDirectory(editorPath); - await this.GetEditor(unityVersion, modules); - } else { - throw error; - } - } - - return new UnityEditor(path.normalize(editorPath), unityVersion); - } - - /** - * Lists the installed Unity Editors. - * @returns A list of installed Unity Editor versions and their paths. - */ - public async ListInstalledEditors(): Promise { - const output = await this.Exec(['editors', '-i']); - return this.parseInstalledEditorsFromHubText(output); - } - - /** - * Lists the available Unity releases. - * @returns A list of available Unity release versions. - */ - public async ListAvailableReleases(): Promise { - const output = await this.Exec(['editors', '--releases']); - return this.parseAvailableReleasesFromHubText(output); - } - - private async checkInstalledEditors( - unityVersion: UnityVersion, - failOnEmpty: boolean, - installDir: string | undefined = undefined, - allowPartialMatches: boolean = true - ): Promise { - let editorPath = undefined; - - if (!installDir) { - const editors: UnityEditor[] = await this.ListInstalledEditors(); - - if (editors && editors.length > 0) { - // Prefer exact version match first - const exactEditor = editors.find(e => e.version && e.version.version === unityVersion.version); - - if (exactEditor) { - editorPath = exactEditor.editorPath; - } else if (allowPartialMatches) { - // Fallback: semver satisfies - const versionEditors = editors.filter(e => e.version && unityVersion.satisfies(e.version)); - - if (versionEditors.length === 0) { - return undefined; - } - - // ListInstalledEditors already returns editors sorted descending by version. - for (const editor of versionEditors) { - if (!editor.version || !editor.editorPath) { - continue; - } - - // If no architecture requested or editor has no arch info, accept it - if (!unityVersion.architecture || !editor.version.architecture) { - editorPath = editor.editorPath; - break; - } - - // Exact architecture match - if (unityVersion.architecture === editor.version.architecture) { - editorPath = editor.editorPath; - break; - } - - // Fallback: check for architecture suffix in path (e.g., -arm64) - if (unityVersion.architecture && editor.editorPath.toLowerCase().includes(`-${unityVersion.architecture.toLowerCase()}`)) { - editorPath = editor.editorPath; - break; - } - } - } - } - } else { - if (process.platform == 'win32') { - editorPath = path.join(installDir, 'Unity.exe'); - } else { - editorPath = installDir; - } - } - - if (!editorPath) { - if (failOnEmpty) { - throw new Error(`Failed to find installed Unity Editor: ${unityVersion.toString()}`); - } - else { - return undefined; - } - } - - if (process.platform === 'darwin') { - editorPath = path.join(editorPath, '/Contents/MacOS/Unity'); - } - - try { - await fs.promises.access(editorPath, fs.constants.R_OK | fs.constants.X_OK); - } catch (error) { - throw new Error(`Failed to find installed Unity Editor: ${unityVersion.toString()}\n > ${error}`); - } - - this.logger.debug(`Found installed editor: "${editorPath}"`); - return editorPath; - } - - /** - * Patches the Bee Backend for Unity Linux Editor. - * https://discussions.unity.com/t/linux-editor-stuck-on-loading-because-of-bee-backend-w-workaround/854480 - * @param editorPath The path to the Unity Editor executable. - */ - private async patchBeeBackend(editorPath: string): Promise { - if (process.platform === 'linux') { - const dataPath = path.join(path.dirname(editorPath), 'Data'); - const beeBackend = path.join(dataPath, 'bee_backend'); - const dotBeeBackend = path.join(dataPath, '.bee_backend'); - if (fs.existsSync(beeBackend) && !fs.existsSync(dotBeeBackend)) { - this.logger.ci(`Patching Unity Linux Editor for Bee Backend...`); - await fs.promises.rename(beeBackend, dotBeeBackend); - const wrapperSource: string = `#!/bin/bash -# https://discussions.unity.com/t/linux-editor-stuck-on-loading-because-of-bee-backend-w-workaround/854480 -set -e -args=("$@") -for ((i = 0; i < "\${#args[@]}"; ++i)); do - case \${args[i]} in - --stdin-canary) - unset "args[i]" - break - ;; - esac -done -"$(dirname "$0")/.$(basename "$0")" "\${args[@]}"` - await fs.promises.writeFile(beeBackend, wrapperSource, { encoding: 'utf-8', mode: 0o755 }); - } - } - } - - /** - * Gets the specified Unity release info from the Unity Releases API. - * Supports querying by exact version or by prefix (e.g., "2020", "2020.1", "2021.x", "2021.3.x"). - * @param unityVersion The Unity version to get the release info for. - * @param channels Letter channels to accept (`f`, `p`, `b`, `a`, `x`). Default stable-only. - * @returns The Unity release info. - */ - public async GetEditorReleaseInfo(unityVersion: UnityVersion, channels: string[] = ['f']): Promise { - // Prefer querying the releases API with the exact fully-qualified Unity version (e.g., 2022.3.10f1). - // If we don't have a fully-qualified version, use the most specific prefix available: - // - "YYYY.M" when provided (e.g., 6000.1) - // - otherwise "YYYY" - const fullUnityVersionPattern = /^\d{1,4}\.\d+\.\d+[abcfpx]\d+$/; - let version: string; - - if (fullUnityVersionPattern.test(unityVersion.version)) { - version = unityVersion.version; - } else { - const match = unityVersion.version.match(/^(\d{1,4})(?:\.(\d+))?/); - - if (match) { - version = match[2] ? `${match[1]}.${match[2]}` : match[1]!; - } else { - version = unityVersion.version.split('.')[0]!; - } - } - - const releasesClient = new UnityReleasesClient(); - const channelSet = new Set(channels.map(c => c.toLowerCase())); - - function getPlatform(): Array<('MAC_OS' | 'LINUX' | 'WINDOWS')> { - switch (process.platform) { - case 'darwin': - return ['MAC_OS']; - case 'linux': - return ['LINUX']; - case 'win32': - return ['WINDOWS']; - default: - throw new Error(`Unsupported platform: ${process.platform}`); - } - } - - function releaseChannelLetter(releaseVersion: string): string | undefined { - const m = /^(\d{1,4})\.(\d+)\.(\d+)([abcfpx])(\d+)$/.exec(releaseVersion); - return m?.[4]; - } - - const request: GetUnityReleasesData = { - url: '/unity/editor/release/v1/releases', - query: { - version: version, - architecture: [unityVersion.architecture], - platform: getPlatform(), - limit: 10, - order: 'RELEASE_DATE_DESC', - } - }; - - this.logger.debug(`Get Unity Release: ${JSON.stringify(request, null, 2)}`); - - async function getRelease() { - const { data, error } = await releasesClient.api.Release.getUnityReleases(request); - - if (error) { - throw new Error(`Failed to get Unity releases: ${JSON.stringify(error, null, 2)}`); - } - - if (!data || !data.results || data.results.length === 0) { - throw new Error(`No Unity releases found for version: ${version}`); - } - - const releases: ReleaseInfo[] = (data.results || []) - .filter((release) => { - const v = release.version; - if (v == null || v === '') { - return false; - } - // Exact FQ request: accept that row regardless of channel filter. - if (fullUnityVersionPattern.test(unityVersion.version) && v === unityVersion.version) { - return true; - } - const letter = releaseChannelLetter(v); - return letter != null && channelSet.has(letter); - }) - .map(release => ({ - unityRelease: release, - unityVersion: new UnityVersion(release.version!, release.shortRevision, unityVersion.architecture) - })); - - if (releases.length === 0) { - const channelList = [...channelSet].join(','); - throw new Error( - `No suitable Unity releases (channels: ${channelList}) found for version: ${version}` + - (channelSet.has('f') && channelSet.size === 1 - ? `. No stable (f) release for ${version}; use --channel b/a or a fully-qualified version.` - : '') - ); - } - - releases.sort((a, b) => UnityVersion.compare(b.unityVersion, a.unityVersion)); - - Logger.instance.debug(`Found ${releases.length} matching Unity releases for version: ${version}`); - releases.forEach(release => { - Logger.instance.debug(` - ${release.unityRelease.version} (${release.unityRelease.shortRevision}) - ${release.unityRelease.recommended}`); - }); - const latest = releases[0]!.unityRelease!; - return latest; - } - - try { - return await getRelease(); - } catch (error) { - if (error instanceof Error && error.message.includes('fetch failed')) { - // Transient network error, retry once - return await getRelease(); - } - - throw new Error(`Failed to get Unity releases: ${error}`); - } - } - - private async fallbackVersionLookup(unityVersion: UnityVersion): Promise { - if (!unityVersion.isFullyQualified()) { - throw new Error(`Cannot lookup changeset for non-fully-qualified Unity version: ${unityVersion.toString()}`); - } - - const url = `https://unity.com/releases/editor/whats-new/${unityVersion.version}`; - this.logger.debug(`Fetching release page: "${url}"`); - let response: Response; - - try { - response = await fetch(url); - } catch (error) { - this.logger.warn(`Failed to fetch changeset for Unity ${unityVersion.toString()}: ${error}`); - return unityVersion; - } - - const responseText = await response.text(); - - if (!response.ok) { - this.logger.info(responseText); - } - - if (!response.ok) { - throw new Error(`Failed to fetch changeset for Unity ${unityVersion.toString()} [${response.status}] "${url}"`); - } - - this.logger.debug(`Release page content: \n${responseText}`); - const match = responseText.match(/unityhub:\/\/(?\d+\.\d+\.\d+[abcfpx]?\d*)\/(?[a-zA-Z0-9]+)/); - - if (match && match.groups && match.groups.version && match.groups.changeset) { - return new UnityVersion(match.groups.version, match.groups.changeset, unityVersion.architecture); - } - - this.logger.error(`Failed to find changeset for Unity ${unityVersion.toString()}`); - return unityVersion; - } - - private async checkEditorModules(editorPath: string, unityVersion: UnityVersion, modules: string[]): Promise<[string[], string[]]> { - let args = ['install-modules', '--version', unityVersion.version]; - - if (unityVersion.architecture) { - args.push('-a', unityVersion.architecture.toLowerCase()); - } - - for (const module of modules) { - args.push('-m', module); - } - - const editorRootPath = UnityEditor.GetEditorRootPath(editorPath); - const modulesPath = path.join(editorRootPath, 'modules.json'); - this.logger.debug(`Editor Modules Manifest:\n > "${modulesPath}"`); - const output = await this.Exec([...args, '--cm'], { showCommand: true, silent: false }); - const moduleMatches = output.matchAll(/Omitting module (?.+) because it's already installed/g); - - if (moduleMatches) { - const omittedModules = [...moduleMatches].map(match => match.groups?.module); - for (const module of omittedModules) { - if (module && !modules.includes(module)) { - modules.push(module); - } - } - } - - const installedModules = [...modules]; - const additionalModules = []; - const additionalModulesJson = await this.getModulesContent(modulesPath); - - if (additionalModulesJson.length > 0) { - for (const module of additionalModulesJson) { - if (module.category === "Platforms" && module.visible === true) { - if (!installedModules.includes(module.id)) { - additionalModules.push(module.id); - } - } - } - } - - return [installedModules, additionalModules]; - } - - private async getModulesContent(modulesPath: string): Promise { - const modulesContent = await ReadFileContents(modulesPath); - return JSON.parse(modulesContent); - } - - private async installUnity(unityVersion: UnityVersion, modules: string[]): Promise { - if (unityVersion.isLegacy()) { - return await this.installUnity4x(unityVersion); - } - - if (process.platform === 'linux') { - const arch = process.arch === 'x64' ? 'amd64' : process.arch === 'arm64' ? 'arm64' : process.arch; - - // install older versions of libssl for older Unity versions - if (['2019.1', '2019.2'].some(v => unityVersion.version.startsWith(v))) { - const url = `https://archive.ubuntu.com/ubuntu/pool/main/o/openssl/libssl1.0.0_1.0.2g-1ubuntu4.20_${arch}.deb`; - const downloadPath = path.join(GetTempDir(), `libssl1.0.0_1.0.2g-1ubuntu4.20_${arch}.deb`); - await DownloadFile(url, downloadPath); - - try { - await Exec('sudo', ['dpkg', '-i', downloadPath]); - } finally { - fs.promises.unlink(downloadPath); - } - } else if ( - ['2019.3', '2019.4'].some(v => unityVersion.version.startsWith(v)) || - unityVersion.version.startsWith('2020.') || - unityVersion.version.startsWith('2021.') - ) { - const url = `https://archive.ubuntu.com/ubuntu/pool/main/o/openssl/libssl1.1_1.1.0g-2ubuntu4_${arch}.deb`; - const downloadPath = path.join(GetTempDir(), `libssl1.1_1.1.0g-2ubuntu4_${arch}.deb`); - await DownloadFile(url, downloadPath); - - try { - await Exec('sudo', ['dpkg', '-i', downloadPath]); - } finally { - fs.promises.unlink(downloadPath); - } - } - } - - this.logger.ci(`Installing Unity ${unityVersion.toString()}...`); - const args = ['install', '--version', unityVersion.version]; - - if (unityVersion.changeset) { - args.push('--changeset', unityVersion.changeset); - } - - if (unityVersion.architecture) { - args.push('-a', unityVersion.architecture.toLowerCase()); - } - - if (modules.length > 0) { - for (const module of modules) { - this.logger.ci(` > with module: ${module}`); - args.push('-m', module); - } - - args.push('--cm'); - } - - const output = await this.Exec(args, { showCommand: true, silent: false }); - - if (output.includes(`Error while installing an editor or a module from changeset`) || - output.includes(`failed to download.`)) { - throw new Error(`Failed to install Unity ${unityVersion.toString()}`); - } - } - - private async installUnity4x(unityVersion: UnityVersion): Promise { - this.logger.ci(`Installing Unity ${unityVersion.toString()}...`); - const hubInstallDir = await this.GetInstallPath(); - - switch (process.platform) { - case 'win32': { - const installDir = path.join(hubInstallDir, `Unity ${unityVersion.version}`); - const installPath = path.join(installDir, 'Unity.exe'); - - if (!fs.existsSync(installPath)) { - const url = `https://beta.unity3d.com/download/UnitySetup-${unityVersion.version}.exe`; - const installerPath = path.join(GetTempDir(), `UnitySetup-${unityVersion.version}.exe`); - await DownloadFile(url, installerPath); - - this.logger.info(`Running Unity ${unityVersion.toString()} installer...`); - - try { - await Exec('powershell', [ - '-Command', - `Start-Process -FilePath \"${installerPath}\" -ArgumentList \"/S /D=${installDir}\" -Wait` - ], { silent: true, showCommand: true }); - } catch (error) { - this.logger.error(`Failed to install Unity ${unityVersion.toString()}: ${error}`); - } finally { - fs.promises.unlink(installerPath); - } - } - - await fs.promises.access(installDir, fs.constants.R_OK | fs.constants.X_OK); - return installDir; - } - case 'darwin': { - const installDir = path.join(hubInstallDir, `Unity ${unityVersion.version}`, 'Unity.app'); - - if (!fs.existsSync(installDir)) { - const url = `https://beta.unity3d.com/download/unity-${unityVersion.version}.dmg`; - const installerPath = path.join(GetTempDir(), `UnitySetup-${unityVersion.version}.dmg`); - await DownloadFile(url, installerPath); - - this.logger.info(`Running Unity ${unityVersion.toString()} installer...`); - - let mountPoint = ''; - - try { - const output = await Exec('hdiutil', ['attach', installerPath, '-nobrowse'], { silent: true, showCommand: true }); - const mountPointMatch = output.match(/\/Volumes\/Unity Installer.*$/m); - - if (!mountPointMatch || mountPointMatch.length === 0) { - throw new Error(`Failed to find mount point in hdiutil output: ${output}`); - } - - mountPoint = mountPointMatch[0]; - this.logger.debug(`Mounted Unity Installer at ${mountPoint}`); - - const pkgPath = path.join(mountPoint, 'Unity.pkg'); - await fs.promises.access(pkgPath, fs.constants.R_OK); - - this.logger.debug(`Found .pkg installer: ${pkgPath}`); - await Exec('sudo', ['installer', '-pkg', pkgPath, '-target', '/', '-verboseR'], { silent: true, showCommand: true }); - const unityAppPath = path.join('/Applications', 'Unity'); - const targetPath = path.join(hubInstallDir, `Unity ${unityVersion.version}`); - - if (fs.existsSync(unityAppPath)) { - this.logger.debug(`Moving ${unityAppPath} to ${targetPath}...`); - await fs.promises.mkdir(path.dirname(targetPath), { recursive: true }); - - const items = await fs.promises.readdir(unityAppPath); - - for (const item of items) { - if (item !== 'Hub') { - const src = path.join(unityAppPath, item); - const dest = path.join(targetPath, item); - this.logger.debug(` > Moving ${src} to ${dest}`); - await fs.promises.cp(src, dest, { recursive: true }); - await fs.promises.rm(src, { recursive: true, force: true }); - } - } - - await fs.promises.chmod(targetPath, 0o777); - } else { - throw new Error(`Failed to find Unity.app after installation.`); - } - } catch (error) { - this.logger.error(`Failed to mount Unity ${unityVersion.toString()} installer: ${error}`); - } finally { - try { - if (mountPoint && mountPoint.length > 0) { - await Exec('hdiutil', ['detach', mountPoint, '-quiet'], { silent: true, showCommand: true }); - } - } finally { - await fs.promises.unlink(installerPath); - } - } - } - - await fs.promises.access(installDir, fs.constants.R_OK | fs.constants.X_OK); - return installDir; - } - default: - throw new Error(`Unity ${unityVersion.toString()} is not supported on ${process.platform}`); - } - } - - /** - * Get the mapping of Unity platform targets to their corresponding module identifiers for the current OS. - * @returns A map of Unity platform targets to their corresponding module identifiers for the current OS. - */ - public static GetPlatformTargetModuleMap(): { [key: string]: string } { - const osType = os.type(); - let moduleMap: { [key: string]: string }; - - switch (osType) { - case 'Linux': - moduleMap = { - "StandaloneLinux64": "linux-il2cpp", - "Android": "android", - "WebGL": "webgl", - "iOS": "ios", - }; - break; - case 'Darwin': - moduleMap = { - "StandaloneOSX": "mac-il2cpp", - "iOS": "ios", - "Android": "android", - "tvOS": "appletv", - "StandaloneLinux64": "linux-il2cpp", - "WebGL": "webgl", - "VisionOS": "visionos" - }; - break; - case 'Windows_NT': - moduleMap = { - "StandaloneWindows64": "windows-il2cpp", - "WSAPlayer": "universal-windows-platform", - "Android": "android", - "iOS": "ios", - "tvOS": "appletv", - "StandaloneLinux64": "linux-il2cpp", - "Lumin": "lumin", - "WebGL": "webgl", - }; - break; - default: - throw Error(`${osType} not supported`); - } - - return moduleMap; - } - - /** - * Returns the path to the Unity Hub log file. - * @see https://docs.unity.com/en-us/licensing-server/troubleshooting-client#logs - * @returns The Unity Hub log file path. - */ - public static LogPath(): string { - switch (process.platform) { - case 'win32': - // %APPDATA%\UnityHub\logs\info-log.json - return path.join(process.env.APPDATA || '', 'UnityHub', 'logs', 'info-log.json'); - case 'darwin': - // ~/Library/Application Support/UnityHub/logs/info-log.json - return path.join(process.env.HOME || '', 'Library', 'Application Support', 'UnityHub', 'logs', 'info-log.json'); - case 'linux': - // ~/.config/UnityHub/logs/info-log.json - return path.join(process.env.HOME || '', '.config', 'UnityHub', 'logs', 'info-log.json'); - default: - throw new Error(`Unsupported platform: ${process.platform}`); - } - } - - /** - * Returns the path to the Unity Package Manager log file. - * @see https://docs.unity3d.com/Manual/LogFiles.html - * @returns The Unity Package Manager log file path. - */ - public static PackageManagerLogsPath(): string { - switch (process.platform) { - case 'win32': - // C:\Users\username\AppData\Local\Unity\Editor\upm.log - return path.join(process.env.LOCALAPPDATA || '', 'Unity', 'Editor', 'upm.log'); - case 'darwin': - // ~/Library/Logs/Unity/upm.log - return path.join(process.env.HOME || '', 'Library', 'Logs', 'Unity', 'upm.log'); - case 'linux': - // ~/.config/unity3d/upm.log - return path.join(process.env.HOME || '', '.config', 'unity3d', 'upm.log'); - default: - throw new Error(`Unsupported platform: ${process.platform}`); - } - } -} \ No newline at end of file diff --git a/src/unity-installer.ts b/src/unity-installer.ts new file mode 100644 index 00000000..4a0acaeb --- /dev/null +++ b/src/unity-installer.ts @@ -0,0 +1,806 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { Logger } from './logging'; +import { UnityEditor } from './unity-editor'; +import { UnityVersion } from './unity-version'; +import { FindAbsolutePath, UnityCli } from './unity-cli'; +import { + DownloadFile, + Exec, + GetTempDir, +} from './utilities'; +import { + UnityReleasesClient, + GetUnityReleasesData, + UnityRelease, +} from '@rage-against-the-pixel/unity-releases-api'; + +/** Exit codes the official CLI documents as retryable: network, install locked and timeout. */ +const RETRYABLE_EXIT_CODES = [7, 9, 14]; + +/** + * Packages the Unity Linux Editor needs that Ubuntu runners do not ship. + * Some of them are only in jammy, so its archive is added as a source. + */ +const LINUX_EDITOR_DEPENDENCIES_SCRIPT = `#!/bin/sh +set -e +if [ ! -f /etc/apt/sources.list.d/jammy.list ]; then + echo "deb https://archive.ubuntu.com/ubuntu jammy main universe" > /etc/apt/sources.list.d/jammy.list +fi +apt-get update +apt-get install -y --no-install-recommends xvfb ffmpeg libgtk2.0-0 libglu1-mesa libgconf-2-4 libncurses5 pulseaudio +apt-get clean +`; + +/** Unity 5.x target support installer names, keyed by module id. */ +const ARCHIVE_MODULE_NAMES: { [module: string]: string } = { + 'android': 'Android', + 'ios': 'iOS', + 'appletv': 'AppleTV', + 'webgl': 'WebGL', + 'linux': 'Linux', + 'linux-il2cpp': 'Linux', + 'mac': 'Mac', + 'mac-il2cpp': 'Mac', + 'windows': 'Windows', + 'windows-il2cpp': 'Windows', + 'universal-windows-platform': 'Metro', + 'facebook-games': 'Facebook-Games', + 'samsungtv': 'Samsung-TV', + 'tizen': 'Tizen', +}; + +/** + * Finds or installs Unity Editors. + * - 2017.4 and newer install through the official Unity CLI (`unity install`). + * - 5.x installs from Unity's download archive, because the official CLI reads one-digit majors as Unity 6 shorthand. + * - 4.x installs from Unity's legacy installers. + */ +export class UnityInstaller { + private readonly logger: Logger = Logger.instance; + private cli: UnityCli | undefined; + + /** + * @param cli The official Unity CLI to use. Installed on demand when omitted. + */ + constructor(cli: UnityCli | undefined = undefined) { + this.cli = cli; + } + + /** + * Gets the official Unity CLI, installing it when needed. + */ + public async GetCli(): Promise { + if (!this.cli) { + this.cli = await UnityCli.Get(); + } + + return this.cli; + } + + /** + * Sets the directory editors are installed to. + * @param installPath The directory to install editors to. + */ + public async SetInstallPath(installPath: string): Promise { + const cli = await this.GetCli(); + await cli.SetInstallPath(path.resolve(installPath)); + } + + /** + * Gets the directory editors are installed to. + */ + public async GetInstallPath(): Promise { + const cli = await this.GetCli(); + return await cli.GetInstallPath(); + } + + /** + * Finds the requested Unity Editor, installing it and any missing modules when needed. + * @param unityVersion The Unity version to find or install. Partial versions such as 2021.3.x resolve to the newest matching release. + * @param modules The module ids to install alongside the editor. + * @param channels The release channels to accept when resolving a partial version: f, p, b, a or x. Default is ['f']. + */ + public async GetEditor(unityVersion: UnityVersion, modules: string[] = [], channels: string[] = ['f']): Promise { + this.logger.ci(`Getting release info for Unity ${unityVersion.toString()}...`); + const resolvedVersion = await this.resolveVersion(unityVersion, channels); + let editorPath: string; + + if (resolvedVersion.isLegacy()) { + editorPath = await this.installLegacyEditor(resolvedVersion); + } else if (resolvedVersion.isLessThan('2017.0.0')) { + editorPath = await this.installArchiveEditor(resolvedVersion, modules); + } else { + editorPath = await this.installCliEditor(resolvedVersion, modules); + } + + await fs.promises.access(editorPath, fs.constants.X_OK); + await this.patchBeeBackend(editorPath); + return new UnityEditor(path.normalize(editorPath), resolvedVersion); + } + + /** + * Resolves a partial or changeset-less version to an exact release with its changeset. + * 4.x versions are returned as they are, since no release service lists them. + * @param unityVersion The requested version. + * @param channels The release channels to accept. + */ + public async resolveVersion(unityVersion: UnityVersion, channels: string[] = ['f']): Promise { + if (unityVersion.isLegacy() || (unityVersion.isFullyQualified() && unityVersion.changeset)) { + return unityVersion; + } + + try { + const release = await UnityInstaller.GetEditorReleaseInfo(unityVersion, channels); + return new UnityVersion(release.version, release.shortRevision, unityVersion.architecture); + } catch (error) { + // fail closed for partial versions: never install "6000.6" and hope it picks a release + if (!unityVersion.isFullyQualified()) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to resolve Unity ${unityVersion.toString()} for channel(s) [${channels.join(', ')}]: ${message}`); + } + + this.logger.warn(`Failed to get Unity release info for ${unityVersion.toString()}! falling back to the release notes page...\n${error}`); + + try { + return await UnityInstaller.fallbackVersionLookup(unityVersion); + } catch (fallbackError) { + this.logger.warn(`Failed to lookup changeset for Unity ${unityVersion.toString()}!\n${fallbackError}`); + return unityVersion; + } + } + } + + private async installCliEditor(unityVersion: UnityVersion, modules: string[]): Promise { + const cli = await this.GetCli(); + let editorPath = await this.findCliEditor(cli, unityVersion); + const requestedModules = modules.length > 0 ? await this.filterModules(cli, unityVersion, modules) : []; + + if (!editorPath) { + if (process.platform === 'linux') { + await this.installLinuxDependencies(unityVersion); + } + + this.logger.ci(`Installing Unity ${unityVersion.toString()}...`); + const args = ['install', unityVersion.version]; + + if (unityVersion.changeset) { + args.push('--changeset', unityVersion.changeset); + } + + args.push('--architecture', unityVersion.architecture.toLowerCase()); + + if (requestedModules.length > 0) { + for (const module of requestedModules) { + this.logger.ci(` > with module: ${module}`); + } + + args.push('--module', ...requestedModules, '--child-modules'); + } + + args.push('--accept-eula', '--yes'); + await this.execWithRetry(cli, args); + editorPath = await this.findCliEditor(cli, unityVersion); + + if (!editorPath) { + throw new Error(`Unity ${unityVersion.toString()} was installed, but its editor could not be found`); + } + } else if (requestedModules.length > 0) { + this.logger.ci(`Installing modules for Unity ${unityVersion.toString()}: ${requestedModules.join(', ')}`); + await this.execWithRetry(cli, [ + 'install-modules', + '--editor-version', unityVersion.version, + '--architecture', unityVersion.architecture.toLowerCase(), + '--module', ...requestedModules, + '--child-modules', + '--accept-eula', + '--yes' + ]); + } + + if (unityVersion.isGreaterThanOrEqualTo('6000.0.0')) { + // so the editor opens without the terms dialog + const result = await cli.Run(['terms', 'accept', unityVersion.version, '--architecture', unityVersion.architecture.toLowerCase(), '--yes']); + + if (result.exitCode !== 0) { + this.logger.debug(`unity terms accept exited with code ${result.exitCode}:\n${result.stdout}${result.stderr}`); + } + } + + return editorPath; + } + + private async execWithRetry(cli: UnityCli, args: string[]): Promise { + let result = await cli.Run(args, { stream: true }); + + if (RETRYABLE_EXIT_CODES.includes(result.exitCode)) { + this.logger.warn(`unity ${args[0]} failed with exit code ${result.exitCode}, retrying...`); + result = await cli.Run(args[0] === 'install' ? [...args, '--resume'] : args, { stream: true }); + } + + if (result.exitCode !== 0) { + throw new Error(`unity ${args.join(' ')} failed with exit code ${result.exitCode}`); + } + } + + /** + * Drops modules the release does not offer, since `unity install` fails on an unknown module id. + */ + private async filterModules(cli: UnityCli, unityVersion: UnityVersion, modules: string[]): Promise { + const { exitCode, envelope } = await cli.RunJson<{ modules?: { id: string }[] }>(['editors', 'info', unityVersion.version]); + const available = envelope?.data?.modules?.map(module => module.id); + + if (exitCode !== 0 || !available || available.length === 0) { + this.logger.debug(`Could not read the module list for Unity ${unityVersion.version}, requesting modules as given`); + return [...modules]; + } + + const supported: string[] = []; + + for (const module of modules) { + if (available.includes(module)) { + supported.push(module); + } else { + this.logger.warn(`Unity ${unityVersion.version} has no ${module} module on ${process.platform}, skipping it`); + } + } + + return supported; + } + + /** + * Looks up an installed editor with `unity editors path`, registering one that sits in the install path but that the CLI does not know about yet. + */ + private async findCliEditor(cli: UnityCli, unityVersion: UnityVersion): Promise { + const { exitCode, envelope } = await cli.RunJson(['editors', 'path', unityVersion.version]); + + if (exitCode === 0 && envelope?.success !== false) { + const editorPath = resolveEditorExecutable(FindAbsolutePath(envelope?.data)); + + if (editorPath) { + this.logger.debug(`Found installed editor: "${editorPath}"`); + return editorPath; + } + } + + let installPath: string; + + try { + installPath = await cli.GetInstallPath(); + } catch (error) { + this.logger.debug(`${error}`); + return undefined; + } + + const candidates = [ + path.join(installPath, unityVersion.version), + path.join(installPath, `${unityVersion.version}-${unityVersion.architecture.toLowerCase()}`), + ]; + + for (const candidate of candidates) { + const editorPath = resolveEditorExecutable(candidate); + + if (editorPath) { + this.logger.info(`Registering existing Unity ${unityVersion.version} at ${editorPath}`); + const result = await cli.Run(['editors', 'add', editorPath]); + + if (result.exitCode !== 0) { + this.logger.warn(`unity editors add exited with code ${result.exitCode}`); + } + + return editorPath; + } + } + + return undefined; + } + + private async getLegacyInstallPath(): Promise { + try { + return await this.GetInstallPath(); + } catch (error) { + this.logger.debug(`Falling back to the default editor install path: ${error}`); + + switch (process.platform) { + case 'win32': + return path.join(process.env.ProgramFiles || 'C:\\Program Files', 'Unity', 'Hub', 'Editor'); + case 'darwin': + return '/Applications/Unity/Hub/Editor'; + default: + return path.join(os.homedir(), 'Unity', 'Hub', 'Editor'); + } + } + } + + /** + * Installs Unity 5.x from the download archive. + */ + private async installArchiveEditor(unityVersion: UnityVersion, modules: string[]): Promise { + if (!unityVersion.changeset) { + throw new Error(`Unity ${unityVersion.toString()} needs a changeset to install from the download archive`); + } + + const installPath = await this.getLegacyInstallPath(); + const installDir = path.join(installPath, unityVersion.version); + const baseUrl = `https://download.unity3d.com/download_unity/${unityVersion.changeset}`; + const moduleNames = this.getArchiveModuleNames(unityVersion, modules); + + switch (process.platform) { + case 'win32': { + let editorPath = resolveEditorExecutable(installDir); + + if (!editorPath) { + this.logger.ci(`Installing Unity ${unityVersion.toString()}...`); + await this.runWindowsInstaller(`${baseUrl}/Windows64EditorInstaller/UnitySetup64-${unityVersion.version}.exe`, installDir); + editorPath = resolveEditorExecutable(installDir); + } + + if (!editorPath) { + throw new Error(`Failed to install Unity ${unityVersion.toString()} to ${installDir}`); + } + + for (const moduleName of moduleNames) { + if (fs.existsSync(path.join(installDir, 'Editor', 'Data', 'PlaybackEngines', `${moduleName.toLowerCase()}support`))) { + continue; + } + + this.logger.ci(` > with module: ${moduleName}`); + await this.runWindowsInstaller(`${baseUrl}/TargetSupportInstaller/UnitySetup-${moduleName}-Support-for-Editor-${unityVersion.version}.exe`, installDir); + } + + return editorPath; + } + case 'darwin': { + let editorPath = resolveEditorExecutable(installDir); + + if (editorPath) { + if (moduleNames.length > 0) { + this.logger.warn(`Unity ${unityVersion.version} is already installed; add modules to it with its own installers`); + } + + return editorPath; + } + + this.logger.ci(`Installing Unity ${unityVersion.toString()}...`); + const packages = [ + `${baseUrl}/MacEditorInstaller/Unity-${unityVersion.version}.pkg`, + ...moduleNames.map(name => `${baseUrl}/MacEditorTargetInstaller/UnitySetup-${name}-Support-for-Editor-${unityVersion.version}.pkg`), + ]; + + for (const url of packages) { + const pkgPath = path.join(GetTempDir(), path.basename(url)); + await DownloadFile(url, pkgPath); + + try { + await Exec('sudo', ['installer', '-pkg', pkgPath, '-target', '/'], { silent: true, showCommand: true }); + } finally { + await fs.promises.rm(pkgPath, { force: true }); + } + } + + await this.moveMacApplicationsUnity(installDir); + editorPath = resolveEditorExecutable(installDir); + + if (!editorPath) { + throw new Error(`Failed to install Unity ${unityVersion.toString()} to ${installDir}`); + } + + return editorPath; + } + default: + throw new Error(`Unity ${unityVersion.toString()} is not supported on ${process.platform}`); + } + } + + private getArchiveModuleNames(unityVersion: UnityVersion, modules: string[]): string[] { + const hostModule = process.platform === 'win32' ? 'Windows' : process.platform === 'darwin' ? 'Mac' : 'Linux'; + const names: string[] = []; + + for (const module of modules) { + const name = ARCHIVE_MODULE_NAMES[module]; + + if (!name) { + this.logger.warn(`Unity ${unityVersion.version} has no ${module} module in the download archive, skipping it`); + } else if (name !== hostModule && !names.includes(name)) { + names.push(name); + } + } + + return names; + } + + private async runWindowsInstaller(url: string, installDir: string): Promise { + const installerPath = path.join(GetTempDir(), path.basename(url)); + await DownloadFile(url, installerPath); + this.logger.info(`Running ${path.basename(installerPath)}...`); + + try { + await Exec('powershell', [ + '-NoProfile', + '-Command', + `Start-Process -FilePath "${installerPath}" -ArgumentList "/S /D=${installDir}" -Wait` + ], { silent: true, showCommand: true }); + } finally { + await fs.promises.rm(installerPath, { force: true }); + } + } + + /** + * Moves an editor that a macOS package installed to /Applications/Unity into its versioned folder. + */ + private async moveMacApplicationsUnity(targetPath: string): Promise { + const unityAppPath = path.join('/Applications', 'Unity'); + + if (!fs.existsSync(path.join(unityAppPath, 'Unity.app'))) { + throw new Error('Failed to find /Applications/Unity/Unity.app after installation.'); + } + + this.logger.debug(`Moving ${unityAppPath} to ${targetPath}...`); + await Exec('sudo', ['mkdir', '-p', targetPath], { silent: true, showCommand: true }); + + for (const item of await fs.promises.readdir(unityAppPath)) { + // the editor install path itself lives in /Applications/Unity/Hub + if (item === 'Hub') { + continue; + } + + await Exec('sudo', ['mv', path.join(unityAppPath, item), path.join(targetPath, item)], { silent: true, showCommand: true }); + } + + await Exec('sudo', ['chmod', '-R', '777', targetPath], { silent: true, showCommand: true }); + } + + /** + * Installs Unity 4.x from Unity's legacy installers. + */ + private async installLegacyEditor(unityVersion: UnityVersion): Promise { + const installPath = await this.getLegacyInstallPath(); + const installDir = path.join(installPath, `Unity ${unityVersion.version}`); + let editorPath = resolveEditorExecutable(installDir); + + if (editorPath) { + return editorPath; + } + + this.logger.ci(`Installing Unity ${unityVersion.toString()}...`); + + switch (process.platform) { + case 'win32': { + await this.runWindowsInstaller(`https://beta.unity3d.com/download/UnitySetup-${unityVersion.version}.exe`, installDir); + break; + } + case 'darwin': { + const url = `https://beta.unity3d.com/download/unity-${unityVersion.version}.dmg`; + const installerPath = path.join(GetTempDir(), `UnitySetup-${unityVersion.version}.dmg`); + await DownloadFile(url, installerPath); + let mountPoint = ''; + + try { + const output = await Exec('hdiutil', ['attach', installerPath, '-nobrowse'], { silent: true, showCommand: true }); + const mountPointMatch = output.match(/\/Volumes\/Unity Installer.*$/m); + + if (!mountPointMatch || mountPointMatch.length === 0) { + throw new Error(`Failed to find mount point in hdiutil output: ${output}`); + } + + mountPoint = mountPointMatch[0]; + const pkgPath = path.join(mountPoint, 'Unity.pkg'); + await fs.promises.access(pkgPath, fs.constants.R_OK); + await Exec('sudo', ['installer', '-pkg', pkgPath, '-target', '/', '-verboseR'], { silent: true, showCommand: true }); + await this.moveMacApplicationsUnity(installDir); + } finally { + try { + if (mountPoint.length > 0) { + await Exec('hdiutil', ['detach', mountPoint, '-quiet'], { silent: true, showCommand: true }); + } + } finally { + await fs.promises.rm(installerPath, { force: true }); + } + } + break; + } + default: + throw new Error(`Unity ${unityVersion.toString()} is not supported on ${process.platform}`); + } + + editorPath = resolveEditorExecutable(installDir); + + if (!editorPath) { + throw new Error(`Failed to install Unity ${unityVersion.toString()} to ${installDir}`); + } + + return editorPath; + } + + /** + * Installs the system packages the Linux editor needs, and the older libssl that 2019.1 to 2021.x link against. + */ + private async installLinuxDependencies(unityVersion: UnityVersion): Promise { + if (process.env.UNITY_CLI_LINUX_DEPENDENCIES_INSTALLED !== 'true') { + try { + await Exec('sudo', ['sh', '-c', LINUX_EDITOR_DEPENDENCIES_SCRIPT], { silent: true, showCommand: true }); + process.env.UNITY_CLI_LINUX_DEPENDENCIES_INSTALLED = 'true'; + this.logger.CI_setEnvironmentVariable('UNITY_CLI_LINUX_DEPENDENCIES_INSTALLED', 'true'); + } catch (error) { + this.logger.warn(`Failed to install Linux editor dependencies: ${error}`); + } + } + + const arch = process.arch === 'x64' ? 'amd64' : process.arch; + let debName: string | undefined; + + if (['2019.1', '2019.2'].some(v => unityVersion.version.startsWith(v))) { + debName = `libssl1.0.0_1.0.2g-1ubuntu4.20_${arch}.deb`; + } else if ( + ['2019.3', '2019.4'].some(v => unityVersion.version.startsWith(v)) || + unityVersion.version.startsWith('2020.') || + unityVersion.version.startsWith('2021.') + ) { + debName = `libssl1.1_1.1.0g-2ubuntu4_${arch}.deb`; + } + + if (debName) { + const downloadPath = path.join(GetTempDir(), debName); + await DownloadFile(`https://archive.ubuntu.com/ubuntu/pool/main/o/openssl/${debName}`, downloadPath); + + try { + await Exec('sudo', ['dpkg', '-i', downloadPath]); + } finally { + await fs.promises.rm(downloadPath, { force: true }); + } + } + } + + /** + * Patches the Bee Backend for Unity Linux Editor. + * https://discussions.unity.com/t/linux-editor-stuck-on-loading-because-of-bee-backend-w-workaround/854480 + * @param editorPath The path to the Unity Editor executable. + */ + private async patchBeeBackend(editorPath: string): Promise { + if (process.platform === 'linux') { + const dataPath = path.join(path.dirname(editorPath), 'Data'); + const beeBackend = path.join(dataPath, 'bee_backend'); + const dotBeeBackend = path.join(dataPath, '.bee_backend'); + if (fs.existsSync(beeBackend) && !fs.existsSync(dotBeeBackend)) { + this.logger.ci(`Patching Unity Linux Editor for Bee Backend...`); + await fs.promises.rename(beeBackend, dotBeeBackend); + const wrapperSource: string = `#!/bin/bash +# https://discussions.unity.com/t/linux-editor-stuck-on-loading-because-of-bee-backend-w-workaround/854480 +set -e +args=("$@") +for ((i = 0; i < "\${#args[@]}"; ++i)); do + case \${args[i]} in + --stdin-canary) + unset "args[i]" + break + ;; + esac +done +"$(dirname "$0")/.$(basename "$0")" "\${args[@]}"` + await fs.promises.writeFile(beeBackend, wrapperSource, { encoding: 'utf-8', mode: 0o755 }); + } + } + } + + /** + * Gets the specified Unity release info from the Unity Releases API. + * Supports querying by exact version or by prefix (e.g., "2020", "2020.1", "2021.x", "2021.3.x"). + * @param unityVersion The Unity version to get the release info for. + * @param channels Letter channels to accept (`f`, `p`, `b`, `a`, `x`). Default stable-only. + * @returns The Unity release info. + */ + public static async GetEditorReleaseInfo(unityVersion: UnityVersion, channels: string[] = ['f']): Promise { + // Prefer querying the releases API with the exact fully-qualified Unity version (e.g., 2022.3.10f1). + // If we don't have a fully-qualified version, use the most specific prefix available: + // - "YYYY.M" when provided (e.g., 6000.1) + // - otherwise "YYYY" + const fullUnityVersionPattern = /^\d{1,4}\.\d+\.\d+[abcfpx]\d+$/; + let version: string; + + if (fullUnityVersionPattern.test(unityVersion.version)) { + version = unityVersion.version; + } else { + const match = unityVersion.version.match(/^(\d{1,4})(?:\.(\d+))?/); + + if (match) { + version = match[2] ? `${match[1]}.${match[2]}` : match[1]!; + } else { + version = unityVersion.version.split('.')[0]!; + } + } + + const releasesClient = new UnityReleasesClient(); + const channelSet = new Set(channels.map(c => c.toLowerCase())); + + function getPlatform(): Array<('MAC_OS' | 'LINUX' | 'WINDOWS')> { + switch (process.platform) { + case 'darwin': + return ['MAC_OS']; + case 'linux': + return ['LINUX']; + case 'win32': + return ['WINDOWS']; + default: + throw new Error(`Unsupported platform: ${process.platform}`); + } + } + + function releaseChannelLetter(releaseVersion: string): string | undefined { + const m = /^(\d{1,4})\.(\d+)\.(\d+)([abcfpx])(\d+)$/.exec(releaseVersion); + return m?.[4]; + } + + const request: GetUnityReleasesData = { + url: '/unity/editor/release/v1/releases', + query: { + version: version, + architecture: [unityVersion.architecture], + platform: getPlatform(), + limit: 10, + order: 'RELEASE_DATE_DESC', + } + }; + + Logger.instance.debug(`Get Unity Release: ${JSON.stringify(request, null, 2)}`); + + async function getRelease() { + const { data, error } = await releasesClient.api.Release.getUnityReleases(request); + + if (error) { + throw new Error(`Failed to get Unity releases: ${JSON.stringify(error, null, 2)}`); + } + + if (!data || !data.results || data.results.length === 0) { + throw new Error(`No Unity releases found for version: ${version}`); + } + + const releases = (data.results || []) + .filter((release) => { + const v = release.version; + if (v == null || v === '') { + return false; + } + // Exact FQ request: accept that row regardless of channel filter. + if (fullUnityVersionPattern.test(unityVersion.version) && v === unityVersion.version) { + return true; + } + const letter = releaseChannelLetter(v); + return letter != null && channelSet.has(letter); + }) + .map(release => ({ + unityRelease: release, + unityVersion: new UnityVersion(release.version!, release.shortRevision, unityVersion.architecture) + })); + + if (releases.length === 0) { + const channelList = [...channelSet].join(','); + throw new Error( + `No suitable Unity releases (channels: ${channelList}) found for version: ${version}` + + (channelSet.has('f') && channelSet.size === 1 + ? `. No stable (f) release for ${version}; use --channel b/a or a fully-qualified version.` + : '') + ); + } + + releases.sort((a, b) => UnityVersion.compare(b.unityVersion, a.unityVersion)); + + Logger.instance.debug(`Found ${releases.length} matching Unity releases for version: ${version}`); + releases.forEach(release => { + Logger.instance.debug(` - ${release.unityRelease.version} (${release.unityRelease.shortRevision}) - ${release.unityRelease.recommended}`); + }); + return releases[0]!.unityRelease!; + } + + try { + return await getRelease(); + } catch (error) { + if (error instanceof Error && error.message.includes('fetch failed')) { + // Transient network error, retry once + return await getRelease(); + } + + throw new Error(`Failed to get Unity releases: ${error}`); + } + } + + private static async fallbackVersionLookup(unityVersion: UnityVersion): Promise { + const url = `https://unity.com/releases/editor/whats-new/${unityVersion.version}`; + Logger.instance.debug(`Fetching release page: "${url}"`); + const response = await fetch(url); + const responseText = await response.text(); + + if (!response.ok) { + throw new Error(`Failed to fetch changeset for Unity ${unityVersion.toString()} [${response.status}] "${url}"`); + } + + const match = responseText.match(/unityhub:\/\/(?\d+\.\d+\.\d+[abcfpx]?\d*)\/(?[a-zA-Z0-9]+)/); + + if (match && match.groups && match.groups.version && match.groups.changeset) { + return new UnityVersion(match.groups.version, match.groups.changeset, unityVersion.architecture); + } + + throw new Error(`Failed to find changeset for Unity ${unityVersion.toString()} in "${url}"`); + } + + /** + * Get the mapping of Unity platform targets to their corresponding module identifiers for the current OS. + * @returns A map of Unity platform targets to their corresponding module identifiers for the current OS. + */ + public static GetPlatformTargetModuleMap(): { [key: string]: string } { + const osType = os.type(); + + switch (osType) { + case 'Linux': + return { + "StandaloneLinux64": "linux-il2cpp", + "Android": "android", + "WebGL": "webgl", + "iOS": "ios", + }; + case 'Darwin': + return { + "StandaloneOSX": "mac-il2cpp", + "iOS": "ios", + "Android": "android", + "tvOS": "appletv", + "StandaloneLinux64": "linux-il2cpp", + "WebGL": "webgl", + "VisionOS": "visionos" + }; + case 'Windows_NT': + return { + "StandaloneWindows64": "windows-il2cpp", + "WSAPlayer": "universal-windows-platform", + "Android": "android", + "iOS": "ios", + "tvOS": "appletv", + "StandaloneLinux64": "linux-il2cpp", + "Lumin": "lumin", + "WebGL": "webgl", + }; + default: + throw Error(`${osType} not supported`); + } + } +} + +/** + * Resolves an install directory, Editor folder, app bundle or executable to the editor executable. + * @param location A path the editor may be under. + * @returns The editor executable, or undefined when none is found. + */ +export function resolveEditorExecutable(location: string | undefined): string | undefined { + if (!location || !fs.existsSync(location)) { + return undefined; + } + + if (fs.statSync(location).isFile()) { + return location; + } + + let candidates: string[]; + + switch (process.platform) { + case 'darwin': + candidates = [ + path.join(location, 'Unity.app', 'Contents', 'MacOS', 'Unity'), + path.join(location, 'Contents', 'MacOS', 'Unity'), + ]; + break; + case 'win32': + candidates = [ + path.join(location, 'Editor', 'Unity.exe'), + path.join(location, 'Unity.exe'), + ]; + break; + default: + candidates = [ + path.join(location, 'Editor', 'Unity'), + path.join(location, 'Unity'), + ]; + break; + } + + return candidates.find(candidate => fs.existsSync(candidate) && fs.statSync(candidate).isFile()); +} diff --git a/src/upm-cli.ts b/src/upm-cli.ts deleted file mode 100644 index 654fb0e2..00000000 --- a/src/upm-cli.ts +++ /dev/null @@ -1,531 +0,0 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { - SemVer, - coerce, - compare, - parse, - valid, -} from 'semver'; -import { - Logger, - LogLevel -} from './logging'; -import { - DeleteDirectory, - DownloadFile, - Exec, - ExecOptions, - extractZipNative, - GetTempDir, - HttpsGetText, - isInteractiveTerminalSession, - PromptYesNo, - Sha256FileHex, -} from './utilities'; - -export interface EnsureUpmInstalledOptions { - version?: string; - skipIfInstalled?: boolean; -} - -/** Arguments for {@link UpmCli.Pack} (mapped to `UnityPackageManager pack …`). */ -export interface UpmPackOptions { - /** Unity Cloud organization id. */ - organizationId: string; - /** Output path for the packed artifact. */ - destination?: string; - /** Folder containing `package.json` to pack; when omitted the child process cwd applies. */ - packageDirectory?: string; -} - -/** - * Managed Unity Package Manager CLI (unity-cli–installed `UnityPackageManager`), modeled after {@link UnityHub}: - * parameterless constructor resolves roots and executable preference, {@link Install} manages downloads, {@link Exec} runs the binary. - */ -export class UpmCli { - /** Root directory for managed installs (~/.unity-cli/upm), analogous to {@link UnityHub.rootDirectory}. */ - public readonly managedRoot: string; - - private readonly logger: Logger = Logger.instance; - - constructor() { - this.managedRoot = path.join(os.homedir(), '.unity-cli', 'upm'); - } - - private static getCdnBaseUrl(): string { - const override = process.env.UPM_CDN_BASE_URL?.trim(); - if (override && override.length > 0) { - return `${override.replace(/\/$/, '')}/upm-cli`; - } - return 'https://cdn.packages.unity.com/upm-cli'; - } - - /** - * HTTPS URL under the UPM CLI CDN for a release file. Caller must validate `tag` (e.g. {@link UpmCli.validateVersionFormat}); - * path segments are encoded to avoid tainted file-derived strings reaching the network unchecked (CodeQL js/file-access-to-http). - */ - private static buildUpmReleaseAssetUrl(cdnBase: string, tag: string, fileName: string): string { - const root = new URL(`${cdnBase.replace(/\/$/, '')}/`); - return new URL(`releases/${encodeURIComponent(tag)}/${encodeURIComponent(fileName)}`, root).href; - } - - private static buildUpmLatestTxtUrl(cdnBase: string): string { - return new URL('latest.txt', new URL(`${cdnBase.replace(/\/$/, '')}/`)).href; - } - - private static normalizeSemver(version: string): string | undefined { - const normalized = valid(version); - if (normalized) { - return normalized; - } - const coerced = coerce(version); - return coerced?.version; - } - - private static parseVerifiedSemVerFromLine(line: string): SemVer | null { - const t = line.trim(); - if (!t) { - return null; - } - const direct = valid(t); - if (direct) { - const parsed = parse(direct, false); - if (parsed && valid(parsed.version)) { - return parsed; - } - } - const coerced = coerce(t); - if (coerced && valid(coerced)) { - return coerced; - } - return null; - } - - private static parseCliVersionStdout(output: string): SemVer { - const trimmed = output.trim(); - if (!trimmed) { - throw new Error('Upm cli --version produced empty output.'); - } - const lines = trimmed.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0); - for (let i = lines.length - 1; i >= 0; i--) { - const version = UpmCli.parseVerifiedSemVerFromLine(lines[i]!); - if (version) { - return version; - } - } - const fallback = UpmCli.parseVerifiedSemVerFromLine(trimmed); - if (fallback) { - return fallback; - } - throw new Error(`Failed to parse upm cli version: ${JSON.stringify(trimmed)}`); - } - - private getVersionInstallDir(version: string): string { - const t = version.trim(); - this.validateVersionFormat(t); - if (t.includes('..') || path.normalize(t) !== t) { - throw new Error(`Invalid upm cli release tag for path use: ${version}`); - } - const dir = path.join(this.managedRoot, t); - const resolvedDir = path.resolve(dir); - const resolvedRoot = path.resolve(this.managedRoot); - const rootPrefix = resolvedRoot.endsWith(path.sep) ? resolvedRoot : `${resolvedRoot}${path.sep}`; - if (resolvedDir !== resolvedRoot && !resolvedDir.startsWith(rootPrefix)) { - throw new Error('Resolved UPM install directory left managed root.'); - } - return dir; - } - - private getCurrentVersionFilePath(): string { - return path.join(this.managedRoot, 'current-version.txt'); - } - - private getPlatformId(): string { - const plat = process.platform; - const arch = process.arch; - - if (plat === 'win32') { - if (arch === 'arm64') { - return 'windows-arm64'; - } - return 'windows-x64'; - } - - if (plat === 'darwin') { - if (arch === 'arm64') { - return 'macos-arm64'; - } - return 'macos-x64'; - } - - if (plat === 'linux') { - if (arch === 'arm64') { - return 'linux-arm64'; - } - return 'linux-x64'; - } - - throw new Error(`Unsupported platform for upm cli: ${plat} ${arch}`); - } - - private validateVersionFormat(version: string): void { - const t = version.trim(); - if (!t.startsWith('v') || !valid(t)) { - throw new Error(`Invalid upm cli version format: ${version}. Expected a semver release tag with leading v (e.g. v9.27.0).`); - } - } - - private findPrimaryExecutable(installDir: string): string { - if (process.platform === 'win32') { - const exe = path.join(installDir, 'UnityPackageManager.exe'); - if (fs.existsSync(exe)) { - return exe; - } - } else { - const bin = path.join(installDir, 'UnityPackageManager'); - if (fs.existsSync(bin)) { - return bin; - } - } - throw new Error(`Could not find UnityPackageManager binary under ${installDir}`); - } - - /** Optional executable override (mirrors `UNITY_HUB_PATH` for {@link UnityHub}). */ - private getExecutablePathOverride(): string | undefined { - const p = process.env.UPM_CLI_PATH?.trim(); - return p && p.length > 0 ? path.normalize(p) : undefined; - } - - private executableOverrideIsUsable(): boolean { - const p = this.getExecutablePathOverride(); - if (!p) { - return false; - } - try { - fs.accessSync(p, fs.constants.R_OK | fs.constants.X_OK); - return true; - } catch { - return false; - } - } - - /** - * Release tag of the managed install from `current-version.txt`, if present and valid. - */ - public GetInstalledReleaseTag(): string | undefined { - const currentFile = this.getCurrentVersionFilePath(); - if (!fs.existsSync(currentFile)) { - return undefined; - } - try { - const version = fs.readFileSync(currentFile, 'utf8').trim(); - if (!version) { - return undefined; - } - this.validateVersionFormat(version); - return version; - } catch { - return undefined; - } - } - - /** - * Path to the primary `UnityPackageManager` binary for a managed release, or `undefined` if missing. - */ - public ResolveManagedPrimaryPath(version?: string): string | undefined { - let v = version?.trim() || this.GetInstalledReleaseTag(); - - if (!v) { - return undefined; - } - - const installDir = this.getVersionInstallDir(v); - try { - return this.findPrimaryExecutable(installDir); - } catch { - return undefined; - } - } - - /** - * Resolved path used to spawn the UPM CLI: `UPM_CLI_PATH` override when set, otherwise the managed primary binary. - * @throws If nothing usable is installed (mirrors Hub/Editor behavior when the executable cannot be used). - */ - public GetExecutablePath(): string { - const overridePath = this.getExecutablePathOverride(); - if (overridePath) { - fs.accessSync(overridePath, fs.constants.R_OK | fs.constants.X_OK); - return overridePath; - } - const managed = this.ResolveManagedPrimaryPath(); - if (!managed) { - throw new Error('Upm cli is not installed. Run `unity-cli upm-install` first.'); - } - fs.accessSync(managed, fs.constants.R_OK | fs.constants.X_OK); - return managed; - } - - /** Same role as {@link UnityHub.executable}: path used to spawn the UPM CLI (may reflect `UPM_CLI_PATH` or the managed install). */ - public get executable(): string { - return this.GetExecutablePath(); - } - - public async GetLatestReleaseTag(): Promise { - const cdn = UpmCli.getCdnBaseUrl(); - const latestUrl = UpmCli.buildUpmLatestTxtUrl(cdn); - const version = (await HttpsGetText(latestUrl)).trim(); - this.validateVersionFormat(version); - return version; - } - - /** True if `latestTag` is newer than the installed managed release, or nothing is installed yet. */ - public IsUpdateAvailable(latestTag: string): boolean { - const current = this.GetInstalledReleaseTag(); - if (!current) { - return true; - } - const normalizedCurrent = UpmCli.normalizeSemver(current); - const normalizedLatest = UpmCli.normalizeSemver(latestTag); - if (normalizedCurrent && normalizedLatest) { - return compare(normalizedLatest, normalizedCurrent) > 0; - } - return latestTag.trim() !== current.trim(); - } - - /** - * Installs or updates the managed UPM CLI (mirrors {@link UnityHub.Install} for the Hub itself). - * @returns Installed release tag (e.g. v9.27.0). - */ - public async Install(options?: EnsureUpmInstalledOptions): Promise { - const cdn = UpmCli.getCdnBaseUrl(); - let version = options?.version?.trim(); - - if (!version || version.length === 0) { - version = await this.GetLatestReleaseTag(); - } - - version = version.trim(); - - const installDir = this.getVersionInstallDir(version); - const markerPath = path.join(installDir, '.unity-cli-upm-installed'); - - if (options?.skipIfInstalled !== false && fs.existsSync(markerPath)) { - try { - this.findPrimaryExecutable(installDir); - const recordedTag = path.basename(installDir); - await fs.promises.writeFile(this.getCurrentVersionFilePath(), `${recordedTag}\n`, 'utf8'); - return version; - } catch { - // reinstall - } - } - - const platform = this.getPlatformId(); - const zipName = `upm-${platform}.zip`; - const zipUrl = UpmCli.buildUpmReleaseAssetUrl(cdn, version, zipName); - const checksumUrl = UpmCli.buildUpmReleaseAssetUrl(cdn, version, `${zipName}.sha256`); - - const tempRoot = path.join(GetTempDir(), `unity-cli-upm-${Date.now()}`); - const resolvedTempRoot = path.resolve(tempRoot); - const zipPath = path.join(resolvedTempRoot, zipName); - const checksumPath = path.join(resolvedTempRoot, `${zipName}.sha256`); - - try { - this.logger.info(`Installing upm cli ${version} (${platform})...`); - await DownloadFile(zipUrl, zipPath); - await DownloadFile(checksumUrl, checksumPath); - - const checksumContent = (await fs.promises.readFile(checksumPath, 'utf8')).trim(); - const expectedHash = checksumContent.split(/\s+/)[0]?.toLowerCase(); - if (!expectedHash) { - throw new Error(`Could not read SHA-256 from ${checksumPath}`); - } - - const actualHash = (await Sha256FileHex(zipPath)).toLowerCase(); - if (actualHash !== expectedHash) { - throw new Error(`SHA-256 mismatch for upm cli zip. Expected ${expectedHash}, got ${actualHash}`); - } - - await DeleteDirectory(installDir); - await fs.promises.mkdir(installDir, { recursive: true }); - await extractZipNative(zipPath, installDir, { - zipUnder: resolvedTempRoot, - destUnder: path.resolve(this.managedRoot), - }, { - silent: false, - showCommand: this.logger.logLevel === LogLevel.DEBUG - }); - - const primary = this.findPrimaryExecutable(installDir); - if (process.platform !== 'win32') { - try { - fs.chmodSync(primary, 0o755); - } catch { - // ignore - } - } - - const wrapperUnix = path.join(installDir, 'upm'); - if (process.platform !== 'win32' && fs.existsSync(wrapperUnix)) { - try { - fs.chmodSync(wrapperUnix, 0o755); - } catch { - // ignore - } - } - - await fs.promises.writeFile(markerPath, `${new Date().toISOString()}\n`, 'utf8'); - const recordedTag = path.basename(installDir); - await fs.promises.writeFile(this.getCurrentVersionFilePath(), `${recordedTag}\n`, 'utf8'); - - return version; - } finally { - await DeleteDirectory(tempRoot); - } - } - - /** - * When running in an interactive terminal, may prompt to install a missing UPM CLI or update to the latest CDN release. - * When not interactive, logs a warning if the running binary is older than the CDN latest (no install). - * Compares the running binary ({@link Version}) to {@link GetLatestReleaseTag} (including when {@code UPM_CLI_PATH} overrides the managed install). - * Call before {@link GetExecutablePath} / {@link Exec} for Hub-style optional install/update (e.g. pack). - */ - public async PromptInstallOrUpdateWhenInteractive(): Promise { - const overrideUsable = this.executableOverrideIsUsable(); - const managedExe = this.ResolveManagedPrimaryPath(); - const hasExecutable = overrideUsable || managedExe !== undefined; - - if (!hasExecutable) { - if (isInteractiveTerminalSession()) { - const install = await PromptYesNo( - 'The upm cli is not installed. Download and install it now?', - true - ); - if (install) { - await this.Install({ skipIfInstalled: false }); - } - } - return; - } - - try { - const latestTag = await this.GetLatestReleaseTag(); - const latestSem = UpmCli.parseVerifiedSemVerFromLine(latestTag); - if (!latestSem) { - return; - } - - const installedSem = await this.Version(); - if (compare(latestSem, installedSem) <= 0) { - return; - } - - const usingOverride = overrideUsable; - - if (!isInteractiveTerminalSession()) { - if (usingOverride) { - this.logger.warn( - `The upm cli (UPM_CLI_PATH) reports ${installedSem.version}, but ${latestTag} is available on the CDN. This run still uses UPM_CLI_PATH; update that binary or unset it and run unity-cli upm-install to use the managed release.`, - ); - } else { - this.logger.warn( - `The upm cli (${installedSem.version}) is older than the latest release (${latestTag}). Run unity-cli upm-install or unity-cli upm-install --auto-update to update.`, - ); - } - return; - } - - const prompt = usingOverride - ? `Your upm cli (UPM_CLI_PATH) reports ${installedSem.version}, but ${latestTag} is available. Install the latest to the managed location now? This run will keep using UPM_CLI_PATH until you unset it or point it at the new binary.` - : `A newer upm cli version is available (${installedSem.version} -> ${latestTag}). Install it now?`; - - const shouldUpdate = await PromptYesNo(prompt, !usingOverride); - if (!shouldUpdate) { - return; - } - - await this.Install({ - version: latestTag, - skipIfInstalled: false, - }); - - if (usingOverride) { - this.logger.warn( - `Installed upm cli ${latestTag} under ${this.managedRoot}. Unset UPM_CLI_PATH (or update it) so subsequent commands use the new install.`, - ); - } - } catch (error) { - this.logger.debug(`Failed to check for upm cli updates: ${error}`); - } - } - - /** - * Executes the UPM CLI with the given arguments (mirrors {@link UnityHub.Exec}). - */ - public async Exec(args: string[], options: ExecOptions = { silent: this.logger.logLevel > LogLevel.CI, showCommand: this.logger.logLevel <= LogLevel.CI }): Promise { - const exe = this.GetExecutablePath(); - if (exe.includes(path.sep)) { - fs.accessSync(exe, fs.constants.R_OK | fs.constants.X_OK); - } - return Exec(exe, args, options); - } - - /** - * Runs `--version` and returns the verified semver from the binary. - * @param expectedReleaseTag When set (e.g. from {@link Install}), ensures the reported semver matches this CDN release tag. - */ - public async Version(expectedReleaseTag?: string): Promise { - const raw = await this.Exec(['--version'], { - silent: true, - showCommand: this.logger.logLevel === LogLevel.DEBUG, - }); - const version = UpmCli.parseCliVersionStdout(raw); - if (expectedReleaseTag !== undefined && expectedReleaseTag.trim().length > 0) { - const tag = expectedReleaseTag.trim(); - const expected = UpmCli.parseVerifiedSemVerFromLine(tag); - if (!expected) { - throw new Error(`Invalid installed upm cli release tag: ${expectedReleaseTag}`); - } - if (compare(version, expected) !== 0) { - throw new Error( - `Upm cli binary version mismatch: binary reported ${version.version} (--version), expected ${expected.version} (${expectedReleaseTag}).` - ); - } - } - return version; - } - - /** - * Runs the UPM CLI `pack` subcommand (builds argv from {@link UpmPackOptions}, then {@link Exec}). - */ - public async Pack(options: UpmPackOptions, execOptions?: ExecOptions): Promise { - const orgId = options.organizationId.trim(); - - if (!orgId) { - throw new Error('UpmCli.Pack requires a non-empty organizationId.'); - } - - const args: string[] = []; - - if (this.logger.logLevel === LogLevel.DEBUG) { - args.push('--log-level', '5', '--console-log-level', '5'); - } - - args.push('pack', '--organization-id', orgId); - const dest = options.destination?.trim(); - - if (dest && dest.length > 0) { - args.push('--destination', dest); - } - - const dir = options.packageDirectory?.trim(); - - if (dir && dir.length > 0) { - args.push(dir); - } - - return this.Exec(args, execOptions); - } -} diff --git a/tests/hub-cdn-urls.test.ts b/tests/hub-cdn-urls.test.ts deleted file mode 100644 index f04b5785..00000000 --- a/tests/hub-cdn-urls.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Locks Hub CDN URL contracts against Unity's public CDN (HEAD, short timeouts). - * Fails if Unity removes or reshuffles artifacts we rely on. - */ -jest.setTimeout(90_000); - -async function httpStatus(url: string, method: 'HEAD' | 'GET' = 'HEAD'): Promise { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 60_000); - try { - const res = await fetch(url, { method, redirect: 'follow', signal: controller.signal }); - return res.status; - } finally { - clearTimeout(timer); - } -} - -describe('Unity Hub public CDN URLs', () => { - it('serves prod Windows arch-specific installers (not legacy UnityHubSetup.exe)', async () => { - expect(await httpStatus('https://public-cdn.cloud.unity3d.com/hub/prod/UnityHubSetup-x64.exe')).toBe(200); - expect(await httpStatus('https://public-cdn.cloud.unity3d.com/hub/prod/UnityHubSetup-arm64.exe')).toBe(200); - expect(await httpStatus('https://public-cdn.cloud.unity3d.com/hub/prod/UnityHubSetup.exe')).toBe(404); - }); - - it('serves pinned Hub semver Windows layout (single UnityHubSetup.exe)', async () => { - expect(await httpStatus('https://public-cdn.cloud.unity3d.com/hub/3.12.0/UnityHubSetup.exe')).toBe(200); - expect(await httpStatus('https://public-cdn.cloud.unity3d.com/hub/3.12.0/UnityHubSetup-x64.exe')).toBe(404); - expect(await httpStatus('https://public-cdn.cloud.unity3d.com/hub/3.12.0/UnityHubSetup-arm64.exe')).toBe(404); - }); - - it('serves prod and pinned Hub macOS arm64 dmgs (installer path used by unity-cli)', async () => { - expect(await httpStatus('https://public-cdn.cloud.unity3d.com/hub/prod/UnityHubSetup-arm64.dmg')).toBe(200); - expect(await httpStatus('https://public-cdn.cloud.unity3d.com/hub/3.12.0/UnityHubSetup-arm64.dmg')).toBe(200); - }); - - it('serves latest.yml for Hub version discovery (latest-linux.yml is not published)', async () => { - expect(await httpStatus('https://public-cdn.cloud.unity3d.com/hub/prod/latest.yml')).toBe(200); - expect(await httpStatus('https://public-cdn.cloud.unity3d.com/hub/prod/latest-mac.yml')).toBe(200); - expect(await httpStatus('https://public-cdn.cloud.unity3d.com/hub/prod/latest-linux.yml')).toBe(404); - }); -}); diff --git a/tests/linux-hub-path.test.ts b/tests/linux-hub-path.test.ts deleted file mode 100644 index 68a95ebc..00000000 --- a/tests/linux-hub-path.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { - LINUX_HUB_EXECUTABLE_LEGACY, - LINUX_HUB_EXECUTABLE_MODERN, - resolveLinuxHubExecutable, -} from '../src/unity-hub'; - -describe('resolveLinuxHubExecutable', () => { - it('prefers UNITY_HUB_PATH when set', () => { - const exists = jest.fn(() => true); - expect(resolveLinuxHubExecutable('/custom/unityhub', exists)).toBe('/custom/unityhub'); - expect(exists).not.toHaveBeenCalled(); - }); - - it('ignores empty UNITY_HUB_PATH and uses filesystem candidates', () => { - const exists = (p: string) => p === LINUX_HUB_EXECUTABLE_LEGACY; - expect(resolveLinuxHubExecutable('', exists)).toBe(LINUX_HUB_EXECUTABLE_LEGACY); - expect(resolveLinuxHubExecutable(undefined, exists)).toBe(LINUX_HUB_EXECUTABLE_LEGACY); - }); - - it('prefers Hub 3.20+ /usr/lib layout over legacy /opt', () => { - const exists = (p: string) => - p === LINUX_HUB_EXECUTABLE_MODERN || p === LINUX_HUB_EXECUTABLE_LEGACY; - expect(resolveLinuxHubExecutable(undefined, exists)).toBe(LINUX_HUB_EXECUTABLE_MODERN); - }); - - it('falls back to legacy /opt when modern path is missing', () => { - const exists = (p: string) => p === LINUX_HUB_EXECUTABLE_LEGACY; - expect(resolveLinuxHubExecutable(undefined, exists)).toBe(LINUX_HUB_EXECUTABLE_LEGACY); - }); - - it('defaults to modern path when neither layout is installed', () => { - expect(resolveLinuxHubExecutable(undefined, () => false)).toBe(LINUX_HUB_EXECUTABLE_MODERN); - }); -}); diff --git a/tests/mocks/electron-asar.js b/tests/mocks/electron-asar.js deleted file mode 100644 index ac4b15b7..00000000 --- a/tests/mocks/electron-asar.js +++ /dev/null @@ -1,10 +0,0 @@ -// Minimal mock for @electron/asar used in tests -module.exports = { - extractFile: (asarPath, file) => { - if (file === 'package.json') { - return Buffer.from(JSON.stringify({ version: '1.0.0' })); - } - return Buffer.from(''); - }, - uncacheAll: () => { }, -}; diff --git a/tests/unity-cli.test.ts b/tests/unity-cli.test.ts new file mode 100644 index 00000000..ee9c4c9d --- /dev/null +++ b/tests/unity-cli.test.ts @@ -0,0 +1,166 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { FindAbsolutePath, UnityCli } from '../src/unity-cli'; +import { resolveEditorExecutable } from '../src/unity-installer'; +import { + ActivateLicense, + GetLegacyLicenseVersion, + LicenseType, + LicensingClient, + ReturnLicense, +} from '../src/license-client'; + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('FindAbsolutePath', () => { + const absolute = process.platform === 'win32' ? 'C:\\Unity\\6000.0.0f1' : '/opt/unity/6000.0.0f1'; + + it('reads a bare path', () => { + expect(FindAbsolutePath(absolute)).toBe(absolute); + }); + + it('prefers well-known keys', () => { + expect(FindAbsolutePath({ name: 'x', path: absolute })).toBe(absolute); + }); + + it('ignores relative paths and errors', () => { + expect(FindAbsolutePath({ path: 'relative/path', errors: [{ message: absolute }] })).toBeUndefined(); + }); +}); + +describe('resolveEditorExecutable', () => { + it('finds the editor below an install directory', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'unity-cli-resolve-')); + + try { + const editorPath = process.platform === 'darwin' + ? path.join(root, 'Unity.app', 'Contents', 'MacOS', 'Unity') + : path.join(root, 'Editor', process.platform === 'win32' ? 'Unity.exe' : 'Unity'); + fs.mkdirSync(path.dirname(editorPath), { recursive: true }); + fs.writeFileSync(editorPath, ''); + expect(resolveEditorExecutable(root)).toBe(editorPath); + expect(resolveEditorExecutable(path.join(root, 'missing'))).toBeUndefined(); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); + +describe('GetLegacyLicenseVersion', () => { + it('detects 4.x and 5.x editors', () => { + expect(GetLegacyLicenseVersion('/Applications/Unity/Hub/Editor/Unity 4.7.2/Unity.app/Contents/MacOS/Unity')).toBe('4.x'); + expect(GetLegacyLicenseVersion('C:\\Program Files\\Unity\\Hub\\Editor\\5.6.7f1\\Editor\\Unity.exe')).toBe('5.x'); + }); + + it('returns undefined for newer editors or no editor', () => { + expect(GetLegacyLicenseVersion('/opt/unity/6000.0.0f1/Editor/Unity')).toBeUndefined(); + expect(GetLegacyLicenseVersion(undefined)).toBeUndefined(); + }); +}); + +describe('license activation order', () => { + const originalEditorPath = process.env.UNITY_EDITOR_PATH; + + beforeEach(() => { + delete process.env.UNITY_EDITOR_PATH; + }); + + afterAll(() => { + if (originalEditorPath === undefined) { + delete process.env.UNITY_EDITOR_PATH; + } else { + process.env.UNITY_EDITOR_PATH = originalEditorPath; + } + }); + + const mockCli = (results: { [command: string]: { exitCode: number; data?: unknown } }) => { + const cli = new UnityCli('unity', '1.0.0-beta.14'); + const calls: string[][] = []; + jest.spyOn(cli, 'RunJson').mockImplementation(async (args: string[]) => { + calls.push(args); + const key = args.slice(0, 2).join(' '); + const result = results[key] ?? { exitCode: 0, data: null }; + return { + exitCode: result.exitCode, + envelope: { success: result.exitCode === 0, data: result.data as any, errors: [] }, + stdout: '', + stderr: '', + }; + }); + jest.spyOn(UnityCli, 'Get').mockResolvedValue(cli); + return calls; + }; + + it('uses the Unity CLI when it activates the license', async () => { + const calls = mockCli({ + 'license activate': { exitCode: 0 }, + 'license status': { exitCode: 0, data: { active: true } }, + }); + const fallback = jest.spyOn(LicensingClient.prototype, 'Activate'); + + await ActivateLicense({ licenseType: LicenseType.personal, username: 'user@example.com', password: 'secret' }); + + expect(calls[0]).toEqual(['license', 'activate', '--personal', '--accept-eula']); + expect(fallback).not.toHaveBeenCalled(); + }); + + it('falls back to the licensing client when the Unity CLI fails', async () => { + mockCli({ 'license activate': { exitCode: 3 } }); + const fallback = jest.spyOn(LicensingClient.prototype, 'Activate').mockResolvedValue(undefined); + + await ActivateLicense({ licenseType: LicenseType.personal, username: 'user@example.com', password: 'secret' }); + + expect(fallback).toHaveBeenCalledTimes(1); + }); + + it('falls back when the Unity CLI reports no active license', async () => { + mockCli({ + 'license activate': { exitCode: 0 }, + 'license status': { exitCode: 4, data: { active: false } }, + }); + const fallback = jest.spyOn(LicensingClient.prototype, 'Activate').mockResolvedValue(undefined); + + await ActivateLicense({ licenseType: LicenseType.personal, username: 'user@example.com', password: 'secret' }); + + expect(fallback).toHaveBeenCalledTimes(1); + }); + + it('uses the licensing client directly for 5.x editors', async () => { + process.env.UNITY_EDITOR_PATH = 'C:\\Unity\\5.6.7f1\\Editor\\Unity.exe'; + const calls = mockCli({}); + const fallback = jest.spyOn(LicensingClient.prototype, 'Activate').mockResolvedValue(undefined); + + await ActivateLicense({ licenseType: LicenseType.personal, username: 'user@example.com', password: 'secret' }); + + expect(calls).toHaveLength(0); + expect(fallback).toHaveBeenCalledTimes(1); + }); + + it('returns through the Unity CLI and verifies nothing is still active', async () => { + const calls = mockCli({ + 'license return': { exitCode: 0 }, + 'license status': { exitCode: 4, data: { active: false } }, + }); + const fallback = jest.spyOn(LicensingClient.prototype, 'Deactivate'); + + await ReturnLicense(LicenseType.personal); + + expect(calls[0]).toEqual(['license', 'return', '--yes']); + expect(fallback).not.toHaveBeenCalled(); + }); + + it('returns through the licensing client when a license is still active', async () => { + mockCli({ + 'license return': { exitCode: 0 }, + 'license status': { exitCode: 0, data: { active: true } }, + }); + const fallback = jest.spyOn(LicensingClient.prototype, 'Deactivate').mockResolvedValue(undefined); + + await ReturnLicense(LicenseType.personal); + + expect(fallback).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unity-editor.test.ts b/tests/unity-editor.test.ts index 1bb2ed83..abbf5f44 100644 --- a/tests/unity-editor.test.ts +++ b/tests/unity-editor.test.ts @@ -1,158 +1,114 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; import { UnityEditor } from '../src/unity-editor'; -import { UnityHub } from '../src/unity-hub'; - -jest.setTimeout(30000); // UnityHub operations can be slow +import { UnityVersion } from '../src/unity-version'; describe('UnityEditor', () => { - const editors: UnityEditor[] = []; - - beforeAll(async () => { - const unityHub = new UnityHub(); - editors.push(...await unityHub.ListInstalledEditors()); + let tempRoot: string; + let testEditor: UnityEditor; + + beforeAll(() => { + // a fake editor executable is enough for the pure helpers under test + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'unity-cli-editor-')); + const editorPath = process.platform === 'darwin' + ? path.join(tempRoot, '6000.0.0f1', 'Unity.app', 'Contents', 'MacOS', 'Unity') + : path.join(tempRoot, '6000.0.0f1', 'Editor', process.platform === 'win32' ? 'Unity.exe' : 'Unity'); + fs.mkdirSync(path.dirname(editorPath), { recursive: true }); + fs.writeFileSync(editorPath, '', { mode: 0o755 }); + testEditor = new UnityEditor(editorPath, new UnityVersion('6000.0.0f1')); }); - it('lists available templates for each installed Unity editor', async () => { - expect(editors).toBeDefined(); - expect(Array.isArray(editors)).toBe(true); + afterAll(() => { + fs.rmSync(tempRoot, { recursive: true, force: true }); + }); - for (const editor of editors) { - const templates = editor.GetAvailableTemplates(); - expect(templates).toBeDefined(); - expect(Array.isArray(templates)).toBe(true); - } + describe('GetUnityRunArgs', () => { + it('lets unity run own -batchmode, -projectPath and -logFile', () => { + const args = UnityEditor.GetUnityRunArgs('/editor/Unity', '/project', '/logs/run.log', [ + '-projectPath', '/project', + '-logFile', '/logs/run.log', + '-automated', + '-batchmode', + '-nographics', + '-quit', + '-executeMethod', 'Build.Run', + ]); + + expect(args).toEqual([ + 'run', '/project', + '--editor-path', '/editor/Unity', + '--log-file', '/logs/run.log', + '--no-tail', + '--', + '-automated', + '-nographics', + '-quit', + '-executeMethod', 'Build.Run', + ]); + }); }); - it('gets a single template by regex for each installed Unity editor', async () => { - const pattern: string = 'com.unity.template.3d(-cross-platform)?'; + describe('scrubSensitiveArgs', () => { + it('should redact -username value', () => { + const args = ['-batchmode', '-username', 'test@example.com', '-quit']; + const scrubbedArgs = testEditor.scrubSensitiveArgs(args); + + expect(scrubbedArgs).toContain('-username'); + expect(scrubbedArgs).toContain('[REDACTED]'); + expect(scrubbedArgs).not.toContain('test@example.com'); + expect(scrubbedArgs).toContain('-batchmode'); + expect(scrubbedArgs).toContain('-quit'); + }); - expect(editors).toBeDefined(); - expect(Array.isArray(editors)).toBe(true); + it('should redact -password value', () => { + const args = ['-batchmode', '-password', 'SuperSecret123', '-quit']; + const scrubbedArgs = testEditor.scrubSensitiveArgs(args); - for (const editor of editors) { - if (editor.version.isLessThan('2019.0.0')) { - continue; // Skip versions that do not support templates - } + expect(scrubbedArgs).toContain('-password'); + expect(scrubbedArgs).toContain('[REDACTED]'); + expect(scrubbedArgs).not.toContain('SuperSecret123'); + }); - const template = editor.GetTemplatePath(pattern); - expect(template).toBeDefined(); - } - }); + it('should redact -cloudOrganization value', () => { + const args = ['-batchmode', '-cloudOrganization', 'org-id-12345', '-quit']; + const scrubbedArgs = testEditor.scrubSensitiveArgs(args); + + expect(scrubbedArgs).toContain('-cloudOrganization'); + expect(scrubbedArgs).toContain('[REDACTED]'); + expect(scrubbedArgs).not.toContain('org-id-12345'); + }); - describe('scrubSensitiveArgs', () => { - // Create a simple UnityEditor instance for testing scrubSensitiveArgs - // We don't need a full editor setup since we're only testing a pure function - let testEditor: UnityEditor; - - beforeAll(() => { - // Use the first available editor or create a mock-like one - if (editors.length > 0) { - testEditor = editors[0]; - } - }); - - it('should redact -username value', () => { - if (!testEditor) { - // Skip if no editors available - return; - } - - const args = ['-batchmode', '-username', 'test@example.com', '-quit']; - const scrubbedArgs = testEditor.scrubSensitiveArgs(args); - - expect(scrubbedArgs).toContain('-username'); - expect(scrubbedArgs).toContain('[REDACTED]'); - expect(scrubbedArgs).not.toContain('test@example.com'); - expect(scrubbedArgs).toContain('-batchmode'); - expect(scrubbedArgs).toContain('-quit'); - }); - - it('should redact -password value', () => { - if (!testEditor) return; - - const args = ['-batchmode', '-password', 'SuperSecret123', '-quit']; - const scrubbedArgs = testEditor.scrubSensitiveArgs(args); - - expect(scrubbedArgs).toContain('-password'); - expect(scrubbedArgs).toContain('[REDACTED]'); - expect(scrubbedArgs).not.toContain('SuperSecret123'); - }); - - it('should redact -cloudOrganization value', () => { - if (!testEditor) return; - - const args = ['-batchmode', '-cloudOrganization', 'org-id-12345', '-quit']; - const scrubbedArgs = testEditor.scrubSensitiveArgs(args); - - expect(scrubbedArgs).toContain('-cloudOrganization'); - expect(scrubbedArgs).toContain('[REDACTED]'); - expect(scrubbedArgs).not.toContain('org-id-12345'); - }); - - it('should redact -serial value', () => { - if (!testEditor) return; - - const args = ['-batchmode', '-serial', 'ABC-123-DEF-456', '-quit']; - const scrubbedArgs = testEditor.scrubSensitiveArgs(args); - - expect(scrubbedArgs).toContain('-serial'); - expect(scrubbedArgs).toContain('[REDACTED]'); - expect(scrubbedArgs).not.toContain('ABC-123-DEF-456'); - }); - - it('should redact multiple sensitive values', () => { - if (!testEditor) return; - - const args = [ - '-batchmode', - '-username', 'user@example.com', - '-password', 'MyPassword123', - '-cloudOrganization', 'org-xyz', - '-quit' - ]; - const scrubbedArgs = testEditor.scrubSensitiveArgs(args); - - expect(scrubbedArgs).toContain('[REDACTED]'); - expect(scrubbedArgs).not.toContain('user@example.com'); - expect(scrubbedArgs).not.toContain('MyPassword123'); - expect(scrubbedArgs).not.toContain('org-xyz'); - expect(scrubbedArgs).toContain('-batchmode'); - expect(scrubbedArgs).toContain('-quit'); - }); - - it('should preserve non-sensitive arguments', () => { - if (!testEditor) return; - - const args = [ - '-batchmode', - '-projectPath', '/path/to/project', - '-buildTarget', 'StandaloneWindows64', - '-quit' - ]; - const scrubbedArgs = testEditor.scrubSensitiveArgs(args); - - expect(scrubbedArgs).toEqual(args); - expect(scrubbedArgs).toContain('/path/to/project'); - expect(scrubbedArgs).toContain('StandaloneWindows64'); - }); - - it('should handle empty array', () => { - if (!testEditor) return; - - const args: string[] = []; - const scrubbedArgs = testEditor.scrubSensitiveArgs(args); - - expect(scrubbedArgs).toEqual([]); - }); - - it('should handle sensitive flag at end of array without value', () => { - if (!testEditor) return; - - const args = ['-batchmode', '-quit', '-username']; - const scrubbedArgs = testEditor.scrubSensitiveArgs(args); - - // The flag is included but there's no value to redact - expect(scrubbedArgs).toContain('-username'); - expect(scrubbedArgs).not.toContain('[REDACTED]'); - }); + it('should redact -serial value', () => { + const args = ['-batchmode', '-serial', 'ABC-123-DEF-456', '-quit']; + const scrubbedArgs = testEditor.scrubSensitiveArgs(args); + + expect(scrubbedArgs).toContain('-serial'); + expect(scrubbedArgs).toContain('[REDACTED]'); + expect(scrubbedArgs).not.toContain('ABC-123-DEF-456'); + }); + + it('should preserve non-sensitive arguments', () => { + const args = [ + '-batchmode', + '-projectPath', '/path/to/project', + '-buildTarget', 'StandaloneWindows64', + '-quit' + ]; + const scrubbedArgs = testEditor.scrubSensitiveArgs(args); + + expect(scrubbedArgs).toEqual(args); + }); + + it('should handle empty array', () => { + expect(testEditor.scrubSensitiveArgs([])).toEqual([]); }); -}); \ No newline at end of file + + it('should handle sensitive flag at end of array without value', () => { + const scrubbedArgs = testEditor.scrubSensitiveArgs(['-batchmode', '-quit', '-username']); + + expect(scrubbedArgs).toContain('-username'); + expect(scrubbedArgs).not.toContain('[REDACTED]'); + }); + }); +}); diff --git a/tests/unity-hub.test.ts b/tests/unity-hub.test.ts deleted file mode 100644 index bd0c349c..00000000 --- a/tests/unity-hub.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { UnityRelease } from '@rage-against-the-pixel/unity-releases-api'; -import { UnityHub } from '../src/unity-hub'; -import { UnityVersion } from '../src/unity-version'; - -jest.setTimeout(30000); // UnityHub operations can be slow - -describe('UnityHub', () => { - it('should get the Unity Hub version', async () => { - const unityHub = new UnityHub(); - const version = await unityHub.Version(); - expect(version).toBeDefined(); - expect(typeof version).toBe('string'); - }); - - it('should list installed editors', async () => { - const unityHub = new UnityHub(); - const editors = await unityHub.ListInstalledEditors(); - - expect(editors).toBeDefined(); - expect(Array.isArray(editors)).toBe(true); - - if (editors.length > 0) { - for (const editor of editors) { - expect(editor).toHaveProperty('version'); - expect(editor).toHaveProperty('editorPath'); - expect(editor).toHaveProperty('editorRootPath'); - } - } else { - console.warn('No Unity editors installed. Skipping ListInstalledEditors tests.'); - } - }); - - it('should get latest editor release info for a partial version YYYY', async () => { - const unityHub = new UnityHub(); - const version = new UnityVersion('2021'); - const releaseInfo: UnityRelease = await unityHub.GetEditorReleaseInfo(version); - expect(releaseInfo).toBeDefined(); - expect(releaseInfo.version).toMatch(/^2021.3.\d+[abcfpx]\d+$/); - }); - - it('should get latest editor release info for a partial version YYYY.x', async () => { - const unityHub = new UnityHub(); - const version = new UnityVersion('2021.x'); - const releaseInfo: UnityRelease = await unityHub.GetEditorReleaseInfo(version); - expect(releaseInfo).toBeDefined(); - expect(releaseInfo.version).toMatch(/^2021.3.\d+[abcfpx]\d+$/); - }); - - it('should get latest editor release info for a partial version YYYY.Y.x', async () => { - const unityHub = new UnityHub(); - const version = new UnityVersion('2021.3.x'); - const releaseInfo: UnityRelease = await unityHub.GetEditorReleaseInfo(version); - expect(releaseInfo).toBeDefined(); - expect(releaseInfo.version).toMatch(/^2021.3.\d+[abcfpx]\d+$/); - }); - - it('should get release info for multiple channels', async () => { - const unityHub = new UnityHub(); - const requestedVersionGlob = new UnityVersion('6000.3'); - const channels = ['f', 'b']; - const releases = await unityHub.ListAvailableReleases(); - expect(releases).toBeDefined(); - expect(Array.isArray(releases)).toBe(true); - const versionMatch = requestedVersionGlob.findMatch(releases, channels); - expect(versionMatch).toBeDefined(); - expect(versionMatch.version).toMatch(/^6000.3.\d+[bf]\d+$/); - const unityReleaseInfo: UnityRelease = await unityHub.GetEditorReleaseInfo(versionMatch); - expect(unityReleaseInfo).toBeDefined(); - expect(unityReleaseInfo.version).toBe(versionMatch.version); - expect(unityReleaseInfo.shortRevision).toBeDefined(); - const resolvedVersion = new UnityVersion(unityReleaseInfo.version, unityReleaseInfo.shortRevision, versionMatch.architecture); - expect(resolvedVersion.isFullyQualified()).toBe(true); - }); -}); \ No newline at end of file diff --git a/tests/unity-hub-release-api-filter.test.ts b/tests/unity-installer-release-api-filter.test.ts similarity index 82% rename from tests/unity-hub-release-api-filter.test.ts rename to tests/unity-installer-release-api-filter.test.ts index 2b8a46e6..bfa3784d 100644 --- a/tests/unity-hub-release-api-filter.test.ts +++ b/tests/unity-installer-release-api-filter.test.ts @@ -1,5 +1,5 @@ import { UnityReleasesClient } from '@rage-against-the-pixel/unity-releases-api'; -import { UnityHub } from '../src/unity-hub'; +import { UnityInstaller } from '../src/unity-installer'; import { UnityVersion } from '../src/unity-version'; jest.mock('@rage-against-the-pixel/unity-releases-api', () => { @@ -14,7 +14,7 @@ jest.mock('@rage-against-the-pixel/unity-releases-api', () => { const mockGetUnityReleases = jest.fn(); -describe('UnityHub GetEditorReleaseInfo (sparse API rows)', () => { +describe('UnityInstaller GetEditorReleaseInfo (sparse API rows)', () => { beforeEach(() => { jest.clearAllMocks(); (UnityReleasesClient as jest.Mock).mockImplementation(() => ({ @@ -43,9 +43,7 @@ describe('UnityHub GetEditorReleaseInfo (sparse API rows)', () => { }, error: undefined, }); - - const hub = new UnityHub(); - const info = await hub.GetEditorReleaseInfo(new UnityVersion('2021')); + const info = await UnityInstaller.GetEditorReleaseInfo(new UnityVersion('2021')); expect(info.version).toBe('2021.3.45f1'); expect(info.shortRevision).toBe('goodrev'); @@ -58,9 +56,7 @@ describe('UnityHub GetEditorReleaseInfo (sparse API rows)', () => { }, error: undefined, }); - - const hub = new UnityHub(); - await expect(hub.GetEditorReleaseInfo(new UnityVersion('2021'))).rejects.toThrow(/No suitable Unity releases/); + await expect(UnityInstaller.GetEditorReleaseInfo(new UnityVersion('2021'))).rejects.toThrow(/No suitable Unity releases/); }); it('accepts beta releases when channel b is requested', async () => { @@ -73,9 +69,7 @@ describe('UnityHub GetEditorReleaseInfo (sparse API rows)', () => { }, error: undefined, }); - - const hub = new UnityHub(); - const info = await hub.GetEditorReleaseInfo(new UnityVersion('6000.6'), ['b']); + const info = await UnityInstaller.GetEditorReleaseInfo(new UnityVersion('6000.6'), ['b']); expect(info.version).toBe('6000.6.0b7'); expect(info.shortRevision).toBe('53d4abb44f07'); }); diff --git a/tests/upm-cli.test.ts b/tests/upm-cli.test.ts deleted file mode 100644 index 95d831d5..00000000 --- a/tests/upm-cli.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import * as path from 'path'; -import * as os from 'os'; -import { UpmCli } from '../src/upm-cli'; - -describe('UpmCli', () => { - it('managedRoot is under home', () => { - const upm = new UpmCli(); - expect(upm.managedRoot).toBe(path.join(os.homedir(), '.unity-cli', 'upm')); - }); - - it('IsUpdateAvailable is true when nothing is installed', () => { - const spy = jest.spyOn(UpmCli.prototype, 'GetInstalledReleaseTag'); - spy.mockReturnValue(undefined); - try { - const upm = new UpmCli(); - expect(upm.IsUpdateAvailable('v9.27.0')).toBe(true); - } finally { - spy.mockRestore(); - } - }); - - it('IsUpdateAvailable compares release tags when a release is recorded', () => { - const spy = jest.spyOn(UpmCli.prototype, 'GetInstalledReleaseTag'); - spy.mockReturnValue('v9.28.0'); - try { - const upm = new UpmCli(); - expect(upm.IsUpdateAvailable('v9.29.0')).toBe(true); - expect(upm.IsUpdateAvailable('v9.28.0')).toBe(false); - expect(upm.IsUpdateAvailable('v9.27.0')).toBe(false); - } finally { - spy.mockRestore(); - } - }); -}); From 2d572e602bb0757c5034f162951794a1adebdfda Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:44:09 -0400 Subject: [PATCH 02/60] harden installer, license and ci paths flagged by code scanning --- src/github-actions-ci.ts | 6 +++-- src/license-client.ts | 48 +++++++++++++++---------------------- src/unity-cli.ts | 12 +++++++++- src/unity-installer.ts | 52 +++++++++++++++++++++++++++++++++------- 4 files changed, 77 insertions(+), 41 deletions(-) diff --git a/src/github-actions-ci.ts b/src/github-actions-ci.ts index 464e3846..2575532c 100644 --- a/src/github-actions-ci.ts +++ b/src/github-actions-ci.ts @@ -74,7 +74,8 @@ export class GitHubActionsLoggerProvider implements ILoggerProvider { public setEnvironmentVariable(name: string, value: string): void { const githubEnv = process.env.GITHUB_ENV; - if (githubEnv) { + // a line break would let the value set other variables + if (githubEnv && !/[\r\n]/.test(`${name}${value}`)) { fs.appendFileSync(githubEnv, `${name}=${value}\n`, { encoding: 'utf8' }); } } @@ -88,7 +89,8 @@ export class GitHubActionsLoggerProvider implements ILoggerProvider { public addPath(directory: string): void { const githubPath = process.env.GITHUB_PATH; - if (githubPath) { + // a line break would let the value add more than one PATH entry + if (githubPath && !/[\r\n]/.test(directory)) { fs.appendFileSync(githubPath, `${directory}\n`, { encoding: 'utf8' }); } } diff --git a/src/license-client.ts b/src/license-client.ts index 41aefc48..90a42550 100644 --- a/src/license-client.ts +++ b/src/license-client.ts @@ -347,27 +347,8 @@ export class LicensingClient { private async patchLicenseVersion(): Promise { if (!this.licenseVersion) { - // check if the UNITY_EDITOR_PATH is set. If it is, use it to determine the license version - const versionMatch = process.env.UNITY_EDITOR_PATH?.match(/(\d+)\.(\d+)\.(\d+)/); - - if (versionMatch) { - switch (versionMatch[1]) { - case '4': { - this.licenseVersion = '4.x'; - break; - } - case '5': { - this.licenseVersion = '5.x'; - break; - } - default: { - this.licenseVersion = '6.x'; // default to 6.x for any other - break; - } - } - } else { - this.licenseVersion = '6.x'; // default to 6.x if not set - } + // use the editor in UNITY_EDITOR_PATH to determine the license version, defaulting to 6.x + this.licenseVersion = GetLegacyLicenseVersion() ?? '6.x'; } if (this.licenseVersion !== '6.x') { @@ -786,16 +767,25 @@ export class LicensingClient { * @param editorPath The editor executable. Defaults to UNITY_EDITOR_PATH. */ export function GetLegacyLicenseVersion(editorPath: string | undefined = process.env.UNITY_EDITOR_PATH): '4.x' | '5.x' | undefined { - const versionMatch = editorPath?.match(/(\d+)\.(\d+)\.(\d+)/); + // anchored per path segment, so the match stays linear on any input + for (const segment of (editorPath ?? '').split(/[\\/ ]/)) { + const major = /^(\d{1,4})\.\d{1,4}\.\d/.exec(segment)?.[1]; + + if (major === undefined) { + continue; + } - switch (versionMatch?.[1]) { - case '4': - return '4.x'; - case '5': - return '5.x'; - default: - return undefined; + switch (major) { + case '4': + return '4.x'; + case '5': + return '5.x'; + default: + return undefined; + } } + + return undefined; } /** How long one official CLI license command may run before it falls back. */ diff --git a/src/unity-cli.ts b/src/unity-cli.ts index df9de532..57052e3c 100644 --- a/src/unity-cli.ts +++ b/src/unity-cli.ts @@ -101,7 +101,8 @@ export class UnityCli { candidates.push(...defaultInstallLocations()); for (const candidate of candidates) { - if (!isExecutableFile(candidate)) { + // only ever run a binary named unity(.exe), even when UNITY_CLI_PATH points elsewhere + if (path.basename(candidate).toLowerCase() !== EXE_NAME || !isExecutableFile(candidate)) { continue; } @@ -131,6 +132,10 @@ export class UnityCli { throw new Error(`Unity CLI ${manifest.version} has no binary for ${platformKey}. Available: ${Object.keys(manifest.binaries).join(', ')}`); } + if (!/^[\w.-]+$/.test(binary.filename) || !/^[0-9a-f]{64}$/i.test(binary.sha256)) { + throw new Error(`Invalid binary entry for ${platformKey} in the Unity CLI ${manifest.version} manifest`); + } + const cached = getToolCachePath(manifest.version); const cachedExecutable = path.join(cached, EXE_NAME); @@ -490,6 +495,11 @@ async function fetchManifest(version: string): Promise { try { const manifest = JSON.parse(await HttpsGetText(url)) as UnityCliManifest; + // the version becomes part of a cache path and a download URL + if (typeof manifest.version !== 'string' || !VERSION_PATTERN.test(manifest.version)) { + throw new Error(`Invalid version in ${url}: ${JSON.stringify(manifest.version)}`); + } + if (version !== 'latest' && manifest.version !== version) { throw new Error(`Manifest mismatch: requested ${version} but ${url} reports ${manifest.version}`); } diff --git a/src/unity-installer.ts b/src/unity-installer.ts index 4a0acaeb..00c0a13d 100644 --- a/src/unity-installer.ts +++ b/src/unity-installer.ts @@ -1,6 +1,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import { spawn } from 'child_process'; import { Logger } from './logging'; import { UnityEditor } from './unity-editor'; import { UnityVersion } from './unity-version'; @@ -321,6 +322,7 @@ export class UnityInstaller { throw new Error(`Unity ${unityVersion.toString()} needs a changeset to install from the download archive`); } + UnityInstaller.assertArchiveVersion(unityVersion); const installPath = await this.getLegacyInstallPath(); const installDir = path.join(installPath, unityVersion.version); const baseUrl = `https://download.unity3d.com/download_unity/${unityVersion.changeset}`; @@ -410,17 +412,32 @@ export class UnityInstaller { return names; } + /** + * Runs a Unity NSIS installer silently. NSIS needs `/D=` last and unquoted, so the command line is passed verbatim, without a shell. + */ private async runWindowsInstaller(url: string, installDir: string): Promise { + if (/["\r\n]/.test(installDir)) { + throw new Error(`Invalid install directory: ${installDir}`); + } + const installerPath = path.join(GetTempDir(), path.basename(url)); await DownloadFile(url, installerPath); this.logger.info(`Running ${path.basename(installerPath)}...`); try { - await Exec('powershell', [ - '-NoProfile', - '-Command', - `Start-Process -FilePath "${installerPath}" -ArgumentList "/S /D=${installDir}" -Wait` - ], { silent: true, showCommand: true }); + const exitCode = await new Promise((resolve, reject) => { + const child = spawn(installerPath, ['/S', `/D=${installDir}`], { + shell: false, + stdio: 'ignore', + windowsVerbatimArguments: true, + }); + child.on('error', reject); + child.on('close', (code) => resolve(code ?? 1)); + }); + + if (exitCode !== 0) { + throw new Error(`${path.basename(installerPath)} failed with exit code ${exitCode}`); + } } finally { await fs.promises.rm(installerPath, { force: true }); } @@ -437,7 +454,7 @@ export class UnityInstaller { } this.logger.debug(`Moving ${unityAppPath} to ${targetPath}...`); - await Exec('sudo', ['mkdir', '-p', targetPath], { silent: true, showCommand: true }); + await fs.promises.mkdir(targetPath, { recursive: true }); for (const item of await fs.promises.readdir(unityAppPath)) { // the editor install path itself lives in /Applications/Unity/Hub @@ -445,16 +462,32 @@ export class UnityInstaller { continue; } - await Exec('sudo', ['mv', path.join(unityAppPath, item), path.join(targetPath, item)], { silent: true, showCommand: true }); + const src = path.join(unityAppPath, item); + await fs.promises.cp(src, path.join(targetPath, item), { recursive: true, verbatimSymlinks: true }); + await fs.promises.rm(src, { recursive: true, force: true }); } - await Exec('sudo', ['chmod', '-R', '777', targetPath], { silent: true, showCommand: true }); + await fs.promises.chmod(targetPath, 0o777); + } + + /** + * Rejects versions and changesets that are not plain Unity release identifiers before they become URLs and paths. + */ + private static assertArchiveVersion(unityVersion: UnityVersion): void { + if (!/^\d{1,2}\.\d{1,2}\.\d{1,2}(?:[abcfpx]\d{1,3})?$/.test(unityVersion.version)) { + throw new Error(`Invalid Unity version: ${unityVersion.version}`); + } + + if (unityVersion.changeset && !/^[0-9a-f]{12}$/i.test(unityVersion.changeset)) { + throw new Error(`Invalid Unity changeset: ${unityVersion.changeset}`); + } } /** * Installs Unity 4.x from Unity's legacy installers. */ private async installLegacyEditor(unityVersion: UnityVersion): Promise { + UnityInstaller.assertArchiveVersion(unityVersion); const installPath = await this.getLegacyInstallPath(); const installDir = path.join(installPath, `Unity ${unityVersion.version}`); let editorPath = resolveEditorExecutable(installDir); @@ -565,6 +598,7 @@ export class UnityInstaller { if (fs.existsSync(beeBackend) && !fs.existsSync(dotBeeBackend)) { this.logger.ci(`Patching Unity Linux Editor for Bee Backend...`); await fs.promises.rename(beeBackend, dotBeeBackend); + // 'wx' fails instead of overwriting if something recreated the file since the rename const wrapperSource: string = `#!/bin/bash # https://discussions.unity.com/t/linux-editor-stuck-on-loading-because-of-bee-backend-w-workaround/854480 set -e @@ -578,7 +612,7 @@ for ((i = 0; i < "\${#args[@]}"; ++i)); do esac done "$(dirname "$0")/.$(basename "$0")" "\${args[@]}"` - await fs.promises.writeFile(beeBackend, wrapperSource, { encoding: 'utf-8', mode: 0o755 }); + await fs.promises.writeFile(beeBackend, wrapperSource, { encoding: 'utf-8', mode: 0o755, flag: 'wx' }); } } } From f21f7aa6e11f388fefa11e026d3432c4833fdef5 Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:44:55 -0400 Subject: [PATCH 03/60] npm audit fix --- package-lock.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index e55318b6..376ac4c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2922,13 +2922,13 @@ "license": "ISC" }, "node_modules/handlebars": { - "version": "4.7.9", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", - "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "version": "4.7.10", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.10.tgz", + "integrity": "sha512-P5VJMVM7qgBn6vjXMw8WG9uVI+ncf2pi72j4de4yz5ZULLj2RGqLYaKOYGsgyrViQ0tePOVlN1tDCCXXtFqXKg==", "dev": true, "license": "MIT", "dependencies": { - "minimist": "^1.2.5", + "minimist": "^1.2.8", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" @@ -4251,9 +4251,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "3.15.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz", - "integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==", + "version": "3.15.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz", + "integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==", "dev": true, "license": "MIT", "dependencies": { From ed597337faabe4ec8969fd5448381ee6e2ae0ca3 Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:45:53 -0400 Subject: [PATCH 04/60] override brace-expansion to a patched release --- package-lock.json | 6 +++--- package.json | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 376ac4c2..b1e10adb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2185,9 +2185,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" diff --git a/package.json b/package.json index c6b5c900..ae6e9cfd 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "babel-plugin-istanbul": { "test-exclude": "7.0.2" }, - "glob": "$glob" + "glob": "$glob", + "brace-expansion": "^5.0.12" } } From 2f98167877c5570485c5d384136cafc83b781adc Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:54:11 -0400 Subject: [PATCH 05/60] patch bee_backend without a check-then-use race --- src/unity-installer.ts | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/src/unity-installer.ts b/src/unity-installer.ts index 00c0a13d..a4f7d4fd 100644 --- a/src/unity-installer.ts +++ b/src/unity-installer.ts @@ -595,11 +595,23 @@ export class UnityInstaller { const dataPath = path.join(path.dirname(editorPath), 'Data'); const beeBackend = path.join(dataPath, 'bee_backend'); const dotBeeBackend = path.join(dataPath, '.bee_backend'); - if (fs.existsSync(beeBackend) && !fs.existsSync(dotBeeBackend)) { - this.logger.ci(`Patching Unity Linux Editor for Bee Backend...`); - await fs.promises.rename(beeBackend, dotBeeBackend); - // 'wx' fails instead of overwriting if something recreated the file since the rename - const wrapperSource: string = `#!/bin/bash + // linking fails with EEXIST once patched and ENOENT without a bee_backend, so nothing is checked before it is used + try { + await fs.promises.link(beeBackend, dotBeeBackend); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + + if (code === 'EEXIST' || code === 'ENOENT') { + return; + } + + throw error; + } + + this.logger.ci(`Patching Unity Linux Editor for Bee Backend...`); + await fs.promises.unlink(beeBackend); + // 'wx' fails instead of overwriting if something recreated the file since the unlink + const wrapperSource: string = `#!/bin/bash # https://discussions.unity.com/t/linux-editor-stuck-on-loading-because-of-bee-backend-w-workaround/854480 set -e args=("$@") @@ -612,8 +624,7 @@ for ((i = 0; i < "\${#args[@]}"; ++i)); do esac done "$(dirname "$0")/.$(basename "$0")" "\${args[@]}"` - await fs.promises.writeFile(beeBackend, wrapperSource, { encoding: 'utf-8', mode: 0o755, flag: 'wx' }); - } + await fs.promises.writeFile(beeBackend, wrapperSource, { encoding: 'utf-8', mode: 0o755, flag: 'wx' }); } } From 0d14c2ce3302b2790f62ce19282d00dae3ccb39c Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:56:46 -0400 Subject: [PATCH 06/60] patch the single-file licensing client for 4.x and 5.x editors --- src/license-client.ts | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/src/license-client.ts b/src/license-client.ts index 90a42550..76b91c1f 100644 --- a/src/license-client.ts +++ b/src/license-client.ts @@ -372,22 +372,37 @@ export class LicensingClient { if (await fs.promises.mkdir(patchedDirectory, { recursive: true }) === undefined) { this.logger.debug('Unity Licensing Client was already patched, reusing'); } else { - let found = false; - for (const fileName of await fs.promises.readdir(clientDirectory)) { - if (fileName === 'Unity.Licensing.EntitlementResolver.dll') { + const fileNames = await fs.promises.readdir(clientDirectory); + // older clients ship the resolver as its own assembly; the single-file client the Unity CLI downloads embeds it in the executable + const patchTarget = fileNames.includes('Unity.Licensing.EntitlementResolver.dll') + ? 'Unity.Licensing.EntitlementResolver.dll' + : path.basename(this.licenseClientPath); + + for (const fileName of fileNames) { + if (fileName === patchTarget) { await this.patchBinary( path.join(clientDirectory, fileName), path.join(patchedDirectory, fileName), Buffer.from('6.x', 'utf16le'), Buffer.from(this.licenseVersion, 'utf16le'), ); - found = true; } else { await fs.promises.symlink(path.join(clientDirectory, fileName), path.join(patchedDirectory, fileName)); } } - if (!found) { - throw new Error(`Could not find Unity.Licensing.EntitlementResolver.dll in ${clientDirectory}`); + const patchedPath = path.join(patchedDirectory, patchTarget); + + if (patchTarget === path.basename(this.licenseClientPath)) { + await fs.promises.chmod(patchedPath, 0o755); + + if (process.platform === 'darwin') { + // patching invalidates the signature, and macOS kills binaries with a broken one + await new Promise((resolve, reject) => { + const child = spawn('codesign', ['--force', '--sign', '-', patchedPath], { stdio: 'ignore' }); + child.on('error', reject); + child.on('close', (code) => code === 0 ? resolve() : reject(new Error(`codesign failed with exit code ${code}`))); + }); + } } } From 6b61f828b94f742840a24e8d13a621220ddcac35 Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:00:05 -0400 Subject: [PATCH 07/60] do not forward -quit to unity run, and launch directly when it rejects an argument --- src/unity-editor.ts | 27 ++++++++++++++++++++++++--- tests/unity-editor.test.ts | 3 +-- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/unity-editor.ts b/src/unity-editor.ts index 572207dc..453f99d8 100644 --- a/src/unity-editor.ts +++ b/src/unity-editor.ts @@ -20,6 +20,9 @@ import { LogTailResult, } from './unity-logging'; +/** Thrown when `unity run` refuses the editor arguments before it starts the editor. */ +class UnityRunRejectedError extends Error { } + export interface EditorCommand { args: string[]; projectPath?: string | undefined; @@ -141,6 +144,20 @@ export class UnityEditor { * @throws Will throw an error if the Unity Editor fails to start or exits with a non-zero code. */ public async Run(command: EditorCommand): Promise { + try { + await this.runEditor(command, true); + } catch (error) { + if (!(error instanceof UnityRunRejectedError)) { + throw error; + } + + // unity run refused the arguments before it started the editor, so launching directly runs it once + this.logger.warn(`${error.message}\nLaunching the editor directly instead.`); + await this.runEditor(command, false); + } + } + + private async runEditor(command: EditorCommand, allowUnityRun: boolean): Promise { let isCancelled = false; let exitCode: number | undefined = undefined; let procInfo: ProcInfo | null = null; @@ -229,7 +246,7 @@ export class UnityEditor { ...(linuxEnvOverrides ?? {}) }; - const unityCli = await this.getUnityCliForRun(command); + const unityCli = allowUnityRun ? await this.getUnityCliForRun(command) : undefined; if (unityCli) { const runArgs = UnityEditor.GetUnityRunArgs(this.editorPath, command.projectPath!, logPath, command.args); @@ -314,6 +331,10 @@ export class UnityEditor { if (exitCode !== undefined && exitCode !== 0 && unityRunOutput) { const output = unityRunOutput.split(/\r?\n/).filter(line => line.length > 0 && !line.includes(' 0) { this.logger.info(`unity run output:\n${output}`); } @@ -355,7 +376,7 @@ export class UnityEditor { /** * Builds the `unity run` arguments for an editor command. - * `unity run` adds -batchmode, -projectPath and -logFile itself, so those are not forwarded. + * `unity run` adds -batchmode, -quit, -projectPath and -logFile itself and rejects them after `--`, so those are not forwarded. * @param editorPath The editor executable. * @param projectPath The Unity project. * @param logPath The editor log file, which unity-cli tails itself. @@ -372,7 +393,7 @@ export class UnityEditor { continue; } - if (arg === '-batchmode') { + if (arg === '-batchmode' || arg === '-quit') { continue; } diff --git a/tests/unity-editor.test.ts b/tests/unity-editor.test.ts index abbf5f44..c8263ed8 100644 --- a/tests/unity-editor.test.ts +++ b/tests/unity-editor.test.ts @@ -24,7 +24,7 @@ describe('UnityEditor', () => { }); describe('GetUnityRunArgs', () => { - it('lets unity run own -batchmode, -projectPath and -logFile', () => { + it('lets unity run own -batchmode, -quit, -projectPath and -logFile', () => { const args = UnityEditor.GetUnityRunArgs('/editor/Unity', '/project', '/logs/run.log', [ '-projectPath', '/project', '-logFile', '/logs/run.log', @@ -43,7 +43,6 @@ describe('UnityEditor', () => { '--', '-automated', '-nographics', - '-quit', '-executeMethod', 'Build.Run', ]); }); From 9a49c9c51f19aecd781bd6865fe27ef6304ce777 Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:17:48 -0400 Subject: [PATCH 08/60] patch the licensing client wherever its license version lives --- src/license-client.ts | 81 +++++++++++++++++++++++++++++++------------ 1 file changed, 58 insertions(+), 23 deletions(-) diff --git a/src/license-client.ts b/src/license-client.ts index 76b91c1f..90c2d6e3 100644 --- a/src/license-client.ts +++ b/src/license-client.ts @@ -88,6 +88,35 @@ export class LicensingClient { return this.licenseClientPath; } + /** + * The folder that holds the whole licensing client: the folder around a macOS app bundle, otherwise the executable's folder. + */ + private static clientRootDirectory(clientPath: string): string { + const bundle = clientPath.match(/^(.*?\.app)[\\/]/)?.[1]; + return bundle ? path.dirname(bundle) : path.dirname(clientPath); + } + + /** + * Lists the files under a directory that can hold compiled code: executables, libraries and extensionless binaries. + */ + private static async listBinaries(directory: string): Promise { + const results: string[] = []; + + for (const entry of await fs.promises.readdir(directory, { withFileTypes: true, recursive: true })) { + if (!entry.isFile()) { + continue; + } + + const extension = path.extname(entry.name).toLowerCase(); + + if (['', '.exe', '.dll', '.dylib', '.so'].includes(extension)) { + results.push(path.join(entry.parentPath, entry.name)); + } + } + + return results; + } + /** * The folders the Unity Hub and the official Unity CLI download Unity.Licensing.Client to. */ @@ -366,39 +395,45 @@ export class LicensingClient { return; // no patching needed } - const clientDirectory = path.dirname(this.licenseClientPath); + const clientRoot = LicensingClient.clientRootDirectory(this.licenseClientPath); const patchedDirectory = path.join(os.tmpdir(), `UnityLicensingClient-${this.licenseVersion.replace('.', '_')}`); + const patchedClientPath = path.join(patchedDirectory, path.relative(clientRoot, this.licenseClientPath)); if (await fs.promises.mkdir(patchedDirectory, { recursive: true }) === undefined) { this.logger.debug('Unity Licensing Client was already patched, reusing'); } else { - const fileNames = await fs.promises.readdir(clientDirectory); - // older clients ship the resolver as its own assembly; the single-file client the Unity CLI downloads embeds it in the executable - const patchTarget = fileNames.includes('Unity.Licensing.EntitlementResolver.dll') - ? 'Unity.Licensing.EntitlementResolver.dll' - : path.basename(this.licenseClientPath); - - for (const fileName of fileNames) { - if (fileName === patchTarget) { - await this.patchBinary( - path.join(clientDirectory, fileName), path.join(patchedDirectory, fileName), - Buffer.from('6.x', 'utf16le'), - Buffer.from(this.licenseVersion, 'utf16le'), - ); - } else { - await fs.promises.symlink(path.join(clientDirectory, fileName), path.join(patchedDirectory, fileName)); + // copy the whole client, then patch whichever binary holds the "6.x" license version: + // older clients ship it in Unity.Licensing.EntitlementResolver.dll, the single-file client the Unity CLI downloads embeds it in the executable + await fs.promises.cp(clientRoot, patchedDirectory, { recursive: true, verbatimSymlinks: true }); + const patchedFiles: string[] = []; + + for (const file of await LicensingClient.listBinaries(patchedDirectory)) { + try { + await this.patchBinary(file, file, Buffer.from('6.x', 'utf16le'), Buffer.from(this.licenseVersion, 'utf16le')); + patchedFiles.push(file); + } catch (error) { + if (!(error instanceof Error && error.message === 'Could not find the search value')) { + throw error; + } } } - const patchedPath = path.join(patchedDirectory, patchTarget); + if (patchedFiles.length === 0) { + await fs.promises.rm(patchedDirectory, { recursive: true, force: true }); + throw new Error(`Could not find the 6.x license version in any Unity Licensing Client binary under ${clientRoot}`); + } + + this.logger.debug(`Patched the license version in:\n${patchedFiles.map(file => ` > ${file}`).join('\n')}`); + await fs.promises.chmod(patchedClientPath, 0o755); - if (patchTarget === path.basename(this.licenseClientPath)) { - await fs.promises.chmod(patchedPath, 0o755); + if (process.platform === 'darwin') { + // patching invalidates the signature, and macOS kills binaries with a broken one + const bundle = patchedClientPath.match(/^(.*?\.app)\//)?.[1]; + const targets = bundle ? [bundle] : patchedFiles; - if (process.platform === 'darwin') { - // patching invalidates the signature, and macOS kills binaries with a broken one + for (const target of targets) { await new Promise((resolve, reject) => { - const child = spawn('codesign', ['--force', '--sign', '-', patchedPath], { stdio: 'ignore' }); + const child = spawn('codesign', ['--force', '--deep', '--sign', '-', target], { stdio: 'ignore' }); child.on('error', reject); child.on('close', (code) => code === 0 ? resolve() : reject(new Error(`codesign failed with exit code ${code}`))); }); @@ -406,7 +441,7 @@ export class LicensingClient { } } - this.licenseClientPath = path.join(patchedDirectory, path.basename(this.licenseClientPath)); + this.licenseClientPath = patchedClientPath; const unityCommonDir = this.getUnityCommonDir(); const legacyLicenseFile = path.join(unityCommonDir, `Unity_v${this.licenseVersion}.ulf`); await fs.promises.mkdir(unityCommonDir, { recursive: true }); From 096846216ad63f07d714e46855e7fd75e7a07b8a Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:19:56 -0400 Subject: [PATCH 09/60] treat already installed modules as success --- src/unity-installer.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/unity-installer.ts b/src/unity-installer.ts index a4f7d4fd..af7a992b 100644 --- a/src/unity-installer.ts +++ b/src/unity-installer.ts @@ -220,6 +220,12 @@ export class UnityInstaller { result = await cli.Run(args[0] === 'install' ? [...args, '--resume'] : args, { stream: true }); } + // install-modules fails when every requested module is already installed + if (result.exitCode !== 0 && args[0] === 'install-modules' && `${result.stdout}${result.stderr}`.includes('No modules found to install')) { + this.logger.info('The requested modules are already installed.'); + return; + } + if (result.exitCode !== 0) { throw new Error(`unity ${args.join(' ')} failed with exit code ${result.exitCode}`); } From 837743341b17d7266dd7fb9abe64862e596dd1ee Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:34:01 -0400 Subject: [PATCH 10/60] patch the macos licensing client bundle --- src/license-client.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/license-client.ts b/src/license-client.ts index 90c2d6e3..7f30e756 100644 --- a/src/license-client.ts +++ b/src/license-client.ts @@ -89,11 +89,16 @@ export class LicensingClient { } /** - * The folder that holds the whole licensing client: the folder around a macOS app bundle, otherwise the executable's folder. + * The folder that holds the whole licensing client: the bundle root for a macOS bundle (…/Contents/MacOS/client), otherwise the executable's folder. */ private static clientRootDirectory(clientPath: string): string { - const bundle = clientPath.match(/^(.*?\.app)[\\/]/)?.[1]; - return bundle ? path.dirname(bundle) : path.dirname(clientPath); + const macOSDirectory = path.dirname(clientPath); + + if (path.basename(macOSDirectory) === 'MacOS' && path.basename(path.dirname(macOSDirectory)) === 'Contents') { + return path.dirname(path.dirname(macOSDirectory)); + } + + return macOSDirectory; } /** @@ -419,8 +424,9 @@ export class LicensingClient { } if (patchedFiles.length === 0) { + const searched = (await LicensingClient.listBinaries(patchedDirectory)).map(file => path.relative(patchedDirectory, file)); await fs.promises.rm(patchedDirectory, { recursive: true, force: true }); - throw new Error(`Could not find the 6.x license version in any Unity Licensing Client binary under ${clientRoot}`); + throw new Error(`Could not find the 6.x license version in any Unity Licensing Client binary under ${clientRoot}. Searched:\n${searched.map(file => ` > ${file}`).join('\n')}`); } this.logger.debug(`Patched the license version in:\n${patchedFiles.map(file => ` > ${file}`).join('\n')}`); @@ -428,8 +434,8 @@ export class LicensingClient { if (process.platform === 'darwin') { // patching invalidates the signature, and macOS kills binaries with a broken one - const bundle = patchedClientPath.match(/^(.*?\.app)\//)?.[1]; - const targets = bundle ? [bundle] : patchedFiles; + const isBundle = fs.existsSync(path.join(patchedDirectory, 'Contents', 'MacOS')); + const targets = isBundle ? [patchedDirectory] : patchedFiles; for (const target of targets) { await new Promise((resolve, reject) => { From 1615eb022d7f3618d2c46d759bdf9a6c2a7ab69c Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Sat, 24 Oct 2026 21:31:21 -0400 Subject: [PATCH 11/60] time out the licensing client and stop background instances before the fallback --- src/license-client.ts | 56 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/src/license-client.ts b/src/license-client.ts index 7f30e756..b71722b2 100644 --- a/src/license-client.ts +++ b/src/license-client.ts @@ -34,6 +34,9 @@ export interface ActivateOptions { * v4 uses it as the fallback when the official Unity CLI cannot activate or return a license, * for example a personal license on a headless runner, and for Unity 4.x and 5.x editors. */ +/** How long one Unity Licensing Client command may run. */ +const LICENSING_CLIENT_TIMEOUT_MS = 10 * 60 * 1000; + export class LicensingClient { private readonly logger: Logger = Logger.instance; @@ -475,6 +478,7 @@ export class LicensingClient { let output: string = ''; let exitCode: number = 0; + let timedOut = false; this.logger.startGroup(`\x1b[34m${this.licenseClientPath} ${args.join(' ')}\x1b[0m`); await fs.promises.access(this.licenseClientPath!, fs.constants.R_OK | fs.constants.X_OK); @@ -490,11 +494,17 @@ export class LicensingClient { const sigtermHandler = () => child.kill('SIGTERM'); process.once('SIGINT', sigintHandler); process.once('SIGTERM', sigtermHandler); + // a client that waits on something that never comes must not hold a CI job until it times out + const timer = setTimeout(() => { + timedOut = true; + child.kill('SIGKILL'); + }, LICENSING_CLIENT_TIMEOUT_MS); let hasCleanedUpListeners = false; function removeListeners() { if (hasCleanedUpListeners) { return; } hasCleanedUpListeners = true; + clearTimeout(timer); process.removeListener('SIGINT', sigintHandler); process.removeListener('SIGTERM', sigtermHandler); } @@ -512,10 +522,15 @@ export class LicensingClient { }); child.on('close', (code) => { removeListeners(); - resolve(code === null ? 0 : code); + resolve(timedOut ? -1 : code === null ? 0 : code); }); }); } finally { + if (timedOut) { + this.logger.error(`Unity Licensing Client did not finish within ${LICENSING_CLIENT_TIMEOUT_MS / 60000} minutes. Its log: ${LicensingClient.ClientLogPath()}`); + await this.printClientLogTail(); + } + if (!silent || exitCode !== 0) { const maskedOutput = this.maskSerialInOutput(output); const splitLines = maskedOutput.split(/\r?\n/); @@ -529,7 +544,7 @@ export class LicensingClient { this.logger.endGroup(); if (exitCode !== 0) { - const message = this.getExitCodeMessage(exitCode); + const message = timedOut ? 'Timed out' : this.getExitCodeMessage(exitCode); throw new Error(`License command failed with exit code ${exitCode}: ${message}`); } } @@ -537,6 +552,39 @@ export class LicensingClient { return output; } + private async printClientLogTail(lineCount: number = 40): Promise { + try { + const lines = (await fs.promises.readFile(LicensingClient.ClientLogPath(), 'utf-8')).split(/\r?\n/); + const tail = this.maskSerialInOutput(lines.slice(-lineCount).join('\n')); + this.logger.info(`Last ${lineCount} lines of the Unity Licensing Client log:\n${tail}`); + } catch (error) { + this.logger.debug(`Could not read the Unity Licensing Client log: ${error}`); + } + } + + /** + * Stops Unity.Licensing.Client instances left running in the background, such as the one the official Unity CLI starts. + * A command line activation can wait on that instance indefinitely. Only done in CI, so a developer's own session is left alone. + */ + public static async StopBackgroundClients(): Promise { + if (process.env.CI !== 'true') { + return; + } + + const [command, args] = process.platform === 'win32' + ? ['taskkill', ['/F', '/IM', 'Unity.Licensing.Client.exe']] + : ['pkill', ['-f', 'Unity.Licensing.Client']]; + + await new Promise((resolve) => { + const child = spawn(command, args, { stdio: 'ignore' }); + child.on('error', () => resolve()); + child.on('close', (code) => { + Logger.instance.debug(`${command} exited with code ${code}`); + resolve(); + }); + }); + } + private maskSerialInOutput(output: string): string { return output.replace(/([\w-]+-XXXX)/g, (_, serial) => { const maskedSerial = serial.slice(0, -4) + `XXXX`; @@ -861,6 +909,7 @@ export async function ActivateLicense(options: ActivateOptions): Promise Date: Sat, 24 Oct 2026 21:54:49 -0400 Subject: [PATCH 12/60] include the macos licensing client executable in the patch scan --- src/license-client.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/license-client.ts b/src/license-client.ts index b71722b2..23150536 100644 --- a/src/license-client.ts +++ b/src/license-client.ts @@ -116,8 +116,10 @@ export class LicensingClient { } const extension = path.extname(entry.name).toLowerCase(); + // the macOS client is named Unity.Licensing.Client, so its extension reads as ".client" + const isClient = entry.name.startsWith('Unity.Licensing.') && !['.md', '.config', '.json', '.plist', '.xml', '.txt'].includes(extension); - if (['', '.exe', '.dll', '.dylib', '.so'].includes(extension)) { + if (isClient || ['', '.exe', '.dll', '.dylib', '.so'].includes(extension)) { results.push(path.join(entry.parentPath, entry.name)); } } From 3c89ac824c0cd2122853d13830c62bd554205745 Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:42:17 -0400 Subject: [PATCH 13/60] settle licensing client commands on exit, not on pipe close --- src/license-client.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/license-client.ts b/src/license-client.ts index 23150536..ad6fa5ff 100644 --- a/src/license-client.ts +++ b/src/license-client.ts @@ -522,9 +522,22 @@ export class LicensingClient { removeListeners(); reject(error); }); - child.on('close', (code) => { + // the client can start a background instance that inherits its stdout and stderr, + // so 'close' may never fire; settle on 'exit' and stop reading the pipes shortly after + let settled = false; + const settle = (code: number | null) => { + if (settled) { return; } + settled = true; removeListeners(); resolve(timedOut ? -1 : code === null ? 0 : code); + }; + child.on('close', settle); + child.on('exit', (code) => { + setTimeout(() => { + child.stdout.destroy(); + child.stderr.destroy(); + settle(code); + }, 1000); }); }); } finally { From 3112cb30a649249e71984bbcf8a1ff26c5d69b95 Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:48:54 -0400 Subject: [PATCH 14/60] redact credentials from licensing client and unity run output --- src/license-client.ts | 25 ++++++++++++------------- src/unity-editor.ts | 11 +++++++++-- tests/unity-cli.test.ts | 20 ++++++++++++++++++++ 3 files changed, 41 insertions(+), 15 deletions(-) diff --git a/src/license-client.ts b/src/license-client.ts index ad6fa5ff..f3d36f46 100644 --- a/src/license-client.ts +++ b/src/license-client.ts @@ -7,6 +7,7 @@ import { UnityCli } from './unity-cli'; import { ResolveGlobToPath, ResolvePathCandidates, + redactSensitiveLiterals, tryParseJson } from './utilities'; @@ -41,6 +42,7 @@ export class LicensingClient { private readonly logger: Logger = Logger.instance; private licenseClientPath: string | undefined; + private readonly secrets: string[] = []; private licenseVersion: string | undefined; /** @@ -482,7 +484,12 @@ export class LicensingClient { let exitCode: number = 0; let timedOut = false; - this.logger.startGroup(`\x1b[34m${this.licenseClientPath} ${args.join(' ')}\x1b[0m`); + // values that must never reach a log, even outside CI where ::add-mask:: does nothing + const sensitiveFlags = ['--username', '--password', '--serial', '--return-floating']; + const argSecrets = args.filter((_, i) => i > 0 && sensitiveFlags.includes(args[i - 1]!)); + const secrets = [...this.secrets, ...argSecrets]; + const redact = (text: string) => redactSensitiveLiterals(text, secrets); + this.logger.startGroup(`\x1b[34m${this.licenseClientPath} ${redact(args.join(' '))}\x1b[0m`); await fs.promises.access(this.licenseClientPath!, fs.constants.R_OK | fs.constants.X_OK); try { @@ -542,12 +549,12 @@ export class LicensingClient { }); } finally { if (timedOut) { + // the client log can hold account details and tokens, so only its path is printed this.logger.error(`Unity Licensing Client did not finish within ${LICENSING_CLIENT_TIMEOUT_MS / 60000} minutes. Its log: ${LicensingClient.ClientLogPath()}`); - await this.printClientLogTail(); } if (!silent || exitCode !== 0) { - const maskedOutput = this.maskSerialInOutput(output); + const maskedOutput = redact(this.maskSerialInOutput(output)); const splitLines = maskedOutput.split(/\r?\n/); for (const line of splitLines) { @@ -567,16 +574,6 @@ export class LicensingClient { return output; } - private async printClientLogTail(lineCount: number = 40): Promise { - try { - const lines = (await fs.promises.readFile(LicensingClient.ClientLogPath(), 'utf-8')).split(/\r?\n/); - const tail = this.maskSerialInOutput(lines.slice(-lineCount).join('\n')); - this.logger.info(`Last ${lineCount} lines of the Unity Licensing Client log:\n${tail}`); - } catch (error) { - this.logger.debug(`Could not read the Unity Licensing Client log: ${error}`); - } - } - /** * Stops Unity.Licensing.Client instances left running in the background, such as the one the official Unity CLI starts. * A command line activation can wait on that instance indefinitely. Only done in CI, so a developer's own session is left alone. @@ -842,6 +839,8 @@ export class LicensingClient { args.push(`--include-personal`); } + // keep redacting the account details in every later command's output, such as --showEntitlements + this.secrets.push(username, password, ...(serial ? [serial, serial.slice(0, -4) + 'XXXX'] : [])); await this.exec(args); const activeLicenses = await this.GetActiveEntitlements(); diff --git a/src/unity-editor.ts b/src/unity-editor.ts index 453f99d8..31e2b2b2 100644 --- a/src/unity-editor.ts +++ b/src/unity-editor.ts @@ -13,6 +13,7 @@ import { KillProcess, KillChildProcesses, Exec, + redactSensitiveLiterals, } from './utilities'; import { UnityCli } from './unity-cli'; import { @@ -29,6 +30,9 @@ export interface EditorCommand { } export class UnityEditor { + /** Editor flags whose values are never logged. */ + private static readonly SENSITIVE_FLAGS = ['-username', '-password', '-cloudOrganization', '-serial']; + public readonly editorPath: string; public readonly editorRootPath: string; public readonly version: UnityVersion; @@ -119,7 +123,7 @@ export class UnityEditor { * @returns A new array with sensitive values redacted. */ public scrubSensitiveArgs(args: string[]): string[] { - const sensitiveFlags = ['-username', '-password', '-cloudOrganization', '-serial']; + const sensitiveFlags = UnityEditor.SENSITIVE_FLAGS; const scrubbedArgs: string[] = []; for (let i = 0; i < args.length; i++) { @@ -329,7 +333,10 @@ export class UnityEditor { await tryKillEditorProcesses(); if (exitCode !== undefined && exitCode !== 0 && unityRunOutput) { - const output = unityRunOutput.split(/\r?\n/).filter(line => line.length > 0 && !line.includes(' i > 0 && UnityEditor.SENSITIVE_FLAGS.includes(command.args[i - 1]!)); + const output = redactSensitiveLiterals(unityRunOutput, sensitiveValues) + .split(/\r?\n/).filter(line => line.length > 0 && !line.includes(' { expect(fallback).toHaveBeenCalledTimes(1); }); }); + +describe('licensing client output redaction', () => { + it('never logs the email, password or serial from the command or its output', async () => { + const client = new LicensingClient('6.x'); + (client as any).licenseClientPath = process.execPath; + const written: string[] = []; + jest.spyOn(process.stdout, 'write').mockImplementation((chunk: any) => { + written.push(String(chunk)); + return true; + }); + + await (client as any).exec(['-e', 'console.log(process.argv.slice(1).join(" "))', '--', '--username', 'user@example.com', '--password', 'hunter2222', '--serial', 'AB-CDEF-1234-5678']); + + const logged = written.join(''); + expect(logged).not.toContain('user@example.com'); + expect(logged).not.toContain('hunter2222'); + expect(logged).not.toContain('AB-CDEF-1234-5678'); + expect(logged).toContain('*****'); + }); +}); From e6b919492c65b7b66c8ed18ae064347d3a2e98df Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Sat, 24 Oct 2026 23:08:00 -0400 Subject: [PATCH 15/60] patch the macos licensing client's utf-8 license version constant --- src/license-client.ts | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/src/license-client.ts b/src/license-client.ts index f3d36f46..81d3c951 100644 --- a/src/license-client.ts +++ b/src/license-client.ts @@ -419,15 +419,32 @@ export class LicensingClient { await fs.promises.cp(clientRoot, patchedDirectory, { recursive: true, verbatimSymlinks: true }); const patchedFiles: string[] = []; - for (const file of await LicensingClient.listBinaries(patchedDirectory)) { - try { - await this.patchBinary(file, file, Buffer.from('6.x', 'utf16le'), Buffer.from(this.licenseVersion, 'utf16le')); - patchedFiles.push(file); - } catch (error) { - if (!(error instanceof Error && error.message === 'Could not find the search value')) { - throw error; + // Windows and Linux builds hold the value as a UTF-16 string; the macOS build only as the + // UTF-8 constant k_UnityLicenseVersionCompatibility, so that one is matched with its name + const constantName = Buffer.concat([Buffer.from('k_UnityLicenseVersionCompatibility'), Buffer.from([0x06])]); + const patterns: [Buffer, Buffer][] = [ + [Buffer.from('6.x', 'utf16le'), Buffer.from(this.licenseVersion, 'utf16le')], + [Buffer.concat([constantName, Buffer.from('6.x')]), Buffer.concat([constantName, Buffer.from(this.licenseVersion)])], + ]; + + const binaries = await LicensingClient.listBinaries(patchedDirectory); + + // the UTF-8 constant is only patched when no UTF-16 value exists, which leaves the Windows and Linux patch as it was + for (const [searchValue, replaceValue] of patterns) { + for (const file of binaries) { + try { + await this.patchBinary(file, file, searchValue, replaceValue); + patchedFiles.push(file); + } catch (error) { + if (!(error instanceof Error && error.message === 'Could not find the search value')) { + throw error; + } } } + + if (patchedFiles.length > 0) { + break; + } } if (patchedFiles.length === 0) { From 0e01ac0774289022ed0860795da0948bb6bf8db1 Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Sat, 24 Oct 2026 23:21:43 -0400 Subject: [PATCH 16/60] retry the android sdk install and log licensing client problems on timeout --- src/android-sdk.ts | 8 +++++++- src/license-client.ts | 26 +++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/android-sdk.ts b/src/android-sdk.ts index a788c559..cb2327c9 100644 --- a/src/android-sdk.ts +++ b/src/android-sdk.ts @@ -43,7 +43,13 @@ export async function CheckAndroidSdkInstalled(editor: UnityEditor, projectPath: const javaSdk = await getJDKPath(editor); await execSdkManager(sdkManagerPath, javaSdk, ['--licenses']); await execSdkManager(sdkManagerPath, javaSdk, ['--update']); - await execSdkManager(sdkManagerPath, javaSdk, ['platform-tools', `platforms;android-${androidTargetSdk}`]); + try { + await execSdkManager(sdkManagerPath, javaSdk, ['platform-tools', `platforms;android-${androidTargetSdk}`]); + } catch (error) { + // sdkmanager sometimes fails to unzip a package it just downloaded; a second attempt downloads it again + logger.warn(`${error}\nRetrying the Android SDK install...`); + await execSdkManager(sdkManagerPath, javaSdk, ['platform-tools', `platforms;android-${androidTargetSdk}`]); + } sdkPath = await getAndroidSdkPath(editor, androidTargetSdk); if (!sdkPath) { diff --git a/src/license-client.ts b/src/license-client.ts index 81d3c951..804bff12 100644 --- a/src/license-client.ts +++ b/src/license-client.ts @@ -507,6 +507,7 @@ export class LicensingClient { const secrets = [...this.secrets, ...argSecrets]; const redact = (text: string) => redactSensitiveLiterals(text, secrets); this.logger.startGroup(`\x1b[34m${this.licenseClientPath} ${redact(args.join(' '))}\x1b[0m`); + const logOffset = await this.getClientLogSize(); await fs.promises.access(this.licenseClientPath!, fs.constants.R_OK | fs.constants.X_OK); try { @@ -566,8 +567,8 @@ export class LicensingClient { }); } finally { if (timedOut) { - // the client log can hold account details and tokens, so only its path is printed this.logger.error(`Unity Licensing Client did not finish within ${LICENSING_CLIENT_TIMEOUT_MS / 60000} minutes. Its log: ${LicensingClient.ClientLogPath()}`); + await this.printClientLogProblems(logOffset, secrets); } if (!silent || exitCode !== 0) { @@ -591,6 +592,29 @@ export class LicensingClient { return output; } + /** + * Prints the warning and error lines the client logged since `offset`, to show why a command hung. + * The log can hold account details and tokens, so only problem lines are printed, with the known secrets, + * email addresses and long token-like strings redacted. + */ + private async printClientLogProblems(offset: number, secrets: string[], maxLines: number = 30): Promise { + try { + const content = await fs.promises.readFile(LicensingClient.ClientLogPath()); + const lines = content.subarray(offset).toString('utf-8').split(/\r?\n/) + .filter(line => /\b(error|warn|warning|exception|fail|failed|timeout|denied)\b/i.test(line)) + .slice(-maxLines) + .map(line => redactSensitiveLiterals(line, secrets) + .replace(/[^\s@"']+@[^\s@"']+\.[^\s@"']+/g, '*****') + .replace(/[A-Za-z0-9+/_=.-]{24,}/g, '*****')); + + if (lines.length > 0) { + this.logger.info(`Problems the Unity Licensing Client logged:\n${lines.join('\n')}`); + } + } catch (error) { + this.logger.debug(`Could not read the Unity Licensing Client log: ${error}`); + } + } + /** * Stops Unity.Licensing.Client instances left running in the background, such as the one the official Unity CLI starts. * A command line activation can wait on that instance indefinitely. Only done in CI, so a developer's own session is left alone. From ce563353634e5b0ca5a6f1a65c98f1fdc14d2c03 Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Sat, 24 Oct 2026 23:38:35 -0400 Subject: [PATCH 17/60] download installers to random temp names --- src/unity-installer.ts | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/unity-installer.ts b/src/unity-installer.ts index af7a992b..9632680b 100644 --- a/src/unity-installer.ts +++ b/src/unity-installer.ts @@ -1,6 +1,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import { randomUUID } from 'crypto'; import { spawn } from 'child_process'; import { Logger } from './logging'; import { UnityEditor } from './unity-editor'; @@ -377,7 +378,7 @@ export class UnityInstaller { ]; for (const url of packages) { - const pkgPath = path.join(GetTempDir(), path.basename(url)); + const pkgPath = tempInstallerPath('.pkg'); await DownloadFile(url, pkgPath); try { @@ -426,9 +427,9 @@ export class UnityInstaller { throw new Error(`Invalid install directory: ${installDir}`); } - const installerPath = path.join(GetTempDir(), path.basename(url)); + const installerPath = tempInstallerPath('.exe'); await DownloadFile(url, installerPath); - this.logger.info(`Running ${path.basename(installerPath)}...`); + this.logger.info(`Running ${path.basename(url)}...`); try { const exitCode = await new Promise((resolve, reject) => { @@ -442,7 +443,7 @@ export class UnityInstaller { }); if (exitCode !== 0) { - throw new Error(`${path.basename(installerPath)} failed with exit code ${exitCode}`); + throw new Error(`${path.basename(url)} failed with exit code ${exitCode}`); } } finally { await fs.promises.rm(installerPath, { force: true }); @@ -511,7 +512,7 @@ export class UnityInstaller { } case 'darwin': { const url = `https://beta.unity3d.com/download/unity-${unityVersion.version}.dmg`; - const installerPath = path.join(GetTempDir(), `UnitySetup-${unityVersion.version}.dmg`); + const installerPath = tempInstallerPath('.dmg'); await DownloadFile(url, installerPath); let mountPoint = ''; @@ -580,7 +581,7 @@ export class UnityInstaller { } if (debName) { - const downloadPath = path.join(GetTempDir(), debName); + const downloadPath = tempInstallerPath('.deb'); await DownloadFile(`https://archive.ubuntu.com/ubuntu/pool/main/o/openssl/${debName}`, downloadPath); try { @@ -855,3 +856,12 @@ export function resolveEditorExecutable(location: string | undefined): string | return candidates.find(candidate => fs.existsSync(candidate) && fs.statSync(candidate).isFile()); } + +/** + * A fresh temp path for a downloaded installer. The name is random rather than taken from the URL, + * so nothing from the requested version reaches the installer command line. + * @param extension The installer's file extension, e.g. .pkg. + */ +function tempInstallerPath(extension: string): string { + return path.join(GetTempDir(), `unity-installer-${randomUUID()}${extension}`); +} From cf78b4304d2a9d80d2f4d77f50e09f749e7b963f Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Sat, 24 Oct 2026 23:44:31 -0400 Subject: [PATCH 18/60] address copilot review: license status, redaction, latest, -quit, arch lookup, 5.x macos modules --- src/license-client.ts | 14 ++++- src/unity-cli.ts | 10 +++- src/unity-editor.ts | 4 +- src/unity-installer.ts | 131 ++++++++++++++++++++++++++++++++++------- 4 files changed, 133 insertions(+), 26 deletions(-) diff --git a/src/license-client.ts b/src/license-client.ts index 804bff12..323ef5be 100644 --- a/src/license-client.ts +++ b/src/license-client.ts @@ -1092,9 +1092,19 @@ async function activateWithUnityCli(options: ActivateOptions): Promise<{ activat return { activated: true, token }; } +/** + * Reads whether a license is active. Throws when the CLI gives no clear answer (a timeout or an unparseable result), + * so callers fall back instead of assuming the license is gone. LICENSE_NOT_ACTIVE exits 4 with active: false. + */ async function isLicenseActive(cli: UnityCli): Promise { - const { envelope } = await cli.RunJson<{ active?: boolean }>(['license', 'status'], { timeoutMs: UNITY_CLI_LICENSE_TIMEOUT_MS }); - return envelope?.data?.active === true; + const { exitCode, envelope } = await cli.RunJson<{ active?: boolean }>(['license', 'status'], { timeoutMs: UNITY_CLI_LICENSE_TIMEOUT_MS }); + const active = envelope?.data?.active; + + if ((exitCode === 0 || exitCode === 4) && typeof active === 'boolean') { + return active; + } + + throw new Error(`unity license status exited with code ${exitCode} without a license state${formatEnvelopeErrors(envelope)}`); } function findToken(value: unknown): string | undefined { diff --git a/src/unity-cli.ts b/src/unity-cli.ts index 57052e3c..05edc6e1 100644 --- a/src/unity-cli.ts +++ b/src/unity-cli.ts @@ -67,9 +67,15 @@ export class UnityCli { public static async Get(version: string | undefined = undefined): Promise { configureEnvironment(); const requested = version ? normalizeVersion(version) : undefined; + + // latest resolves the manifest first, so an outdated CLI is not reused + if (requested === 'latest') { + return await UnityCli.Install('latest'); + } + const existing = await UnityCli.Find(); - if (existing && (!requested || requested === 'latest' || existing.version === requested)) { + if (existing && (!requested || existing.version === requested)) { Logger.instance.debug(`Using Unity CLI ${existing.version} at ${existing.executable}`); return exportCli(existing); } @@ -284,7 +290,7 @@ export class UnityCli { public async RunJson(args: string[], options: UnityCliRunOptions = {}): Promise<{ exitCode: number; envelope: UnityCliEnvelope | undefined; stdout: string; stderr: string }> { const result = await this.Run([...args, '--format', 'json'], { ...options, stream: false }); const envelope = parseEnvelope(result.stdout); - this.logger.debug(`unity ${args.join(' ')} [${result.exitCode}]:\n${result.stdout}${stripHint(result.stderr)}`); + this.logger.debug(`unity ${redactSensitiveLiterals(args.join(' '), options.redactLiterals)} [${result.exitCode}]:\n${result.stdout}${stripHint(result.stderr)}`); return { exitCode: result.exitCode, envelope, stdout: result.stdout, stderr: result.stderr }; } diff --git a/src/unity-editor.ts b/src/unity-editor.ts index 31e2b2b2..cc6de7db 100644 --- a/src/unity-editor.ts +++ b/src/unity-editor.ts @@ -358,14 +358,16 @@ export class UnityEditor { /** * Decides whether a run goes through the official CLI's `unity run`. - * It needs a project, and is skipped for editors and hosts it is not verified with: + * It needs a project and a -quit argument, and is skipped for editors and hosts it is not verified with: * editors older than 2019.1, Linux runs that need xvfb, and x86_64 editors under Rosetta. * Set UNITY_CLI_DIRECT_LAUNCH=true to always launch the editor directly. */ private async getUnityCliForRun(command: EditorCommand): Promise { const directLaunch = process.env.UNITY_CLI_DIRECT_LAUNCH?.trim().toLowerCase(); + // unity run always quits the editor, so commands that manage their own shutdown launch directly if (!command.projectPath || + !command.args.includes('-quit') || directLaunch === 'true' || directLaunch === '1' || this.version.isLessThan('2019.1.0') || (process.platform === 'linux' && !command.args.includes('-nographics')) || diff --git a/src/unity-installer.ts b/src/unity-installer.ts index 9632680b..7014cfbd 100644 --- a/src/unity-installer.ts +++ b/src/unity-installer.ts @@ -263,10 +263,22 @@ export class UnityInstaller { private async findCliEditor(cli: UnityCli, unityVersion: UnityVersion): Promise { const { exitCode, envelope } = await cli.RunJson(['editors', 'path', unityVersion.version]); + // editors path takes no architecture, so the binary is checked to not return another architecture's install + const matchesArchitecture = (editorPath: string) => { + const architectures = getExecutableArchitectures(editorPath); + const matches = !architectures || architectures.includes(unityVersion.architecture); + + if (!matches) { + this.logger.debug(`Skipping ${editorPath}: built for ${architectures!.join(', ')}, not ${unityVersion.architecture}`); + } + + return matches; + }; + if (exitCode === 0 && envelope?.success !== false) { const editorPath = resolveEditorExecutable(FindAbsolutePath(envelope?.data)); - if (editorPath) { + if (editorPath && matchesArchitecture(editorPath)) { this.logger.debug(`Found installed editor: "${editorPath}"`); return editorPath; } @@ -281,15 +293,16 @@ export class UnityInstaller { return undefined; } + // the architecture-specific folder first; the plain one only when its binary is the requested architecture const candidates = [ - path.join(installPath, unityVersion.version), path.join(installPath, `${unityVersion.version}-${unityVersion.architecture.toLowerCase()}`), + path.join(installPath, unityVersion.version), ]; for (const candidate of candidates) { const editorPath = resolveEditorExecutable(candidate); - if (editorPath) { + if (editorPath && matchesArchitecture(editorPath)) { this.logger.info(`Registering existing Unity ${unityVersion.version} at ${editorPath}`); const result = await cli.Run(['editors', 'add', editorPath]); @@ -362,32 +375,21 @@ export class UnityInstaller { } case 'darwin': { let editorPath = resolveEditorExecutable(installDir); + const modulePackages = moduleNames.map(name => `${baseUrl}/MacEditorTargetInstaller/UnitySetup-${name}-Support-for-Editor-${unityVersion.version}.pkg`); if (editorPath) { - if (moduleNames.length > 0) { - this.logger.warn(`Unity ${unityVersion.version} is already installed; add modules to it with its own installers`); + if (modulePackages.length > 0) { + // the target support packages install into /Applications/Unity/PlaybackEngines, so they are moved into this editor afterwards + this.logger.ci(`Installing modules for Unity ${unityVersion.toString()}: ${moduleNames.join(', ')}`); + await this.installMacPackages(modulePackages); + await this.moveMacPlaybackEngines(installDir); } return editorPath; } this.logger.ci(`Installing Unity ${unityVersion.toString()}...`); - const packages = [ - `${baseUrl}/MacEditorInstaller/Unity-${unityVersion.version}.pkg`, - ...moduleNames.map(name => `${baseUrl}/MacEditorTargetInstaller/UnitySetup-${name}-Support-for-Editor-${unityVersion.version}.pkg`), - ]; - - for (const url of packages) { - const pkgPath = tempInstallerPath('.pkg'); - await DownloadFile(url, pkgPath); - - try { - await Exec('sudo', ['installer', '-pkg', pkgPath, '-target', '/'], { silent: true, showCommand: true }); - } finally { - await fs.promises.rm(pkgPath, { force: true }); - } - } - + await this.installMacPackages([`${baseUrl}/MacEditorInstaller/Unity-${unityVersion.version}.pkg`, ...modulePackages]); await this.moveMacApplicationsUnity(installDir); editorPath = resolveEditorExecutable(installDir); @@ -450,6 +452,40 @@ export class UnityInstaller { } } + private async installMacPackages(urls: string[]): Promise { + for (const url of urls) { + const pkgPath = tempInstallerPath('.pkg'); + await DownloadFile(url, pkgPath); + + try { + await Exec('sudo', ['installer', '-pkg', pkgPath, '-target', '/'], { silent: true, showCommand: true }); + } finally { + await fs.promises.rm(pkgPath, { force: true }); + } + } + } + + /** + * Moves target support that macOS packages installed to /Applications/Unity/PlaybackEngines into an existing versioned editor. + */ + private async moveMacPlaybackEngines(targetPath: string): Promise { + const sourcePath = path.join('/Applications', 'Unity', 'PlaybackEngines'); + + if (!fs.existsSync(sourcePath)) { + throw new Error(`The module packages did not install anything into ${sourcePath}`); + } + + const destinationPath = path.join(targetPath, 'PlaybackEngines'); + await fs.promises.mkdir(destinationPath, { recursive: true }); + + for (const item of await fs.promises.readdir(sourcePath)) { + await fs.promises.rm(path.join(destinationPath, item), { recursive: true, force: true }); + await fs.promises.cp(path.join(sourcePath, item), path.join(destinationPath, item), { recursive: true, verbatimSymlinks: true }); + } + + await fs.promises.rm(sourcePath, { recursive: true, force: true }); + } + /** * Moves an editor that a macOS package installed to /Applications/Unity into its versioned folder. */ @@ -865,3 +901,56 @@ export function resolveEditorExecutable(location: string | undefined): string | function tempInstallerPath(extension: string): string { return path.join(GetTempDir(), `unity-installer-${randomUUID()}${extension}`); } + +/** + * Reads the CPU architectures of a macOS editor binary from its Mach-O header. + * @param executablePath The editor executable. + * @returns The architectures, or undefined off macOS or when the header is not recognized, in which case callers accept the editor. + */ +export function getExecutableArchitectures(executablePath: string): ('X86_64' | 'ARM64')[] | undefined { + if (process.platform !== 'darwin') { + return undefined; + } + + const cpuTypes: { [cpuType: number]: 'X86_64' | 'ARM64' } = { 0x01000007: 'X86_64', 0x0100000c: 'ARM64' }; + let header: Buffer; + + try { + const fd = fs.openSync(executablePath, 'r'); + + try { + header = Buffer.alloc(4096); + fs.readSync(fd, header, 0, header.length, 0); + } finally { + fs.closeSync(fd); + } + } catch { + return undefined; + } + + const magic = header.readUInt32BE(0); + + if (magic === 0xcafebabe) { + // fat binary: big-endian count, then 20 bytes per architecture with the CPU type first + const count = header.readUInt32BE(4); + const architectures: ('X86_64' | 'ARM64')[] = []; + + for (let i = 0; i < count && 8 + i * 20 + 4 <= header.length; i++) { + const architecture = cpuTypes[header.readUInt32BE(8 + i * 20)]; + + if (architecture && !architectures.includes(architecture)) { + architectures.push(architecture); + } + } + + return architectures.length > 0 ? architectures : undefined; + } + + if (magic === 0xcffaedfe) { + // thin 64-bit binary, little-endian + const architecture = cpuTypes[header.readUInt32LE(4)]; + return architecture ? [architecture] : undefined; + } + + return undefined; +} From a76849070cd628cf6192f491097dd4dbfb6c125c Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:14:56 -0400 Subject: [PATCH 19/60] debug macos activation and print the redacted client log on timeout --- src/license-client.ts | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/src/license-client.ts b/src/license-client.ts index 323ef5be..73bf6be8 100644 --- a/src/license-client.ts +++ b/src/license-client.ts @@ -572,7 +572,10 @@ export class LicensingClient { } if (!silent || exitCode !== 0) { - const maskedOutput = redact(this.maskSerialInOutput(output)); + // --debug output can carry tokens, so it gets the same sanitizing as the client log + const maskedOutput = args.includes('--debug') + ? this.maskSerialInOutput(output).split(/\r?\n/).map(line => LicensingClient.sanitizeLogLine(line, secrets)).join('\n') + : redact(this.maskSerialInOutput(output)); const splitLines = maskedOutput.split(/\r?\n/); for (const line of splitLines) { @@ -597,18 +600,25 @@ export class LicensingClient { * The log can hold account details and tokens, so only problem lines are printed, with the known secrets, * email addresses and long token-like strings redacted. */ - private async printClientLogProblems(offset: number, secrets: string[], maxLines: number = 30): Promise { + /** + * Redacts the known secrets, email addresses and long token-like strings (JWTs, access and refresh tokens) from a client log line. + */ + private static sanitizeLogLine(line: string, secrets: string[]): string { + return redactSensitiveLiterals(line, secrets) + .replace(/[^\s@"'<>]+@[^\s@"'<>]+\.[^\s@"'<>]+/g, '*****') + .replace(/[A-Za-z0-9+_=.-]{32,}/g, '*****'); + } + + private async printClientLogProblems(offset: number, secrets: string[], maxLines: number = 40): Promise { try { const content = await fs.promises.readFile(LicensingClient.ClientLogPath()); const lines = content.subarray(offset).toString('utf-8').split(/\r?\n/) - .filter(line => /\b(error|warn|warning|exception|fail|failed|timeout|denied)\b/i.test(line)) + .filter(line => line.trim().length > 0) .slice(-maxLines) - .map(line => redactSensitiveLiterals(line, secrets) - .replace(/[^\s@"']+@[^\s@"']+\.[^\s@"']+/g, '*****') - .replace(/[A-Za-z0-9+/_=.-]{24,}/g, '*****')); + .map(line => LicensingClient.sanitizeLogLine(line, secrets)); if (lines.length > 0) { - this.logger.info(`Problems the Unity Licensing Client logged:\n${lines.join('\n')}`); + this.logger.info(`Last lines the Unity Licensing Client logged (redacted):\n${lines.join('\n')}`); } } catch (error) { this.logger.debug(`Could not read the Unity Licensing Client log: ${error}`); @@ -880,6 +890,11 @@ export class LicensingClient { args.push(`--include-personal`); } + if (process.platform === 'darwin') { + // macOS activations have hung without logging anything at the default level + args.push('--debug'); + } + // keep redacting the account details in every later command's output, such as --showEntitlements this.secrets.push(username, password, ...(serial ? [serial, serial.slice(0, -4) + 'XXXX'] : [])); await this.exec(args); From e125fa0c9ca05bcd1539e4b04eb8f2aca4cd4003 Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:44:47 -0400 Subject: [PATCH 20/60] create the macos license folder up front instead of letting the client prompt for it --- src/license-client.ts | 40 +++++++++++++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/src/license-client.ts b/src/license-client.ts index 73bf6be8..45f107e3 100644 --- a/src/license-client.ts +++ b/src/license-client.ts @@ -87,12 +87,47 @@ export class LicensingClient { throw new Error('Failed to find Unity.Licensing.Client. Install the Unity CLI with "unity-cli setup-cli", or set UNITY_LICENSING_CLIENT_PATH.'); } + await LicensingClient.ensureMacLicenseDirectory(); + this.licenseClientPath = licenseClientPath; await fs.promises.access(this.licenseClientPath, fs.constants.X_OK); this.logger.debug(`Using Unity Licensing Client at ${this.licenseClientPath}`); return this.licenseClientPath; } + /** + * On macOS the client writes Unity_lic.ulf to /Library/Application Support/Unity. When that folder is missing or not + * writable it asks for an administrator password in a dialog, which never returns on a headless runner. + * The Unity Hub installer used to create it; without the Hub, it is created here with non-interactive sudo. + */ + private static async ensureMacLicenseDirectory(): Promise { + if (process.platform !== 'darwin') { + return; + } + + const directory = '/Library/Application Support/Unity'; + + try { + await fs.promises.access(directory, fs.constants.W_OK); + return; + } catch { + // missing or not writable + } + + for (const args of [['-n', 'mkdir', '-p', directory], ['-n', 'chmod', '777', directory]]) { + const exitCode = await new Promise((resolve) => { + const child = spawn('sudo', args, { stdio: 'ignore' }); + child.on('error', () => resolve(1)); + child.on('close', (code) => resolve(code ?? 1)); + }); + + if (exitCode !== 0) { + Logger.instance.warn(`Could not prepare ${directory} (sudo ${args.slice(1).join(' ')} exited with code ${exitCode}). Activation may wait for an administrator password.`); + return; + } + } + } + /** * The folder that holds the whole licensing client: the bundle root for a macOS bundle (…/Contents/MacOS/client), otherwise the executable's folder. */ @@ -890,11 +925,6 @@ export class LicensingClient { args.push(`--include-personal`); } - if (process.platform === 'darwin') { - // macOS activations have hung without logging anything at the default level - args.push('--debug'); - } - // keep redacting the account details in every later command's output, such as --showEntitlements this.secrets.push(username, password, ...(serial ? [serial, serial.slice(0, -4) + 'XXXX'] : [])); await this.exec(args); From a0e0c51b9e6b6d983320437de6db8c47132be2c7 Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:05:28 -0400 Subject: [PATCH 21/60] link the legacy license file to wherever the client wrote it --- src/license-client.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/license-client.ts b/src/license-client.ts index 45f107e3..9e181b51 100644 --- a/src/license-client.ts +++ b/src/license-client.ts @@ -43,6 +43,8 @@ export class LicensingClient { private licenseClientPath: string | undefined; private readonly secrets: string[] = []; + /** Where the 4.x/5.x license link lives, and the folders the client may have written Unity_lic.ulf to. */ + private legacyLicense: { linkPath: string; commonDirectory: string; patchedDirectory: string } | undefined; private licenseVersion: string | undefined; /** @@ -509,6 +511,7 @@ export class LicensingClient { this.licenseClientPath = patchedClientPath; const unityCommonDir = this.getUnityCommonDir(); const legacyLicenseFile = path.join(unityCommonDir, `Unity_v${this.licenseVersion}.ulf`); + this.legacyLicense ??= { linkPath: legacyLicenseFile, commonDirectory: unityCommonDir, patchedDirectory }; await fs.promises.mkdir(unityCommonDir, { recursive: true }); try { @@ -935,9 +938,35 @@ export class LicensingClient { throw new Error(`Failed to activate license of type '${licenseType}'`); } + await this.linkLegacyLicenseFile(); this.logger.info(`Successfully activated license of type '${licenseType}'`); } + /** + * Points Unity_v4.x.ulf or Unity_v5.x.ulf at the license the client actually wrote. Clients that ignore + * UNITY_COMMON_DIR, such as the macOS one, write Unity_lic.ulf to the regular folder instead of the patched one. + */ + private async linkLegacyLicenseFile(): Promise { + if (!this.legacyLicense) { + return; + } + + const { linkPath, commonDirectory, patchedDirectory } = this.legacyLicense; + const candidates = [path.join(patchedDirectory, 'Unity_lic.ulf'), path.join(commonDirectory, 'Unity_lic.ulf')]; + const licensePath = candidates.find(candidate => fs.existsSync(candidate)); + + if (!licensePath) { + this.logger.warn(`No Unity_lic.ulf was written to ${candidates.join(' or ')}`); + return; + } + + await fs.promises.rm(linkPath, { force: true }); + await fs.promises.symlink(licensePath, linkPath); + // the license version decides whether a 4.x or 5.x editor accepts the file + const licenseVersion = (await fs.promises.readFile(licensePath, 'utf-8')).match(/ { if (licenseType === LicenseType.floating) { if (!token || token.length === 0) { From 8f1604c6eaf8c37989d57829217b6af89f899591 Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:22:23 -0400 Subject: [PATCH 22/60] use the hub-bundled licensing client for 4.x and 5.x editors on macos --- src/license-client.ts | 61 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/src/license-client.ts b/src/license-client.ts index 9e181b51..182440fc 100644 --- a/src/license-client.ts +++ b/src/license-client.ts @@ -6,6 +6,9 @@ import { Logger } from './logging'; import { UnityCli } from './unity-cli'; import { ResolveGlobToPath, + DownloadFile, + Exec, + GetTempDir, ResolvePathCandidates, redactSensitiveLiterals, tryParseJson @@ -63,6 +66,12 @@ export class LicensingClient { async init() { const licensingClientExecutable = process.platform === 'win32' ? 'Unity.Licensing.Client.exe' : 'Unity.Licensing.Client'; let licenseClientPath: string | undefined = process.env.UNITY_LICENSING_CLIENT_PATH; + const legacyVersion = this.licenseVersion ?? GetLegacyLicenseVersion(); + + if (!licenseClientPath && process.platform === 'darwin' && (legacyVersion === '4.x' || legacyVersion === '5.x')) { + // the macOS client the Unity CLI downloads cannot be patched for 4.x/5.x, so use the one Unity Hub bundled + licenseClientPath = await LicensingClient.getHubBundledClient(); + } if (!licenseClientPath) { try { @@ -97,6 +106,58 @@ export class LicensingClient { return this.licenseClientPath; } + /** + * Extracts the licensing client bundled with Unity Hub 3.12.0 on macOS. Its resolver is a separate assembly that + * the 4.x/5.x patch works on, unlike the single-file macOS client the Unity CLI downloads, whose licenses stay 6.x. + * Only the client is copied out; the Hub is not installed. + * @returns The extracted client executable. + */ + private static async getHubBundledClient(): Promise { + const hubVersion = '3.12.0'; + const arch = process.arch === 'arm64' ? 'arm64' : 'x64'; + const cacheDirectory = path.join(os.tmpdir(), `UnityHubLicensingClient-${hubVersion}-${arch}`); + const findClient = async () => (await fs.promises.readdir(cacheDirectory, { recursive: true })) + .map(entry => path.join(cacheDirectory, entry.toString())) + .find(entry => path.basename(entry) === 'Unity.Licensing.Client' && fs.statSync(entry).isFile()); + + if (fs.existsSync(cacheDirectory)) { + const cached = await findClient(); + + if (cached) { + return cached; + } + } + + const dmgPath = path.join(GetTempDir(), `UnityHubSetup-${hubVersion}-${arch}.dmg`); + const mountPoint = path.join(GetTempDir(), `UnityHub-${hubVersion}-${arch}`); + await DownloadFile(`https://public-cdn.cloud.unity3d.com/hub/${hubVersion}/UnityHubSetup-${arch}.dmg`, dmgPath); + await fs.promises.mkdir(mountPoint, { recursive: true }); + + try { + await Exec('hdiutil', ['attach', dmgPath, '-nobrowse', '-readonly', '-mountpoint', mountPoint], { silent: true, showCommand: true }); + + try { + const client = await ResolveGlobToPath([mountPoint, '**', 'Unity.Licensing.Client']); + const sourceDirectory = LicensingClient.clientRootDirectory(client); + await fs.promises.rm(cacheDirectory, { recursive: true, force: true }); + await fs.promises.cp(sourceDirectory, cacheDirectory, { recursive: true, verbatimSymlinks: true }); + } finally { + await Exec('hdiutil', ['detach', mountPoint, '-quiet'], { silent: true, showCommand: true }); + } + } finally { + await fs.promises.rm(dmgPath, { force: true }); + } + + const extracted = await findClient(); + + if (!extracted) { + throw new Error(`Failed to extract Unity.Licensing.Client from Unity Hub ${hubVersion}`); + } + + Logger.instance.info(`Using the Unity Licensing Client bundled with Unity Hub ${hubVersion}: ${extracted}`); + return extracted; + } + /** * On macOS the client writes Unity_lic.ulf to /Library/Application Support/Unity. When that folder is missing or not * writable it asks for an administrator password in a dialog, which never returns on a headless runner. From 96a09e6e9976ff3fa9d438e255c6be53618d69ba Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:54:20 -0400 Subject: [PATCH 23/60] pick the project template from the editor's template list --- .github/workflows/unity-build.yml | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/.github/workflows/unity-build.yml b/.github/workflows/unity-build.yml index f84e15c1..b178cb89 100644 --- a/.github/workflows/unity-build.yml +++ b/.github/workflows/unity-build.yml @@ -118,13 +118,24 @@ jobs: if [ "${RUNNER_ARCH}" = "ARM64" ] && [ "${major}" -ge 2021 ]; then arch="arm64" fi - unity projects create "Unity Project" --path "${project_root}" --editor-version "${UNITY_EDITOR_VERSION}" --architecture "${arch}" --template 3d --no-cloud + unity projects create "Unity Project" --path "${project_root}" --editor-version "${UNITY_EDITOR_VERSION}" --architecture "${arch}" --template "${template}" --no-cloud else unity-cli run --unity-editor "${UNITY_EDITOR_PATH}" -quit -createProject "${project_path}" fi } + # prefer the built-in 3D template like v3 did; the CLI's "3d" means URP, which older editors don't offer + template="3d" if [ "${major}" -ge 2019 ]; then - unity templates list --editor "${UNITY_EDITOR_VERSION}" --json || true + templates_json="$(unity templates list --editor "${UNITY_EDITOR_VERSION}" --json || true)" + echo "${templates_json}" + template="$(printf '%s' "${templates_json}" | node -e ' + let input = ""; process.stdin.on("data", c => input += c).on("end", () => { + let names = []; + try { names = (JSON.parse(input).data || []).map(t => t.name || t.id).filter(Boolean); } catch {} + const preferred = ["com.unity.template.3d", "com.unity.template.3d-cross-platform", "com.unity.template.universal", "com.unity.template.urp-blank"]; + process.stdout.write(preferred.find(p => names.includes(p)) || "3d"); + });')" + echo "Using project template: ${template}" fi create_rc=1 for attempt in 1 2 3; do From dc4ac4c7cd4abcc35cf6e23d606b2c0d040127cb Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Sun, 25 Oct 2026 11:14:32 -0400 Subject: [PATCH 24/60] cap job times and settle unity run on exit --- .github/workflows/integration-tests.yml | 3 +++ .github/workflows/unity-build.yml | 1 + src/unity-editor.ts | 15 ++++++++++++++- 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 872fef21..69da2f1a 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -13,6 +13,7 @@ jobs: utp-batch-contract: if: github.event.pull_request.draft == false runs-on: ubuntu-latest + timeout-minutes: 10 permissions: contents: read steps: @@ -28,6 +29,7 @@ jobs: setup: if: github.event.pull_request.draft == false runs-on: ubuntu-latest + timeout-minutes: 5 permissions: contents: read checks: write # to publish unit test results via checks github api @@ -73,6 +75,7 @@ jobs: needs: [setup, validate, validate-negative-scenarios] if: always() runs-on: ubuntu-latest + timeout-minutes: 5 permissions: contents: read steps: diff --git a/.github/workflows/unity-build.yml b/.github/workflows/unity-build.yml index b178cb89..ee27bb4b 100644 --- a/.github/workflows/unity-build.yml +++ b/.github/workflows/unity-build.yml @@ -18,6 +18,7 @@ jobs: build: name: ${{ matrix.name }} runs-on: ${{ matrix.os }} + timeout-minutes: 45 # successful rows take about 10 minutes; a hung editor or licensing call must not hold the matrix strategy: matrix: ${{ fromJSON(inputs.matrix) }} fail-fast: false diff --git a/src/unity-editor.ts b/src/unity-editor.ts index cc6de7db..21d9fa05 100644 --- a/src/unity-editor.ts +++ b/src/unity-editor.ts @@ -306,9 +306,22 @@ export class UnityEditor { procInfo = { pid: unityProcess.pid, ppid: process.pid, name: this.editorPath }; this.logger.debug(`Unity process started with pid: ${procInfo.pid}`); exitCode = await new Promise((resolve, reject) => { - unityProcess.on('close', (code) => { + // the editor can start a licensing client that outlives it and inherits unity run's piped + // stdout and stderr, so 'close' may never fire; settle on 'exit' and stop reading the pipes shortly after + let settled = false; + const settle = (code: number | null) => { + if (settled) { return; } + settled = true; logTail?.stopLogTail(); resolve(code === null ? 1 : code); + }; + unityProcess.on('close', settle); + unityProcess.on('exit', (code) => { + setTimeout(() => { + unityProcess.stdout?.destroy(); + unityProcess.stderr?.destroy(); + settle(code); + }, 1000); }); unityProcess.on('error', (error) => { this.logger.error(`Unity process error: ${error}`); From 887306dd58969ce685d282a570707de3a3f84297 Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Sun, 25 Oct 2026 11:26:37 -0400 Subject: [PATCH 25/60] sanitize client log lines without a polynomial regex --- src/license-client.ts | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/license-client.ts b/src/license-client.ts index 182440fc..30c809a4 100644 --- a/src/license-client.ts +++ b/src/license-client.ts @@ -694,20 +694,26 @@ export class LicensingClient { return output; } - /** - * Prints the warning and error lines the client logged since `offset`, to show why a command hung. - * The log can hold account details and tokens, so only problem lines are printed, with the known secrets, - * email addresses and long token-like strings redacted. - */ /** * Redacts the known secrets, email addresses and long token-like strings (JWTs, access and refresh tokens) from a client log line. + * The line is split on separator characters and each piece is checked on its own, which stays linear on any input. */ private static sanitizeLogLine(line: string, secrets: string[]): string { return redactSensitiveLiterals(line, secrets) - .replace(/[^\s@"'<>]+@[^\s@"'<>]+\.[^\s@"'<>]+/g, '*****') - .replace(/[A-Za-z0-9+_=.-]{32,}/g, '*****'); + .split(/([\s"'<>=,;()[\]]+)/) + .map(part => { + const at = part.indexOf('@'); + const isEmail = at > 0 && part.lastIndexOf('.') > at + 1; + const isToken = part.length >= 32 && /^[A-Za-z0-9+_.-]+$/.test(part); + return isEmail || isToken ? '*****' : part; + }) + .join(''); } + /** + * Prints the last lines the client logged since `offset`, to show why a command hung. + * The log can hold account details and tokens, so every line is sanitized first. + */ private async printClientLogProblems(offset: number, secrets: string[], maxLines: number = 40): Promise { try { const content = await fs.promises.readFile(LicensingClient.ClientLogPath()); From 780e79ec84c93e33fd443c2f5d7af9e7064eaf19 Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Sun, 25 Oct 2026 11:51:38 -0400 Subject: [PATCH 26/60] clean up editor helpers and background licensing processes the tool started --- src/license-client.ts | 26 ++++++++++++++++++++++++++ src/unity-editor.ts | 9 +++++++++ src/utilities.ts | 43 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+) diff --git a/src/license-client.ts b/src/license-client.ts index 30c809a4..e29773c6 100644 --- a/src/license-client.ts +++ b/src/license-client.ts @@ -8,6 +8,8 @@ import { ResolveGlobToPath, DownloadFile, Exec, + FindProcessesUnder, + KillProcess, GetTempDir, ResolvePathCandidates, redactSensitiveLiterals, @@ -1140,6 +1142,30 @@ export async function ActivateLicense(options: ActivateOptions): Promise { + try { + await returnLicense(licenseType, token); + } finally { + // returning is the last licensing step of a job, so stop what the licensing calls left running in the background + await LicensingClient.StopBackgroundClients(); + await stopUnityCliHelpers(); + } +} + +/** + * Stops background processes the official Unity CLI left running from its own folder. Only done in CI. + */ +async function stopUnityCliHelpers(): Promise { + const cliPath = process.env.UNITY_CLI_PATH; + + if (process.env.CI !== 'true' || !cliPath) { + return; + } + + const helpers = await FindProcessesUnder(path.dirname(cliPath)); + await Promise.all(helpers.map(helper => KillProcess(helper).catch(() => undefined))); +} + +async function returnLicense(licenseType: LicenseType, token: string | undefined): Promise { const logger = Logger.instance; const legacyLicenseVersion = GetLegacyLicenseVersion(); diff --git a/src/unity-editor.ts b/src/unity-editor.ts index 21d9fa05..5f4d8d4e 100644 --- a/src/unity-editor.ts +++ b/src/unity-editor.ts @@ -13,6 +13,7 @@ import { KillProcess, KillChildProcesses, Exec, + FindProcessesUnder, redactSensitiveLiterals, } from './utilities'; import { UnityCli } from './unity-cli'; @@ -169,12 +170,20 @@ export class UnityEditor { let unityProcess: ChildProcess; let unityRunOutput: string | undefined = undefined; + const editorRootPath = this.editorRootPath; + // helpers the editor starts from its own folder (licensing client, shader compiler, bee_backend) outlive it as + // orphans, out of reach of a parent-pid search; anything new under the editor folder after the run is ours + const preexistingEditorProcesses = new Set((await FindProcessesUnder(editorRootPath)).map(proc => proc.pid)); + async function tryKillEditorProcesses(): Promise { try { if (procInfo) { await KillProcess(procInfo); await KillChildProcesses(procInfo); } + + const leftovers = (await FindProcessesUnder(editorRootPath)).filter(proc => !preexistingEditorProcesses.has(proc.pid)); + await Promise.all(leftovers.map(proc => KillProcess(proc))); } catch (error) { Logger.instance.error(`Failed to kill Unity process: ${error}`); } diff --git a/src/utilities.ts b/src/utilities.ts index 531accc4..3ff00632 100644 --- a/src/utilities.ts +++ b/src/utilities.ts @@ -707,6 +707,49 @@ export async function KillChildProcesses(procInfo: ProcInfo): Promise { } } +/** + * Lists running processes whose executable lives under a directory, such as an editor's own helpers + * (Unity.Licensing.Client, UnityShaderCompiler, bee_backend and the like). + * @param directory The directory the executables live under. + * @returns The matching processes. + */ +export async function FindProcessesUnder(directory: string): Promise { + const root = path.resolve(directory).toLowerCase(); + const results: ProcInfo[] = []; + + try { + if (process.platform === 'win32') { + const output = await Exec('powershell', [ + '-NoProfile', + '-Command', + 'Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath } | ForEach-Object { "$($_.ProcessId)`t$($_.ParentProcessId)`t$($_.ExecutablePath)" }' + ], { silent: true, showCommand: false }); + + for (const line of output.split(/\r?\n/)) { + const [pid, ppid, executable] = line.split('\t'); + + if (pid && ppid && executable && path.resolve(executable).toLowerCase().startsWith(root)) { + results.push({ pid: parseInt(pid, 10), ppid: parseInt(ppid, 10), name: executable }); + } + } + } else { + const output = await Exec('ps', ['-axo', 'pid=,ppid=,args='], { silent: true, showCommand: false }); + + for (const line of output.split('\n')) { + const match = line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/); + + if (match && match[3]!.toLowerCase().startsWith(root)) { + results.push({ pid: parseInt(match[1]!, 10), ppid: parseInt(match[2]!, 10), name: match[3]! }); + } + } + } + } catch (error) { + logger.debug(`Failed to list processes under ${directory}: ${error}`); + } + + return results.filter(proc => proc.pid !== process.pid); +} + /** * Checks if the current process is running with elevated (administrator) privileges. * @returns True if the process is elevated, false otherwise. From d6ff8f8fd0b6108eaff9effe32a6141471a930a5 Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Sun, 25 Oct 2026 13:27:04 -0400 Subject: [PATCH 27/60] leave the licensing client running between runs, and launch android builds directly --- src/unity-editor.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/unity-editor.ts b/src/unity-editor.ts index 5f4d8d4e..fbc315a4 100644 --- a/src/unity-editor.ts +++ b/src/unity-editor.ts @@ -182,7 +182,10 @@ export class UnityEditor { await KillChildProcesses(procInfo); } - const leftovers = (await FindProcessesUnder(editorRootPath)).filter(proc => !preexistingEditorProcesses.has(proc.pid)); + // the licensing client is left alone: it may still be saving the license it just updated, and killing it + // then loses the license for the next run; return-license stops it at the end of the job instead + const leftovers = (await FindProcessesUnder(editorRootPath)) + .filter(proc => !preexistingEditorProcesses.has(proc.pid) && !/licensing/i.test(proc.name)); await Promise.all(leftovers.map(proc => KillProcess(proc))); } catch (error) { Logger.instance.error(`Failed to kill Unity process: ${error}`); @@ -387,9 +390,15 @@ export class UnityEditor { private async getUnityCliForRun(command: EditorCommand): Promise { const directLaunch = process.env.UNITY_CLI_DIRECT_LAUNCH?.trim().toLowerCase(); - // unity run always quits the editor, so commands that manage their own shutdown launch directly + // unity run always quits the editor, so commands that manage their own shutdown launch directly. + // Android builds start a Gradle daemon that inherits the editor's output, and unity run waits on that + // output until the daemon dies, so it never returns; those launch directly too + const buildTarget = command.args.indexOf('-buildTarget'); + const isAndroidBuild = buildTarget >= 0 && command.args[buildTarget + 1]?.toLowerCase() === 'android'; + if (!command.projectPath || !command.args.includes('-quit') || + isAndroidBuild || directLaunch === 'true' || directLaunch === '1' || this.version.isLessThan('2019.1.0') || (process.platform === 'linux' && !command.args.includes('-nographics')) || From d82778dd791bf525667eb9871853d55793ce580c Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Sun, 25 Oct 2026 13:36:09 -0400 Subject: [PATCH 28/60] list the editor log's error lines after a failed run, outside the log group --- src/unity-editor.ts | 40 ++++++++++++++++++++++++ src/unity-logging.ts | 62 ++++++++++++++++++++++++++++++++++++- tests/failure-recap.test.ts | 25 +++++++++++++++ 3 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 tests/failure-recap.test.ts diff --git a/src/unity-editor.ts b/src/unity-editor.ts index fbc315a4..8ebc7945 100644 --- a/src/unity-editor.ts +++ b/src/unity-editor.ts @@ -169,6 +169,7 @@ export class UnityEditor { let logTail: LogTailResult | null = null; let unityProcess: ChildProcess; let unityRunOutput: string | undefined = undefined; + let editorLogPath: string | undefined = undefined; const editorRootPath = this.editorRootPath; // helpers the editor starts from its own folder (licensing client, shader compiler, bee_backend) outlive it as @@ -241,6 +242,7 @@ export class UnityEditor { } const logPath: string = GetArgumentValueAsString('-logFile', command.args); + editorLogPath = logPath; logTail = TailLogFile(logPath, command.projectPath); // Scrub sensitive arguments before logging @@ -372,6 +374,10 @@ export class UnityEditor { } } + if (exitCode !== undefined && exitCode !== 0) { + await this.printFailureRecap(logTail, editorLogPath); + } + if (exitCode === undefined) { throw Error('Failed to start Unity!'); } else if (exitCode !== 0) { @@ -381,6 +387,40 @@ export class UnityEditor { } } + /** + * After a failed run, lists the error lines from the Editor log outside the collapsed log group, so the cause sits + * next to "Unity failed with exit code N". Stack frames are left out and the list is capped; with no error line, + * the log's last lines are printed instead. Annotations are not affected. + */ + private async printFailureRecap(logTail: LogTailResult | null, logPath: string | undefined): Promise { + const maxLines = 100; + const errorLines = logTail?.errorLines ?? []; + + if (errorLines.length > 0) { + const total = logTail!.errorLineCount(); + const shown = errorLines.slice(0, maxLines); + const more = total - shown.length; + this.logger.info(`Unity reported ${total} error line${total === 1 ? '' : 's'}:\n${shown.map(line => ` ${line}`).join('\n')}` + + (more > 0 ? `\n ... ${more} more in ${logPath ?? 'the Editor log'}` : '')); + return; + } + + if (!logPath) { + return; + } + + try { + const lines = (await fs.promises.readFile(logPath, 'utf-8')).split(/\r?\n/).filter(line => line.trim().length > 0); + const tail = lines.slice(-20); + + if (tail.length > 0) { + this.logger.info(`No error lines found; the last ${tail.length} lines of ${logPath}:\n${tail.map(line => ` ${line}`).join('\n')}`); + } + } catch (error) { + this.logger.debug(`Could not read ${logPath}: ${error}`); + } + } + /** * Decides whether a run goes through the official CLI's `unity run`. * It needs a project and a -quit argument, and is skipped for editors and hosts it is not verified with: diff --git a/src/unity-logging.ts b/src/unity-logging.ts index cbb1d253..ebb0ae03 100644 --- a/src/unity-logging.ts +++ b/src/unity-logging.ts @@ -35,6 +35,45 @@ export interface LogTailResult { stopLogTail: () => void; /** Collected telemetry objects parsed from lines beginning with '##utp:' */ telemetry: UTP[]; + /** Distinct error lines seen in the log, without stack frames, in the order they appeared (capped at {@link MAX_RECAP_STORED_LINES}). */ + errorLines: string[]; + /** How many distinct error lines were seen, including any beyond the stored cap. */ + errorLineCount: () => number; +} + +/** Upper bound on error lines kept in memory for the failure recap. */ +const MAX_RECAP_STORED_LINES = 1000; + +/** + * True for a stack frame line, which the failure recap leaves out: managed ` at ...` frames, + * Unity's `Class:Method (args)` frames, `(Filename: ... Line: N)` trailers and native `#N 0x...` frames. + * @param line A log line. + */ +export function isStackFrameLine(line: string): boolean { + const trimmed = line.trim(); + + return /^at\s/.test(trimmed) || + /^\(Filename: .* Line: -?\d+\)$/.test(trimmed) || + /^#\d+\s/.test(trimmed) || + /^0x[0-9a-f]+\s/i.test(trimmed) || + /^[\w.`<>[\]+]+:[\w.`<>[\]+]+ ?\(.*\)( \(at .*\))?$/.test(trimmed); +} + +/** + * True for a log line the failure recap lists: an error marker, an exception, a failure or a fatal batch mode message. + * @param line A log line. + */ +export function isErrorRecapLine(line: string): boolean { + if (/\b0 errors?\b/i.test(line)) { + return false; + } + + return /\berror\b/i.test(line) || + /Exception\b/.test(line) || + /\bfatal\b/i.test(line) || + /\bFailed to\b/i.test(line) || + /^Aborting batchmode/.test(line) || + /No valid Unity Editor license found/.test(line); } // Detects workflow command markers to avoid emitting duplicate annotations @@ -1153,6 +1192,21 @@ export function TailLogFile(logPath: string, projectPath: string | undefined): L const actionTableRenderer = new ActionTableRenderer(isStdoutTTY()); const utpLogPath = buildUtpLogPath(logPath); let telemetryFlushed = false; + const errorLines: string[] = []; + const seenErrorLines = new Set(); + const recordErrorLine = (line: string): void => { + const trimmed = line.trim(); + + if (trimmed.length === 0 || seenErrorLines.has(trimmed) || isStackFrameLine(trimmed)) { + return; + } + + seenErrorLines.add(trimmed); + + if (errorLines.length < MAX_RECAP_STORED_LINES) { + errorLines.push(trimmed); + } + }; const buildIssueKey = (file: string | undefined, lineNo: number | undefined, message: string): string => { const normalized = normalizeAnnotationPath(file, projectPath); const canonicalFile = (normalized.absoluteFile ?? normalizePathSlashes(file ?? '')).toLowerCase(); @@ -1236,6 +1290,8 @@ export function TailLogFile(logPath: string, projectPath: string | undefined): L } if (utp.message && 'severity' in utp && isElevatedUtpSeverity(utp.severity)) { + // the message only; its stack trace stays out of the failure recap + recordErrorLine(utp.message.split(/\r?\n/)[0] ?? ''); const normalizedPath = normalizeAnnotationPath(utp.file, projectPath); const stacktrace = sanitizeStackTrace(utp.stackTrace); const message = stacktrace == undefined ? utp.message : `${utp.message}\n${stacktrace}`; @@ -1278,6 +1334,10 @@ export function TailLogFile(logPath: string, projectPath: string | undefined): L return; } const scan = parsePlainLogIssue(line); + if (((scan && scan.severity === Severity.Error) || isErrorRecapLine(line)) && + !(scan && utpMessageMatchesBenignRemap(scan.message))) { + recordErrorLine(line); + } if (scan) { if (utpMessageMatchesBenignRemap(scan.message)) { if (Logger.instance.logLevel !== LogLevel.UTP) { @@ -1444,5 +1504,5 @@ export function TailLogFile(logPath: string, projectPath: string | undefined): L logEnded = true; } - return { tailPromise, stopLogTail, telemetry }; + return { tailPromise, stopLogTail, telemetry, errorLines, errorLineCount: () => seenErrorLines.size }; } diff --git a/tests/failure-recap.test.ts b/tests/failure-recap.test.ts new file mode 100644 index 00000000..0bfe9f24 --- /dev/null +++ b/tests/failure-recap.test.ts @@ -0,0 +1,25 @@ +import { isErrorRecapLine, isStackFrameLine } from '../src/unity-logging'; + +describe('failure recap line detection', () => { + it('lists error lines, including ones without a project file', () => { + expect(isErrorRecapLine("Assets/Foo.cs(2,8): error CS1029: #error: 'Intentional compiler error'")).toBe(true); + expect(isErrorRecapLine('No valid Unity Editor license found. Please activate your license.')).toBe(true); + expect(isErrorRecapLine('Aborting batchmode due to failure:')).toBe(true); + expect(isErrorRecapLine('NullReferenceException: Object reference not set to an instance of an object')).toBe(true); + expect(isErrorRecapLine('Failed to activate/update license.')).toBe(true); + }); + + it('skips ordinary lines and zero-error summaries', () => { + expect(isErrorRecapLine('Refreshing native plugins compatible for Editor in 1.23 ms')).toBe(false); + expect(isErrorRecapLine('Build completed with 0 errors')).toBe(false); + }); + + it('recognizes stack frames', () => { + expect(isStackFrameLine(' at UnityEditor.BuildPipeline.BuildPlayer (UnityEditor.BuildPlayerOptions options) [0x00000] in :0')).toBe(true); + expect(isStackFrameLine('UnityEngine.Debug:LogError (object)')).toBe(true); + expect(isStackFrameLine('Utilities.Editor.BuildPipeline.UnityPlayerBuildTools:StartCommandLineBuild () (at Library/PackageCache/x.cs:42)')).toBe(true); + expect(isStackFrameLine('(Filename: Assets/Foo.cs Line: 12)')).toBe(true); + expect(isStackFrameLine('#0 0x00007ff6 in Unity::Something')).toBe(true); + expect(isStackFrameLine("Assets/Foo.cs(2,8): error CS1029: #error: 'Intentional compiler error'")).toBe(false); + }); +}); From 368d14e57e54562815f8af671d58de5fc23cd1a1 Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Sun, 25 Oct 2026 13:38:21 -0400 Subject: [PATCH 29/60] match unity stack frames without a polynomial regex --- src/unity-logging.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/unity-logging.ts b/src/unity-logging.ts index ebb0ae03..d44382fd 100644 --- a/src/unity-logging.ts +++ b/src/unity-logging.ts @@ -56,7 +56,8 @@ export function isStackFrameLine(line: string): boolean { /^\(Filename: .* Line: -?\d+\)$/.test(trimmed) || /^#\d+\s/.test(trimmed) || /^0x[0-9a-f]+\s/i.test(trimmed) || - /^[\w.`<>[\]+]+:[\w.`<>[\]+]+ ?\(.*\)( \(at .*\))?$/.test(trimmed); + // Class:Method (args) [(at file:line)]: an anchored prefix plus a closing parenthesis keeps this linear + (/^[\w.`<>[\]+]+:[\w.`<>[\]+]+ ?\(/.test(trimmed) && trimmed.endsWith(')')); } /** From 4c634cf67ba2e4afd100615c87267e71d284a5ba Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Sun, 25 Oct 2026 13:38:46 -0400 Subject: [PATCH 30/60] match filename trailers without a backtracking regex --- src/unity-logging.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/unity-logging.ts b/src/unity-logging.ts index d44382fd..fe37b3ae 100644 --- a/src/unity-logging.ts +++ b/src/unity-logging.ts @@ -53,7 +53,7 @@ export function isStackFrameLine(line: string): boolean { const trimmed = line.trim(); return /^at\s/.test(trimmed) || - /^\(Filename: .* Line: -?\d+\)$/.test(trimmed) || + (trimmed.startsWith('(Filename: ') && / Line: -?\d+\)$/.test(trimmed)) || /^#\d+\s/.test(trimmed) || /^0x[0-9a-f]+\s/i.test(trimmed) || // Class:Method (args) [(at file:line)]: an anchored prefix plus a closing parenthesis keeps this linear From a0ef457d4039f95dbfc73e04572a02af1c2a86f1 Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:38:35 -0400 Subject: [PATCH 31/60] return the license on windows rows only, and accept a cached license file in activate-license --- .github/workflows/unity-build.yml | 4 +- src/cli.ts | 12 +++- src/license-client.ts | 92 +++++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+), 2 deletions(-) diff --git a/.github/workflows/unity-build.yml b/.github/workflows/unity-build.yml index ee27bb4b..3cbf54f7 100644 --- a/.github/workflows/unity-build.yml +++ b/.github/workflows/unity-build.yml @@ -308,5 +308,7 @@ jobs: echo "::error::Licensing Audit log file does not exist at ${LICENSING_AUDIT_LOG_PATH}" fi - name: Return License - if: always() + # macOS and Linux runners all report the same machine identity to Unity, so jobs share one activation and a + # return revokes it for every other job; Windows runners are a new machine every job, so only they return + if: ${{ always() && runner.os == 'Windows' }} run: unity-cli return-license --license personal diff --git a/src/cli.ts b/src/cli.ts index 5f8d9d56..8f4e716c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -15,6 +15,7 @@ import { UnityProject } from './unity-project'; import { CheckAndroidSdkInstalled } from './android-sdk'; import { ActivateLicense, + GetLicenseFilePath, LicenseType, LicensingClient, PrintLicenseContext, @@ -70,6 +71,7 @@ program.command('activate-license') .option('-p, --password ', 'Password for the Unity account. Used when the Unity CLI cannot activate a personal or professional license.') .option('-s, --serial ', 'License serial number. Required when activating a professional license.') .option('-c, --config ', 'Path to the configuration file, raw JSON, or base64 encoded JSON string. Required when activating a floating license.') + .option('-f, --license-file ', 'A cached license file (Unity_lic.ulf) from an earlier activation on a machine with the same identity: a path, the raw XML, or base64. Defaults to the UNITY_LICENSE environment variable. Falls back to the email and password when it cannot be used.') .option('--verbose', 'Enable verbose logging.') .option('--json', 'Prints the last line of output as JSON string.') .action(async (options) => { @@ -107,7 +109,8 @@ program.command('activate-license') servicesConfig: options.config, serial: options.serial, username: options.email, - password: options.password + password: options.password, + ...(options.licenseFile ? { licenseFile: options.licenseFile } : {}), }); if (licenseType === LicenseType.floating && token && options.json) { @@ -159,6 +162,13 @@ program.command('license-context') process.exit(0); }); +program.command('license-file-path') + .description('Prints the path to the machine\'s Unity license file (Unity_lic.ulf).') + .action(async () => { + process.stdout.write(`${GetLicenseFilePath()}\n`); + process.exit(0); + }); + program.command('licensing-client-logs') .description('Prints the path to the Unity Licensing Client log file.') .action(async () => { diff --git a/src/license-client.ts b/src/license-client.ts index e29773c6..d255080c 100644 --- a/src/license-client.ts +++ b/src/license-client.ts @@ -33,6 +33,11 @@ export interface ActivateOptions { username?: string; /** The Unity ID password */ password?: string; + /** + * A license file (Unity_lic.ulf) from an earlier activation: a path, the raw XML, or base64. Defaults to UNITY_LICENSE. + * It only works on a machine with the same identity it was activated on. + */ + licenseFile?: string; } /** @@ -1111,6 +1116,19 @@ export async function ActivateLicense(options: ActivateOptions): Promise 0 && + (options.licenseType === LicenseType.personal || options.licenseType === LicenseType.professional)) { + try { + if (await activateFromLicenseFile(licenseFile)) { + return undefined; + } + } catch (error) { + logger.warn(`The license file could not be used, activating with the account instead.\n${error}`); + } + } + let cliError: unknown; try { @@ -1211,6 +1229,80 @@ export async function PrintLicenseContext(): Promise { await new LicensingClient(GetLegacyLicenseVersion()).Context(); } +/** + * Gets where Unity reads the machine's license file, Unity_lic.ulf. + */ +export function GetLicenseFilePath(): string { + switch (process.platform) { + case 'win32': + return path.join(process.env.PROGRAMDATA || 'C:\\ProgramData', 'Unity', 'Unity_lic.ulf'); + case 'darwin': + return path.join('/Library', 'Application Support', 'Unity', 'Unity_lic.ulf'); + default: + return path.join(process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'), 'unity3d', 'Unity', 'Unity_lic.ulf'); + } +} + +/** + * Activates from a cached license file: `unity license activate --file` first, then by placing the file where Unity reads it. + * The file's contents are never logged. + * @param licenseFile A path to the license file, its raw XML, or base64. + * @returns True when a license is active afterwards. + */ +async function activateFromLicenseFile(licenseFile: string): Promise { + const logger = Logger.instance; + const trimmed = licenseFile.trim(); + let content: string; + + if (!trimmed.startsWith('<') && fs.existsSync(trimmed)) { + content = await fs.promises.readFile(trimmed, 'utf-8'); + } else if (trimmed.startsWith('<')) { + content = trimmed; + } else { + content = Buffer.from(trimmed, 'base64').toString('utf-8'); + } + + if (!content.includes('')) { + throw new Error('The license file is not a Unity license (Unity_lic.ulf).'); + } + + logger.info('Activating from the cached license file...'); + const tempDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'unity-license-')); + const tempFile = path.join(tempDirectory, 'Unity_lic.ulf'); + + try { + await fs.promises.writeFile(tempFile, content, { encoding: 'utf-8', mode: 0o600 }); + + try { + const cli = await UnityCli.Get(); + const { exitCode, envelope } = await cli.RunJson(['license', 'activate', '--file', tempFile], { timeoutMs: UNITY_CLI_LICENSE_TIMEOUT_MS }); + + if (exitCode === 0 && envelope?.success !== false && await isLicenseActive(cli)) { + logger.info('Successfully activated from the cached license file with the Unity CLI'); + return true; + } + + logger.warn(`unity license activate --file exited with code ${exitCode}${formatEnvelopeErrors(envelope)}, placing the license file directly...`); + } catch (error) { + logger.warn(`Unity CLI activation from the license file failed, placing the license file directly...\n${error}`); + } + + const licensePath = GetLicenseFilePath(); + await fs.promises.mkdir(path.dirname(licensePath), { recursive: true }); + await fs.promises.copyFile(tempFile, licensePath); + const active = await new LicensingClient().GetActiveEntitlements(); + + if (active.length > 0) { + logger.info(`Successfully activated from the cached license file (${active.join(', ')})`); + return true; + } + + return false; + } finally { + await fs.promises.rm(tempDirectory, { recursive: true, force: true }); + } +} + async function activateWithUnityCli(options: ActivateOptions): Promise<{ activated: boolean; token?: string | undefined; reason?: string | undefined }> { const args = ['license', 'activate']; const redactLiterals: string[] = []; From e87c057d4a3ceb4605ed80f00f2acb8a43803e02 Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:47:09 -0400 Subject: [PATCH 32/60] cache the license file per os for macos and linux rows --- .github/workflows/unity-build.yml | 47 ++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/.github/workflows/unity-build.yml b/.github/workflows/unity-build.yml index 3cbf54f7..5dba36d0 100644 --- a/.github/workflows/unity-build.yml +++ b/.github/workflows/unity-build.yml @@ -101,11 +101,56 @@ jobs: echo "- path: \`${UNITY_EDITOR_PATH:-n/a}\`" } >> "$GITHUB_STEP_SUMMARY" fi + # macOS and Linux runners all report the same machine identity to Unity, so one license per OS serves every job: + # those rows reuse a cached license file (encrypted with the existing UNITY_PASSWORD secret) and never return it. + # Windows runners are a new machine every job, so Windows rows activate with the account and return the license. + - name: Restore cached license + if: ${{ matrix.unity-version != 'none' && runner.os != 'Windows' }} + uses: actions/cache/restore@v6 + with: + path: ${{ runner.temp }}/unity-license-cache + key: unity-license-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}-${{ strategy.job-index }} + restore-keys: unity-license-${{ runner.os }}- - name: Activate License + id: activate-license if: ${{ matrix.unity-version != 'none' }} + env: + LICENSE_CACHE: ${{ runner.temp }}/unity-license-cache + LICENSE_CACHE_KEY: ${{ secrets.UNITY_PASSWORD }} run: | unity-cli license-context - unity-cli activate-license --license personal --email "${{ secrets.UNITY_USERNAME }}" --password "${{ secrets.UNITY_PASSWORD }}" + major="${UNITY_EDITOR_VERSION%%.*}" + # 4.x and 5.x editors need their own license version, so they always activate with the account + use_cache=false + if [ "${RUNNER_OS}" != "Windows" ] && [ "${major}" -ge 2017 ]; then + use_cache=true + fi + cached_license="${RUNNER_TEMP}/Unity_lic.ulf" + if [ "${use_cache}" = true ] && [ -f "${LICENSE_CACHE}/license.enc" ]; then + if openssl enc -d -aes-256-cbc -pbkdf2 -pass env:LICENSE_CACHE_KEY -in "${LICENSE_CACHE}/license.enc" -out "${cached_license}"; then + export UNITY_LICENSE="${cached_license}" + fi + fi + activate_rc=0 + unity-cli activate-license --license personal --email "${{ secrets.UNITY_USERNAME }}" --password "${{ secrets.UNITY_PASSWORD }}" || activate_rc=$? + rm -f "${cached_license}" + if [ "${activate_rc}" -ne 0 ]; then + exit "${activate_rc}" + fi + if [ "${use_cache}" = true ]; then + license_file="$(unity-cli license-file-path)" + if [ -f "${license_file}" ]; then + mkdir -p "${LICENSE_CACHE}" + openssl enc -aes-256-cbc -pbkdf2 -salt -pass env:LICENSE_CACHE_KEY -in "${license_file}" -out "${LICENSE_CACHE}/license.enc" + echo "cache-license=true" >> "$GITHUB_OUTPUT" + fi + fi + - name: Save cached license + if: ${{ steps.activate-license.outputs.cache-license == 'true' }} + uses: actions/cache/save@v6 + with: + path: ${{ runner.temp }}/unity-license-cache + key: unity-license-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}-${{ strategy.job-index }} - name: Create Unity Project if: ${{ matrix.unity-version != 'none' }} run: | From 9eaa53456a7b82c2ea89c610f1eb587e495c6bbf Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:53:22 -0400 Subject: [PATCH 33/60] keep the macos and linux license in repo secrets instead of the actions cache --- .github/workflows/unity-build.yml | 70 ++++++++++++++----------------- src/license-client.ts | 3 ++ 2 files changed, 35 insertions(+), 38 deletions(-) diff --git a/.github/workflows/unity-build.yml b/.github/workflows/unity-build.yml index 5dba36d0..1d0dec06 100644 --- a/.github/workflows/unity-build.yml +++ b/.github/workflows/unity-build.yml @@ -14,6 +14,12 @@ on: required: true UNITY_PASSWORD: required: true + CI_TOKEN: + required: false + UNITY_LICENSE_MACOS: + required: false + UNITY_LICENSE_LINUX: + required: false jobs: build: name: ${{ matrix.name }} @@ -102,55 +108,43 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" fi # macOS and Linux runners all report the same machine identity to Unity, so one license per OS serves every job: - # those rows reuse a cached license file (encrypted with the existing UNITY_PASSWORD secret) and never return it. - # Windows runners are a new machine every job, so Windows rows activate with the account and return the license. - - name: Restore cached license - if: ${{ matrix.unity-version != 'none' && runner.os != 'Windows' }} - uses: actions/cache/restore@v6 - with: - path: ${{ runner.temp }}/unity-license-cache - key: unity-license-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}-${{ strategy.job-index }} - restore-keys: unity-license-${{ runner.os }}- + # those rows activate from the license kept in the UNITY_LICENSE_MACOS / UNITY_LICENSE_LINUX repo secrets and never + # return it. When that secret is missing or no longer valid, the row activates with the account and stores the new + # license there with CI_TOKEN for the next run. Windows runners are a new machine every job, so Windows rows + # activate with the account and return the license. - name: Activate License id: activate-license if: ${{ matrix.unity-version != 'none' }} env: - LICENSE_CACHE: ${{ runner.temp }}/unity-license-cache - LICENSE_CACHE_KEY: ${{ secrets.UNITY_PASSWORD }} + UNITY_LICENSE: ${{ runner.os == 'macOS' && secrets.UNITY_LICENSE_MACOS || runner.os == 'Linux' && secrets.UNITY_LICENSE_LINUX || '' }} run: | unity-cli license-context major="${UNITY_EDITOR_VERSION%%.*}" # 4.x and 5.x editors need their own license version, so they always activate with the account - use_cache=false + shared_identity=false if [ "${RUNNER_OS}" != "Windows" ] && [ "${major}" -ge 2017 ]; then - use_cache=true - fi - cached_license="${RUNNER_TEMP}/Unity_lic.ulf" - if [ "${use_cache}" = true ] && [ -f "${LICENSE_CACHE}/license.enc" ]; then - if openssl enc -d -aes-256-cbc -pbkdf2 -pass env:LICENSE_CACHE_KEY -in "${LICENSE_CACHE}/license.enc" -out "${cached_license}"; then - export UNITY_LICENSE="${cached_license}" - fi - fi - activate_rc=0 - unity-cli activate-license --license personal --email "${{ secrets.UNITY_USERNAME }}" --password "${{ secrets.UNITY_PASSWORD }}" || activate_rc=$? - rm -f "${cached_license}" - if [ "${activate_rc}" -ne 0 ]; then - exit "${activate_rc}" + shared_identity=true + else + unset UNITY_LICENSE fi - if [ "${use_cache}" = true ]; then - license_file="$(unity-cli license-file-path)" - if [ -f "${license_file}" ]; then - mkdir -p "${LICENSE_CACHE}" - openssl enc -aes-256-cbc -pbkdf2 -salt -pass env:LICENSE_CACHE_KEY -in "${license_file}" -out "${LICENSE_CACHE}/license.enc" - echo "cache-license=true" >> "$GITHUB_OUTPUT" - fi + echo "shared-identity=${shared_identity}" >> "$GITHUB_OUTPUT" + # activate-license writes license-source=license-file|account to this step's outputs + unity-cli activate-license --license personal --email "${{ secrets.UNITY_USERNAME }}" --password "${{ secrets.UNITY_PASSWORD }}" + - name: Store license secret + # only when a shared-identity row had to activate with the account, i.e. the stored license was missing or stale + if: ${{ steps.activate-license.outputs.shared-identity == 'true' && steps.activate-license.outputs.license-source == 'account' }} + continue-on-error: true + env: + GH_TOKEN: ${{ secrets.CI_TOKEN }} + run: | + if [ -z "${GH_TOKEN}" ]; then + echo "::warning::CI_TOKEN is not available, so the license is not stored for the next run" + exit 0 fi - - name: Save cached license - if: ${{ steps.activate-license.outputs.cache-license == 'true' }} - uses: actions/cache/save@v6 - with: - path: ${{ runner.temp }}/unity-license-cache - key: unity-license-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}-${{ strategy.job-index }} + secret_name="UNITY_LICENSE_$(echo "${RUNNER_OS}" | tr '[:lower:]' '[:upper:]')" + # gh reads the value from stdin, so the license never appears on a command line or in the log + gh secret set "${secret_name}" --repo "${GITHUB_REPOSITORY}" < "$(unity-cli license-file-path)" + echo "Stored the license in ${secret_name}" - name: Create Unity Project if: ${{ matrix.unity-version != 'none' }} run: | diff --git a/src/license-client.ts b/src/license-client.ts index d255080c..73665db7 100644 --- a/src/license-client.ts +++ b/src/license-client.ts @@ -1122,6 +1122,8 @@ export async function ActivateLicense(options: ActivateOptions): Promise Date: Sun, 25 Oct 2026 21:04:56 -0400 Subject: [PATCH 34/60] fix the security audit findings for v4 and pass the .app bundle to unity run on macos --- .../actions/run-unity-test-batch/action.yml | 11 +- .github/workflows/publish.yml | 5 +- .github/workflows/unity-build.yml | 42 +- .github/workflows/unity-release-discovery.yml | 1 + README.md | 20 +- package-lock.json | 1255 +++++++++++------ package.json | 6 +- src/cli.ts | 31 +- src/github-actions-ci.ts | 10 +- src/license-client.ts | 84 +- src/logging.ts | 2 + src/unity-cli.ts | 4 +- src/unity-editor.ts | 8 +- src/unity-installer.ts | 96 +- src/unity-logging.ts | 6 +- src/utilities.ts | 155 +- tests/unity-editor.test.ts | 5 + 17 files changed, 1187 insertions(+), 554 deletions(-) diff --git a/.github/actions/run-unity-test-batch/action.yml b/.github/actions/run-unity-test-batch/action.yml index 56dc7838..44cf6a41 100644 --- a/.github/actions/run-unity-test-batch/action.yml +++ b/.github/actions/run-unity-test-batch/action.yml @@ -29,10 +29,14 @@ runs: - name: Prepare test list and install packages shell: bash working-directory: ${{ inputs.unity-project-path }} + # inputs reach the script through the environment, never by expression interpolation + env: + TESTS_INPUT_OVERRIDE: ${{ inputs.tests-input }} + TEST_PROFILE: ${{ inputs.test-profile }} run: | set -euo pipefail - tests_input="${{ inputs.tests-input }}" - test_profile="${{ inputs.test-profile }}" + tests_input="${TESTS_INPUT_OVERRIDE}" + test_profile="${TEST_PROFILE}" if [ -z "$tests_input" ]; then case "$test_profile" in @@ -60,7 +64,8 @@ runs: needs_test_framework=true fi - npm install -g openupm-cli + # the CLI is pinned; the packages it adds stay on their latest releases on purpose (see docs/unity-version-support.md) + npm install -g openupm-cli@4.5.2 openupm add com.utilities.buildpipeline if [ "$needs_test_framework" = true ]; then openupm add com.unity.test-framework diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 87715600..7ae233ea 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -9,6 +9,7 @@ permissions: jobs: publish: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 @@ -22,11 +23,11 @@ jobs: minor=$(npm --version | cut -d. -f2) patch=$(npm --version | cut -d. -f3) if [ "$major" -lt 11 ] || { [ "$major" -eq 11 ] && [ "$minor" -lt 5 ]; } || { [ "$major" -eq 11 ] && [ "$minor" -eq 5 ] && [ "$patch" -lt 1 ]; }; then - npm install -g npm@latest + npm install -g npm@11.21.1 fi - name: Build and publish run: | - npm i + npm ci npm run build lastVersion=$(npm show @rage-against-the-pixel/unity-cli version) version=$(node -p "require('./package.json').version") diff --git a/.github/workflows/unity-build.yml b/.github/workflows/unity-build.yml index 1d0dec06..b97476cc 100644 --- a/.github/workflows/unity-build.yml +++ b/.github/workflows/unity-build.yml @@ -37,6 +37,13 @@ jobs: env: UNITY_PROJECT_PATH: '' # Create Unity Project step will set this if needed UNITY_CLI_WORKFLOW_SUMMARY: 'true' # opt-in: append rich markdown to GITHUB_STEP_SUMMARY from unity-cli log parsing + # matrix values reach run scripts through the environment, never by expression interpolation + MATRIX_OS: ${{ matrix.os }} + MATRIX_UNITY_VERSION: ${{ matrix.unity-version }} + MATRIX_BUILD_TARGET: ${{ matrix.build-target }} + MATRIX_BUILD_ARGS: ${{ matrix.build-args }} + MATRIX_CHANNEL: ${{ matrix.channel }} + MATRIX_CHANGESET: ${{ matrix.changeset }} steps: - name: Free Disk Space if: ${{ matrix.os == 'ubuntu-latest' && (matrix.unity-version != '2018' && matrix.unity-version != '2017.4.40f1') }} @@ -59,15 +66,15 @@ jobs: timeout-minutes: 30 run: | unity-cli setup-cli --json - if [ "${{ matrix.unity-version }}" != "none" ]; then + if [ "${MATRIX_UNITY_VERSION}" != "none" ]; then setup_cmd=( unity-cli setup-unity - --unity-version "${{ matrix.unity-version }}" - --build-targets "${{ matrix.build-target }}" + --unity-version "${MATRIX_UNITY_VERSION}" + --build-targets "${MATRIX_BUILD_TARGET}" --json ) - CHANNEL="${{ matrix.channel }}" - CHANGESET="${{ matrix.changeset }}" + CHANNEL="${MATRIX_CHANNEL}" + CHANGESET="${MATRIX_CHANGESET}" if [ -n "${CHANNEL}" ]; then setup_cmd+=(--channel "${CHANNEL}") fi @@ -92,12 +99,12 @@ jobs: fi unity --version - if [ "${{ matrix.unity-version }}" != "none" ] && [ -z "${UNITY_EDITOR_PATH}" ]; then + if [ "${MATRIX_UNITY_VERSION}" != "none" ] && [ -z "${UNITY_EDITOR_PATH}" ]; then echo "::error::UNITY_EDITOR_PATH is not set" exit 1 fi - if [ "${{ matrix.unity-version }}" != "none" ]; then + if [ "${MATRIX_UNITY_VERSION}" != "none" ]; then { echo "### Resolved Unity editor" echo "" @@ -117,6 +124,9 @@ jobs: if: ${{ matrix.unity-version != 'none' }} env: UNITY_LICENSE: ${{ runner.os == 'macOS' && secrets.UNITY_LICENSE_MACOS || runner.os == 'Linux' && secrets.UNITY_LICENSE_LINUX || '' }} + # read by activate-license from the environment, so the credentials never sit on a command line + UNITY_USERNAME: ${{ secrets.UNITY_USERNAME }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} run: | unity-cli license-context major="${UNITY_EDITOR_VERSION%%.*}" @@ -129,10 +139,11 @@ jobs: fi echo "shared-identity=${shared_identity}" >> "$GITHUB_OUTPUT" # activate-license writes license-source=license-file|account to this step's outputs - unity-cli activate-license --license personal --email "${{ secrets.UNITY_USERNAME }}" --password "${{ secrets.UNITY_PASSWORD }}" + unity-cli activate-license --license personal - name: Store license secret - # only when a shared-identity row had to activate with the account, i.e. the stored license was missing or stale - if: ${{ steps.activate-license.outputs.shared-identity == 'true' && steps.activate-license.outputs.license-source == 'account' }} + # only when a shared-identity row had to activate with the account, i.e. the stored license was missing or stale, + # and never in a pull_request run, so a token that can write secrets is only exposed to pushes and manual runs + if: ${{ github.event_name != 'pull_request' && steps.activate-license.outputs.shared-identity == 'true' && steps.activate-license.outputs.license-source == 'account' }} continue-on-error: true env: GH_TOKEN: ${{ secrets.CI_TOKEN }} @@ -204,7 +215,7 @@ jobs: exit 1 fi # check if the project can be built. Only Unity 2019.4+ and newer majors support the build pipeline package - version="${{ matrix.unity-version }}" + version="${MATRIX_UNITY_VERSION}" # extract major and minor (minor may be empty if version is just '2019' etc.) major=$(echo "$version" | cut -d'.' -f1) minor=$(echo "$version" | cut -d'.' -f2) @@ -231,13 +242,13 @@ jobs: MATRIX_NAME: ${{ matrix.name }} run: | set -euo pipefail - unity_version="${{ matrix.unity-version }}" + unity_version="${MATRIX_UNITY_VERSION}" unity_version="${unity_version//'*'/x}" - bt="${{ matrix.build-target }}" + bt="${MATRIX_BUILD_TARGET}" bt="${bt:-none}" # Per-job slug so parallel matrix rows never collide (name encodes the job-builder row). - base="${{ matrix.os }}-${unity_version}-${bt}" - ba="${{ matrix.build-args }}" + base="${MATRIX_OS}-${unity_version}-${bt}" + ba="${MATRIX_BUILD_ARGS}" if [ -n "${ba}" ]; then if command -v sha256sum >/dev/null 2>&1; then bah=$(printf '%s' "$ba" | sha256sum | awk '{print $1}' | cut -c1-12) @@ -337,7 +348,6 @@ jobs: echo "Licensing Client Log Path: ${LICENSING_CLIENT_LOG_PATH}" echo "Licensing Audit Log Path: ${LICENSING_AUDIT_LOG_PATH}" - unity logs || true if [ ! -f "${LICENSING_CLIENT_LOG_PATH}" ]; then echo "::error::Licensing Client log file does not exist at ${LICENSING_CLIENT_LOG_PATH}" diff --git a/.github/workflows/unity-release-discovery.yml b/.github/workflows/unity-release-discovery.yml index 0bf14264..0cfe63a3 100644 --- a/.github/workflows/unity-release-discovery.yml +++ b/.github/workflows/unity-release-discovery.yml @@ -9,6 +9,7 @@ permissions: jobs: discover: runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 diff --git a/README.md b/README.md index be19ecab..8d525907 100644 --- a/README.md +++ b/README.md @@ -132,17 +132,21 @@ unity-cli license-version `activate-license [options]`: Activate a Unity license. - `-l`, `--license`: License type (personal, professional, floating). Required. -- `-e`, `--email`: Email associated with the Unity account. Used when the official CLI cannot activate a personal or professional license. -- `-p`, `--password`: Password for the Unity account. Used when the official CLI cannot activate a personal or professional license. -- `-s`, `--serial`: License serial number. Required when activating a professional license. +- `-e`, `--email`: Email associated with the Unity account. Used when the official CLI cannot activate a personal or professional license. Defaults to `UNITY_USERNAME`. +- `-p`, `--password`: Password for the Unity account. Used when the official CLI cannot activate a personal or professional license. Defaults to `UNITY_PASSWORD`. +- `-s`, `--serial`: License serial number. Required when activating a professional license. Defaults to `UNITY_SERIAL`. +- `-f`, `--license-file`: A cached license file (`Unity_lic.ulf`) from an earlier activation on a machine with the same identity: a path, the raw XML, or base64. Defaults to `UNITY_LICENSE`. Falls back to the email and password when it cannot be used. - `-c`, `--config`: Path to the configuration file, raw JSON, or base64 encoded JSON string. Required when activating a floating license. - `--json`: Prints the last line of output as JSON string. - `--verbose`: Enable verbose logging. ```bash -unity-cli activate-license --license personal --email --password +UNITY_USERNAME= UNITY_PASSWORD= unity-cli activate-license --license personal ``` +> [!NOTE] +> Prefer the environment variables over `--email` and `--password`: values on a command line are visible to other processes on the machine. The Unity Licensing Client fallback still receives them as arguments, which Unity's client requires, so the official CLI stays the first choice. + ##### Return License `return-license [options]`: Return a Unity license. @@ -163,6 +167,14 @@ unity-cli return-license --license personal unity-cli license-context ``` +#### License File Path + +`license-file-path`: Prints the path to the machine's Unity license file (`Unity_lic.ulf`). + +```bash +unity-cli license-file-path +``` + #### Licensing Client Logs `licensing-client-logs`: Prints the path to the Unity Licensing Client log file. diff --git a/package-lock.json b/package-lock.json index b1e10adb..a477a933 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@rage-against-the-pixel/unity-releases-api": "^1.0.5", - "commander": "^14.0.3", + "commander": "^15.0.0", "glob": "^13.0.6", "semver": "^7.8.5", "source-map-support": "^0.5.21", @@ -24,8 +24,8 @@ "@types/node": "^24.13.3", "@types/semver": "^7.8.0", "@types/update-notifier": "^6.0.8", - "jest": "^30.4.2", - "ts-jest": "^29.4.12", + "jest": "^30.5.2", + "ts-jest": "^29.4.15", "ts-node": "^10.9.2", "typescript": "^6.0.3", "yaml": "^2.9.1" @@ -646,17 +646,17 @@ } }, "node_modules/@jest/console": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.4.1.tgz", - "integrity": "sha512-v3bhyxUh9Hgmo5p6hAOXe14/R3ZxZDOsvHleh4B07z3m/x4/ngPUXEm9XwK4sF4u+f+P2ORb0Ge+MgpaqRMVDA==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.5.2.tgz", + "integrity": "sha512-e8sQC4pCMHPBrPX7Hk8TJr+DM5dkUnImSTNag8SEWFAFAF6xIz4AJvCKapnrdHnZxep4bxw4PhG+ZbimYBMyKw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.4.1", + "@jest/types": "30.5.1", "@types/node": "*", "chalk": "^4.1.2", - "jest-message-util": "30.4.1", - "jest-util": "30.4.1", + "jest-message-util": "30.5.1", + "jest-util": "30.5.1", "slash": "^3.0.0" }, "engines": { @@ -697,18 +697,18 @@ } }, "node_modules/@jest/core": { - "version": "30.4.2", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.4.2.tgz", - "integrity": "sha512-TZJA6cPJUFxoWhxaLo8t0VX/MZX2wPWr0uIDvLSHIvN4gu9h02vSzqI2kBADG1ExqQlC+cY09xKMSreivvrChQ==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.5.2.tgz", + "integrity": "sha512-/jqvFWoJF7LV1a6AUpYMbMm+2yIYsHJfR474H0SCzr9k8tnO/jl9wRg6zSG6lxgf6EozNlCG4amFzWsbZSvvZA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.4.1", - "@jest/pattern": "30.4.0", - "@jest/reporters": "30.4.1", - "@jest/test-result": "30.4.1", - "@jest/transform": "30.4.1", - "@jest/types": "30.4.1", + "@jest/console": "30.5.2", + "@jest/pattern": "30.5.0", + "@jest/reporters": "30.5.2", + "@jest/test-result": "30.5.2", + "@jest/transform": "30.5.2", + "@jest/types": "30.5.1", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", @@ -716,20 +716,20 @@ "exit-x": "^0.2.2", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", - "jest-changed-files": "30.4.1", - "jest-config": "30.4.2", - "jest-haste-map": "30.4.1", - "jest-message-util": "30.4.1", - "jest-regex-util": "30.4.0", - "jest-resolve": "30.4.1", - "jest-resolve-dependencies": "30.4.2", - "jest-runner": "30.4.2", - "jest-runtime": "30.4.2", - "jest-snapshot": "30.4.1", - "jest-util": "30.4.1", - "jest-validate": "30.4.1", - "jest-watcher": "30.4.1", - "pretty-format": "30.4.1", + "jest-changed-files": "30.5.1", + "jest-config": "30.5.2", + "jest-haste-map": "30.5.1", + "jest-message-util": "30.5.1", + "jest-regex-util": "30.5.0", + "jest-resolve": "30.5.1", + "jest-resolve-dependencies": "30.5.2", + "jest-runner": "30.5.2", + "jest-runtime": "30.5.2", + "jest-snapshot": "30.5.2", + "jest-util": "30.5.1", + "jest-validate": "30.5.1", + "jest-watcher": "30.5.2", + "pretty-format": "30.5.1", "slash": "^3.0.0" }, "engines": { @@ -778,9 +778,9 @@ } }, "node_modules/@jest/diff-sequences": { - "version": "30.4.0", - "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.4.0.tgz", - "integrity": "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.5.0.tgz", + "integrity": "sha512-OsqBjHXCn8cadasoAZBP6nWYvMsRhpMzGXTpxJ5aO04NlbdhIz+FVe3q49l0AwVhsz/cEmIpBes6gAFl1/dWQg==", "dev": true, "license": "MIT", "engines": { @@ -788,70 +788,70 @@ } }, "node_modules/@jest/environment": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.4.1.tgz", - "integrity": "sha512-AK9yNRqgKxiabqMoe4oW+3/TSSeV8vkdC7BGaxZdU0AFXfOpofTLqdru2GXKZghP3sdgwE9XXpnVwfZ8JnFV4w==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.5.2.tgz", + "integrity": "sha512-nwFPOUMbmsjNNRCpnJpB9HbSZu4C07wDDds++5j+j1MezzPLxMPDdbdUVjr2o4RULDOValnDd70taPFI1EtxAg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/fake-timers": "30.4.1", - "@jest/types": "30.4.1", + "@jest/fake-timers": "30.5.2", + "@jest/types": "30.5.1", "@types/node": "*", - "jest-mock": "30.4.1" + "jest-mock": "30.5.2" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/expect": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.4.1.tgz", - "integrity": "sha512-ginrj6TMgh2GshLUGCjO94Ptx9HhdZA/I6A9iUfyeLKFtdAjnKzHDgzgP9HYQgbxM1lbXScQ2eUBz2lGeVDPWA==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.5.2.tgz", + "integrity": "sha512-2E+1Pg6jJxbRjLNLpciC71iri/3qsKJL222/aXJEFwC0VdUSvfS+JZQQLEVjUYYc8t8pQu2MGE3cF2+bNTYRsg==", "dev": true, "license": "MIT", "dependencies": { - "expect": "30.4.1", - "jest-snapshot": "30.4.1" + "expect": "30.5.2", + "jest-snapshot": "30.5.2" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/expect-utils": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.4.1.tgz", - "integrity": "sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.5.2.tgz", + "integrity": "sha512-d/HOLSrJUlBIl6n3LzOa7cjW3xdePm5H5gQw2lixZ/0h157l9sw3mLblzFqnPcTbhDzhRQdBOX3A3KcG73nm5Q==", "dev": true, "license": "MIT", "dependencies": { - "@jest/get-type": "30.1.0" + "@jest/get-type": "30.5.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/fake-timers": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.4.1.tgz", - "integrity": "sha512-iW5umdmfPeWzehrVhugFQZqCchSCud5S1l2YT0O9ZhjRR0ExclANDZkiSBwzqtnlOn0J1JXvO+HZ6rkuyOVOgQ==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.5.2.tgz", + "integrity": "sha512-GKk0h0tKT5SpDPxDhPg3r9mm5s8/YCBzVNMOGdRiQXwUiMsjU10mVPlMaQstH6/cu50vOb8N9oQUMETHuwwxKw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.4.1", + "@jest/types": "30.5.1", "@sinonjs/fake-timers": "^15.4.0", "@types/node": "*", - "jest-message-util": "30.4.1", - "jest-mock": "30.4.1", - "jest-util": "30.4.1" + "jest-message-util": "30.5.1", + "jest-mock": "30.5.2", + "jest-util": "30.5.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/get-type": { - "version": "30.1.0", - "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", - "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.5.0.tgz", + "integrity": "sha512-9/2VUPitAjmBzbvDvqrxmvB7BzWsBW0WmkkojX1ODuxX1NLGxx9gfaZpHB0z8DtJ9uhGNmZG/VXBhf8uO0OV8Q==", "dev": true, "license": "MIT", "engines": { @@ -859,62 +859,78 @@ } }, "node_modules/@jest/globals": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.4.1.tgz", - "integrity": "sha512-ZbuY4cmXC8DkxYjfvT2DbcHWL2T6vmsMhXCDcmTB2T0y0gaezBI77ufq5ZAIdcRkYZ7NEQEDg1xFeKbxUJ5v5Q==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.5.2.tgz", + "integrity": "sha512-62zfS+dL+M5aTNHXCNLyqD+j4oqxTIzDYrZbpqMP8Yn+gvlAGyzC6BSPNb1ZmFOakV1RtFYX/O3FCQXSWmQdgg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.4.1", - "@jest/expect": "30.4.1", - "@jest/types": "30.4.1", - "jest-mock": "30.4.1" + "@jest/environment": "30.5.2", + "@jest/expect": "30.5.2", + "@jest/types": "30.5.1", + "jest-mock": "30.5.2" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/pattern": { - "version": "30.4.0", - "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.4.0.tgz", - "integrity": "sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.5.0.tgz", + "integrity": "sha512-HdNQYSdRTEBNrginaqzQtTjG0HRMfrra/z6Ok7uL3S87vSlarIVohEsJsSj5edu3MiHoHjAkvPROz5ZjoKai+w==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", - "jest-regex-util": "30.4.0" + "jest-regex-util": "30.5.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/@jest/react-is-18": { + "name": "react-is", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/react-is-19": { + "name": "react-is", + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.3.1.tgz", + "integrity": "sha512-RRieF6IyxQBkcK6pP8xOHprLTPW6QjRkbGdImtqNme+/hYVwCZkLQLdlZGOhUqXwkS9IEynaGvPJtv6XB/cz0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@jest/reporters": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.4.1.tgz", - "integrity": "sha512-/SnkPCzEQpUaBH81kjdEdDdo2WZl5hxw+BmLDGWjRkm8o7XlhjwsU36cqwe5PGBE5WYpBvDzRSdXx9rbGuJtNA==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.5.2.tgz", + "integrity": "sha512-ev6E9iG73twQcfAfoTnviuYkr3yOrYbzAtMFCimv2fxHXPIp9PBt2u1wNMt0aYWgl8FPwIcS8oqRJg6alnht4Q==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "30.4.1", - "@jest/test-result": "30.4.1", - "@jest/transform": "30.4.1", - "@jest/types": "30.4.1", - "@jridgewell/trace-mapping": "^0.3.25", + "@jest/console": "30.5.2", + "@jest/test-result": "30.5.2", + "@jest/transform": "30.5.2", + "@jest/types": "30.5.1", + "@jridgewell/trace-mapping": "^0.3.31", "@types/node": "*", "chalk": "^4.1.2", "collect-v8-coverage": "^1.0.2", "exit-x": "^0.2.2", - "glob": "^10.5.0", + "glob": "^13.0.6", "graceful-fs": "^4.2.11", "istanbul-lib-coverage": "^3.0.0", "istanbul-lib-instrument": "^6.0.0", "istanbul-lib-report": "^3.0.0", "istanbul-lib-source-maps": "^5.0.0", "istanbul-reports": "^3.1.3", - "jest-message-util": "30.4.1", - "jest-util": "30.4.1", - "jest-worker": "30.4.1", + "jest-message-util": "30.5.1", + "jest-util": "30.5.1", + "jest-worker": "30.5.1", "slash": "^3.0.0", "string-length": "^4.0.2", "v8-to-istanbul": "^9.0.1" @@ -965,9 +981,9 @@ } }, "node_modules/@jest/schemas": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", - "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.5.0.tgz", + "integrity": "sha512-/hunigyNpc4RCjC0VaW3f5RCUZVM2+WQ65qP7z083Gmvac7or2LI50XVNOtE4YPgBpV0yxYiAgorAPGniCoJmg==", "dev": true, "license": "MIT", "dependencies": { @@ -978,13 +994,13 @@ } }, "node_modules/@jest/snapshot-utils": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.4.1.tgz", - "integrity": "sha512-ObY4ljvQ95mt6iwKtVLetR/4yXiAgl3H4nJxhztr0MTjrN97TwDYrnCp/kF60Ec9HdhkWTHSu+Hg05aXfngpOA==", + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.5.1.tgz", + "integrity": "sha512-V3wnxNtiVmw5PPVg433Cn3VdXnsOeu/ofLw3KC04Bn2y1wIlU5kozXQn48rhrgj/02LrCVtntTEF1yBha0XSUw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.4.1", + "@jest/types": "30.5.1", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "natural-compare": "^1.4.0" @@ -1027,14 +1043,15 @@ } }, "node_modules/@jest/source-map": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.1.tgz", - "integrity": "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.5.2.tgz", + "integrity": "sha512-c5CehvkbfHX6yyNg7bMn2ylqlp9AHXuZ/3hPH/Lh5bxD/vefd/lV9HBjMqXef9yQNqkdwyZvkLSVSwB38hy8Qw==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/trace-mapping": "^0.3.25", + "@jridgewell/trace-mapping": "^0.3.31", "callsites": "^3.1.0", + "convert-source-map": "^2.0.0", "graceful-fs": "^4.2.11" }, "engines": { @@ -1042,14 +1059,14 @@ } }, "node_modules/@jest/test-result": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.4.1.tgz", - "integrity": "sha512-/ZG7pgEiOmmWkN9TplKbOu4id2N5lh7FHwRwlkgBVAzGdRH+OkkQ8wX/kIxg4zmd3ZQvAL1RwL2yWsvNYYECTw==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.5.2.tgz", + "integrity": "sha512-eO6YU5rsLfs60ne694e0IAmRgeBnoA8mu0nlxXDbl/1j5km0o2vO9/VbbUIKIH+WRKKEI+ELgwraRGn2Lhep8g==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.4.1", - "@jest/types": "30.4.1", + "@jest/console": "30.5.2", + "@jest/types": "30.5.1", "@types/istanbul-lib-coverage": "^2.0.6", "collect-v8-coverage": "^1.0.2" }, @@ -1058,15 +1075,15 @@ } }, "node_modules/@jest/test-sequencer": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.4.1.tgz", - "integrity": "sha512-PeYE+4td5rKjoRPxztObrXU+H8hsjZfxKMXOcmrr34JerSyB/ROOxbbicz8B7A5j9R9VayDnVPvBmedqCsFCdw==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.5.2.tgz", + "integrity": "sha512-bmij8jZyYAC47ExT2GeP7PcrlwSNS+Bp3KeRuBH88gpcxqDv6fljN8PemS4J/lLZ79xb6mpaennQTTq0zUAVVg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/test-result": "30.4.1", + "@jest/test-result": "30.5.2", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.4.1", + "jest-haste-map": "30.5.1", "slash": "^3.0.0" }, "engines": { @@ -1074,23 +1091,23 @@ } }, "node_modules/@jest/transform": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.4.1.tgz", - "integrity": "sha512-Wz0LyktlTvRefoymh+n64hQ84KNXsRGcwdoZ8CSa0Ea+fgYcHZlnk+hDP7v2MS7il2bQ5uTEIxf4/NNfhMN4KQ==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.5.2.tgz", + "integrity": "sha512-LBGwmaE886C41jDZ65eqR1YbXM7642MK3ZhTowZd5+xDlrDLuf4ePuDdDmVSvcr8NTVJ3MqxLkMbUCzuigUaoQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.27.4", - "@jest/types": "30.4.1", - "@jridgewell/trace-mapping": "^0.3.25", - "babel-plugin-istanbul": "^7.0.1", + "@jest/types": "30.5.1", + "@jridgewell/trace-mapping": "^0.3.31", + "babel-plugin-istanbul": "^8.0.0", "chalk": "^4.1.2", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.4.1", - "jest-regex-util": "30.4.0", - "jest-util": "30.4.1", + "jest-haste-map": "30.5.1", + "jest-regex-util": "30.5.0", + "jest-util": "30.5.1", "pirates": "^4.0.7", "slash": "^3.0.0", "write-file-atomic": "^5.0.1" @@ -1133,14 +1150,14 @@ } }, "node_modules/@jest/types": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", - "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.5.1.tgz", + "integrity": "sha512-LvVYn83nnXPl+Rg98nvcFgjx6nRMTArhSn6RAX/w3ELn54S8A42TYZvsCMdGUqTM8S0wyXbtlQdU6Hi6dykj9g==", "dev": true, "license": "MIT", "dependencies": { - "@jest/pattern": "30.4.0", - "@jest/schemas": "30.4.1", + "@jest/pattern": "30.5.0", + "@jest/schemas": "30.5.0", "@types/istanbul-lib-coverage": "^2.0.6", "@types/istanbul-reports": "^3.0.4", "@types/node": "*", @@ -1235,9 +1252,9 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz", - "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.6.tgz", + "integrity": "sha512-mRyDmEw9/fCDXCGfL1sbgIw/GUK0PVxRW4YwgPN61F3LQ+fSyoBFarJVzgGyhF6kRzEQZ0FejDqm4WLXSeqQmw==", "dev": true, "license": "MIT", "optional": true, @@ -1252,8 +1269,313 @@ "url": "https://github.com/sponsors/Brooooooklyn" }, "peerDependencies": { - "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", - "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" + "@emnapi/core": "^1.7.1 || 2.0.0-beta.2", + "@emnapi/runtime": "^1.7.1 || 2.0.0-beta.2" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz", + "integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.6.0", + "@parcel/watcher-darwin-arm64": "2.6.0", + "@parcel/watcher-darwin-x64": "2.6.0", + "@parcel/watcher-freebsd-x64": "2.6.0", + "@parcel/watcher-linux-arm-glibc": "2.6.0", + "@parcel/watcher-linux-arm-musl": "2.6.0", + "@parcel/watcher-linux-arm64-glibc": "2.6.0", + "@parcel/watcher-linux-arm64-musl": "2.6.0", + "@parcel/watcher-linux-x64-glibc": "2.6.0", + "@parcel/watcher-linux-x64-musl": "2.6.0", + "@parcel/watcher-win32-arm64": "2.6.0", + "@parcel/watcher-win32-x64": "2.6.0" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz", + "integrity": "sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz", + "integrity": "sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz", + "integrity": "sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz", + "integrity": "sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz", + "integrity": "sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz", + "integrity": "sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz", + "integrity": "sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz", + "integrity": "sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz", + "integrity": "sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz", + "integrity": "sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz", + "integrity": "sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz", + "integrity": "sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, "node_modules/@pkgr/core": { @@ -1375,9 +1697,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", - "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", + "integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==", "dev": true, "license": "MIT", "optional": true, @@ -1640,6 +1962,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1654,6 +1979,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1668,6 +1996,9 @@ "loong64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1682,6 +2013,9 @@ "loong64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1696,6 +2030,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1710,6 +2047,9 @@ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1724,6 +2064,9 @@ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1738,6 +2081,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1752,6 +2098,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1766,6 +2115,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2008,16 +2360,16 @@ } }, "node_modules/babel-jest": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.4.1.tgz", - "integrity": "sha512-fATAbM8piYxkiXQp3RBXmZHxZVNJZAVXXfyeyCN2Tida3+qJ8ea9UxhiJ2y4fLO90ZImKt6k9FlcH2+rLkJGhw==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.5.2.tgz", + "integrity": "sha512-ES8WeJ2fNWPEDunyAtUfJ12nHyaWUloCHdkfK2oW5uSoQSUmjNKdWiGaXJITJwvFEWoD/evHEg/QoCXAvLaQqQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/transform": "30.4.1", + "@jest/transform": "30.5.2", "@types/babel__core": "^7.20.5", - "babel-plugin-istanbul": "^7.0.1", - "babel-preset-jest": "30.4.0", + "babel-plugin-istanbul": "^8.0.0", + "babel-preset-jest": "30.5.0", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "slash": "^3.0.0" @@ -2063,9 +2415,9 @@ } }, "node_modules/babel-plugin-istanbul": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz", - "integrity": "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-8.0.0.tgz", + "integrity": "sha512-18wCskrN3DgbuBmp1gr7LBGT8xdz5xhQQqFvFhVxbkl8VBCrMKQ2YtqBWtUal1Zrc1HTuX0011+Brjw78TCFkg==", "dev": true, "license": "BSD-3-Clause", "workspaces": [ @@ -2076,16 +2428,16 @@ "@istanbuljs/load-nyc-config": "^1.0.0", "@istanbuljs/schema": "^0.1.3", "istanbul-lib-instrument": "^6.0.2", - "test-exclude": "^6.0.0" + "test-exclude": "^7.0.1" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/babel-plugin-jest-hoist": { - "version": "30.4.0", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.4.0.tgz", - "integrity": "sha512-9EdtWM/sSfXLOGLwSn+GS6pIXyBnL07/8gyJlwFXjWy4DxMOyItqyUT29d4lQiS380EZwYlX7/At4PgBS+m2aA==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.5.0.tgz", + "integrity": "sha512-gtGo1B+u14jrZQv6TdSWIWkTqclboo7Qn+dFAGUOIuXLFuJKAdg3U5MIWzZnW4vfUb4dX9skmAMiby86e/SF4A==", "dev": true, "license": "MIT", "dependencies": { @@ -2123,20 +2475,20 @@ } }, "node_modules/babel-preset-jest": { - "version": "30.4.0", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.4.0.tgz", - "integrity": "sha512-lBY4jxsNmCnSiu7kquw8ZC9F4+XLMOKypT3RnNHPvU2Kpd4W0xaPuLr5ZkRyOsvLYAY4yaW1ZwTW4xB7NIiZzg==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.5.0.tgz", + "integrity": "sha512-ZGPn5ClP4lBDpuOK8W1yQIOy359HmbnZv3suucAlIe+SEE9yDsxV4S2PjSbH2Vc97U+WmzYD7vw3kr8NsQ/i6w==", "dev": true, "license": "MIT", "dependencies": { - "babel-plugin-jest-hoist": "30.4.0", + "babel-plugin-jest-hoist": "30.5.0", "babel-preset-current-node-syntax": "^1.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { - "@babel/core": "^7.11.0 || ^8.0.0-beta.1" + "@babel/core": "^7.11.0 || ^8.0.0-beta.1 || ^8.0.0" } }, "node_modules/balanced-match": { @@ -2342,9 +2694,9 @@ } }, "node_modules/cjs-module-lexer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", - "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.3.0.tgz", + "integrity": "sha512-lmNyBzi6iYiqyrExTctfmjd8tInx+dHu9PBomTfH5RXS53c27JMFJgzNv5TyAnm3PgGf+MLy0+Ws41/HlVxdKQ==", "dev": true, "license": "MIT" }, @@ -2470,12 +2822,12 @@ "license": "MIT" }, "node_modules/commander": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", - "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", "license": "MIT", "engines": { - "node": ">=20" + "node": ">=22.12.0" } }, "node_modules/config-chain": { @@ -2593,6 +2945,16 @@ "node": ">=0.10.0" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -2684,6 +3046,13 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -2765,18 +3134,18 @@ } }, "node_modules/expect": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/expect/-/expect-30.4.1.tgz", - "integrity": "sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.5.2.tgz", + "integrity": "sha512-0LNuMvs8/5mRYhnCR4k+lGV7fqPMs6Bnksda4S3zsCUmdxBpAn4zU4NNzXdq1pMKe/H4W5t/zVIDSJ/H3e0vDA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/expect-utils": "30.4.1", - "@jest/get-type": "30.1.0", - "jest-matcher-utils": "30.4.1", - "jest-message-util": "30.4.1", - "jest-mock": "30.4.1", - "jest-util": "30.4.1" + "@jest/expect-utils": "30.5.2", + "@jest/get-type": "30.5.0", + "jest-matcher-utils": "30.5.2", + "jest-message-util": "30.5.1", + "jest-mock": "30.5.2", + "jest-util": "30.5.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -2799,6 +3168,24 @@ "bser": "2.1.1" } }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", @@ -2813,21 +3200,6 @@ "node": ">=8" } }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -3016,6 +3388,16 @@ "dev": true, "license": "MIT" }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -3035,6 +3417,19 @@ "node": ">=6" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-in-ci": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-1.0.0.tgz", @@ -3182,16 +3577,16 @@ } }, "node_modules/jest": { - "version": "30.4.2", - "resolved": "https://registry.npmjs.org/jest/-/jest-30.4.2.tgz", - "integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.5.2.tgz", + "integrity": "sha512-3gnpdBIza8ijCl3iMV9ChE3hSt1+46865qqod863gQtJr1DixOkAU5DoGbi3u+XlRcw++BhZQMu/y4xBdHBvSQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/core": "30.4.2", - "@jest/types": "30.4.1", + "@jest/core": "30.5.2", + "@jest/types": "30.5.1", "import-local": "^3.2.0", - "jest-cli": "30.4.2" + "jest-cli": "30.5.2" }, "bin": { "jest": "bin/jest.js" @@ -3209,14 +3604,14 @@ } }, "node_modules/jest-changed-files": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.4.1.tgz", - "integrity": "sha512-IuctmYrxi21iOSOaIXpJWalHyPAsVv0GeBHKDn8C1CA4W5htHn7INL+wdnL4Bo0+olEndvAFkmb++tIQJG+vvg==", + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.5.1.tgz", + "integrity": "sha512-0+bvMM/ENhDI29Z8q1r4HxiDIi4G5tnBmSw4esfPQoj9q8Nik7KIyuxHkTVnnniJQf05SxpGaKDQ+4h28ynGPg==", "dev": true, "license": "MIT", "dependencies": { "execa": "^5.1.1", - "jest-util": "30.4.1", + "jest-util": "30.5.1", "p-limit": "^3.1.0" }, "engines": { @@ -3224,29 +3619,29 @@ } }, "node_modules/jest-circus": { - "version": "30.4.2", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.4.2.tgz", - "integrity": "sha512-rvHH7VlY6LgbJXJTQ87GW62g1FntOtbhh0zT+v04kC+pgL6aBKyYINXxWukCpj3dcIBMw5/XUbtDS9dU9JTXeQ==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.5.2.tgz", + "integrity": "sha512-vffFgjs5JSJ9q4ds1vNW3klKYPJfYfIY61LT/zaZgvTeASJOWZUt8LBfHgIcV4rwxlPBLl/ePBGccHEclS4PlQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.4.1", - "@jest/expect": "30.4.1", - "@jest/test-result": "30.4.1", - "@jest/types": "30.4.1", + "@jest/environment": "30.5.2", + "@jest/expect": "30.5.2", + "@jest/test-result": "30.5.2", + "@jest/types": "30.5.1", "@types/node": "*", "chalk": "^4.1.2", "co": "^4.6.0", "dedent": "^1.6.0", "is-generator-fn": "^2.1.0", - "jest-each": "30.4.1", - "jest-matcher-utils": "30.4.1", - "jest-message-util": "30.4.1", - "jest-runtime": "30.4.2", - "jest-snapshot": "30.4.1", - "jest-util": "30.4.1", + "jest-each": "30.5.2", + "jest-matcher-utils": "30.5.2", + "jest-message-util": "30.5.1", + "jest-runtime": "30.5.2", + "jest-snapshot": "30.5.2", + "jest-util": "30.5.1", "p-limit": "^3.1.0", - "pretty-format": "30.4.1", + "pretty-format": "30.5.1", "pure-rand": "^7.0.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" @@ -3289,21 +3684,21 @@ } }, "node_modules/jest-cli": { - "version": "30.4.2", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.4.2.tgz", - "integrity": "sha512-jfA2ocvVHMXS2QijrJ0d31ektP+d/W0T5RpcTX2Pq+3sVqHlsXVCM2+FmwpL+bdY8OfHpIg9xMxLF17Zg0U49Q==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.5.2.tgz", + "integrity": "sha512-YYYxUUVjFgPvgEleKNKMaYGIFgR3l3832Cl0kRL/oCiDZ03v7wImUtquHl2OOBCCNgprjZgBN5bczS/JklPBDg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/core": "30.4.2", - "@jest/test-result": "30.4.1", - "@jest/types": "30.4.1", + "@jest/core": "30.5.2", + "@jest/test-result": "30.5.2", + "@jest/types": "30.5.1", "chalk": "^4.1.2", "exit-x": "^0.2.2", "import-local": "^3.2.0", - "jest-config": "30.4.2", - "jest-util": "30.4.1", - "jest-validate": "30.4.1", + "jest-config": "30.5.2", + "jest-util": "30.5.1", + "jest-validate": "30.5.1", "yargs": "^17.7.2" }, "bin": { @@ -3355,33 +3750,33 @@ } }, "node_modules/jest-config": { - "version": "30.4.2", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.4.2.tgz", - "integrity": "sha512-rNHAShJQqQwFNoL0hbf3BphSBOWnpOUAKvidLS/AjNVLPfoj5mSf4jQMfW3cYOs6hXeZC7nF7mDHaBnbxELOzg==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.5.2.tgz", + "integrity": "sha512-U6221OZekabePvGcGPf4raIBKMvYQWFHvhobDJRojOYaFMj2HaGytAdknjDhYc6JTz2fdHW9+6k0wWUDaNeOFQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.27.4", - "@jest/get-type": "30.1.0", - "@jest/pattern": "30.4.0", - "@jest/test-sequencer": "30.4.1", - "@jest/types": "30.4.1", - "babel-jest": "30.4.1", + "@jest/get-type": "30.5.0", + "@jest/pattern": "30.5.0", + "@jest/test-sequencer": "30.5.2", + "@jest/types": "30.5.1", + "babel-jest": "30.5.2", "chalk": "^4.1.2", "ci-info": "^4.2.0", "deepmerge": "^4.3.1", - "glob": "^10.5.0", + "glob": "^13.0.6", "graceful-fs": "^4.2.11", - "jest-circus": "30.4.2", - "jest-docblock": "30.4.0", - "jest-environment-node": "30.4.1", - "jest-regex-util": "30.4.0", - "jest-resolve": "30.4.1", - "jest-runner": "30.4.2", - "jest-util": "30.4.1", - "jest-validate": "30.4.1", + "jest-circus": "30.5.2", + "jest-docblock": "30.5.0", + "jest-environment-node": "30.5.2", + "jest-regex-util": "30.5.0", + "jest-resolve": "30.5.1", + "jest-runner": "30.5.2", + "jest-util": "30.5.1", + "jest-validate": "30.5.1", "parse-json": "^5.2.0", - "pretty-format": "30.4.1", + "pretty-format": "30.5.1", "slash": "^3.0.0", "strip-json-comments": "^3.1.1" }, @@ -3439,16 +3834,16 @@ } }, "node_modules/jest-diff": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.4.1.tgz", - "integrity": "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.5.2.tgz", + "integrity": "sha512-rBtHnI6BWTiWj3lM7fOLVfChx7R9B0u9VV3PRHawKs79x6ZjW1QoSrKenaigNGWlvhtoi2BqvDbABDie+Os3xQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/diff-sequences": "30.4.0", - "@jest/get-type": "30.1.0", + "@jest/diff-sequences": "30.5.0", + "@jest/get-type": "30.5.0", "chalk": "^4.1.2", - "pretty-format": "30.4.1" + "pretty-format": "30.5.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -3488,9 +3883,9 @@ } }, "node_modules/jest-docblock": { - "version": "30.4.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.4.0.tgz", - "integrity": "sha512-ZPMabUZCx5MpbZ2eBYSvZ0J8fvo3dR9oM+eeUpb3aKNQFuS2tu3Duw1TNlMoP8k3WQgKGJuhcMFvwcVuq6T7oA==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.5.0.tgz", + "integrity": "sha512-NwDqcxtoZi33RhuW+zJS/RVA3rmheQ8BnwpYZuc/Eruaz6seQb7+aoCeDZu/3X7W2XmD8DbSo9Pn72DbKsBFYw==", "dev": true, "license": "MIT", "dependencies": { @@ -3501,17 +3896,18 @@ } }, "node_modules/jest-each": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.4.1.tgz", - "integrity": "sha512-/8MJbH6fuj48TstjrMf+u/pd06Qezz5xOXvZA6442heNOWr8bdeoGZX2d9fCn028CoMgYmroH9//zky5GfyYmA==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.5.2.tgz", + "integrity": "sha512-GRrW+7fmmgmkvyfEBqc1QFdWnF1Ex5yG6Y6AWL4TTxnkWlKIT98dsKh3P3UuWm6v4XsQwVE6DEzXIPMzWJ3ZbA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/get-type": "30.1.0", - "@jest/types": "30.4.1", + "@jest/get-type": "30.5.0", + "@jest/types": "30.5.1", "chalk": "^4.1.2", - "jest-util": "30.4.1", - "pretty-format": "30.4.1" + "jest-regex-util": "30.5.0", + "jest-util": "30.5.1", + "pretty-format": "30.5.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -3551,74 +3947,72 @@ } }, "node_modules/jest-environment-node": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.4.1.tgz", - "integrity": "sha512-4FZYVOk85hz2AyT6BbarKy9u37g6DbrDyCdFhsnDdXqyrueYQvB+0zO4f/kqLCRD0BsPRXPMNJeQwihKZV8naw==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.5.2.tgz", + "integrity": "sha512-Ciz4KgTGIbojxSKpuFImgWJj5EyqfE8xMq9NxWmOXnQywHHS03H9Mv+vaCB29J5pr3JBrY27WbRV18rBdjh8Dg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.4.1", - "@jest/fake-timers": "30.4.1", - "@jest/types": "30.4.1", + "@jest/environment": "30.5.2", + "@jest/fake-timers": "30.5.2", + "@jest/types": "30.5.1", "@types/node": "*", - "jest-mock": "30.4.1", - "jest-util": "30.4.1", - "jest-validate": "30.4.1" + "jest-mock": "30.5.2", + "jest-util": "30.5.1", + "jest-validate": "30.5.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-haste-map": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.4.1.tgz", - "integrity": "sha512-rFrcONd8jeFsyw+Z9CrScJgglRf2+NFmNam8dKu7n+SoHqNYT47mn0DdEcVUZJpvh7Iz6/si7f7yUH7GJHVgnw==", + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.5.1.tgz", + "integrity": "sha512-VIFgt67jW480YDxKfEv9IYQKrFpYt7bOCMn3VsnjK7AK9qo7p6acpnA1DHwsVOULE3dTaYew/6JaTD/d0VDHoQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.4.1", + "@jest/types": "30.5.1", + "@parcel/watcher": "^2.6.0", "@types/node": "*", "anymatch": "^3.1.3", "fb-watchman": "^2.0.2", + "fdir": "^6.5.0", "graceful-fs": "^4.2.11", - "jest-regex-util": "30.4.0", - "jest-util": "30.4.1", - "jest-worker": "30.4.1", - "picomatch": "^4.0.3", - "walker": "^1.0.8" + "jest-regex-util": "30.5.0", + "jest-util": "30.5.1", + "jest-worker": "30.5.1", + "picomatch": "^4.0.3" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "optionalDependencies": { - "fsevents": "^2.3.3" } }, "node_modules/jest-leak-detector": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.4.1.tgz", - "integrity": "sha512-IpmyiioeHxiWDhesHnUFmOxcTzwCwKpgACgWajtAP+nYQXiY7DakTxB6Bx9JFiRMljr0AX1PvnQdaU1KFoz6NQ==", + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.5.1.tgz", + "integrity": "sha512-gt4GT2aWgEoCNTcBe4rqS74xTIUJxi+UD9SNSu9aOk5LmTEd6fS5KSTmAKAfitCCktQHNN+upUuL6EkBfFbMDQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/get-type": "30.1.0", - "pretty-format": "30.4.1" + "@jest/get-type": "30.5.0", + "pretty-format": "30.5.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-matcher-utils": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.4.1.tgz", - "integrity": "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.5.2.tgz", + "integrity": "sha512-lFkB365PTIHOhPzwrb9T6gvlDnPpOZ7/c3ewOKKB7bzztqjVrtlyRL2MkyfJXpyY2u2SKw001JKrTx14fBUFcQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/get-type": "30.1.0", + "@jest/get-type": "30.5.0", "chalk": "^4.1.2", - "jest-diff": "30.4.1", - "pretty-format": "30.4.1" + "jest-diff": "30.5.2", + "pretty-format": "30.5.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -3658,20 +4052,20 @@ } }, "node_modules/jest-message-util": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", - "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.5.1.tgz", + "integrity": "sha512-UdQlLdd9wL/Ys7xRErckqwD6wPlSZYueosSWuHc1r2ztGLwlgPvtSJq2+BPEgaEY13WLvfFbmhTj8pba0Sd1jg==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@jest/types": "30.4.1", + "@jest/types": "30.5.1", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", - "jest-util": "30.4.1", + "jest-util": "30.5.1", "picomatch": "^4.0.3", - "pretty-format": "30.4.1", + "pretty-format": "30.5.1", "slash": "^3.0.0", "stack-utils": "^2.0.6" }, @@ -3713,42 +4107,25 @@ } }, "node_modules/jest-mock": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.4.1.tgz", - "integrity": "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.5.2.tgz", + "integrity": "sha512-KTHb37Fhj8xY8qPFvTFaFJHcdsumQp+ExsUlgoX232Agmc706gagBs53+CYvulV7MVQo9AgXOUxsqzzVBoJlrA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.4.1", + "@jest/expect-utils": "30.5.2", + "@jest/types": "30.5.1", "@types/node": "*", - "jest-util": "30.4.1" + "jest-util": "30.5.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-pnp-resolver": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", - "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "peerDependencies": { - "jest-resolve": "*" - }, - "peerDependenciesMeta": { - "jest-resolve": { - "optional": true - } - } - }, "node_modules/jest-regex-util": { - "version": "30.4.0", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.4.0.tgz", - "integrity": "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.5.0.tgz", + "integrity": "sha512-Mg0WK7A6xRHLSA1udJ8y9f3lM0uUhFTBnLKzwPmqB9AylvpleJ6BLemR8K9dK27DY+cesDryoA7yLZCAHsPG1A==", "dev": true, "license": "MIT", "engines": { @@ -3756,34 +4133,33 @@ } }, "node_modules/jest-resolve": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.4.1.tgz", - "integrity": "sha512-Zry8Yq/yJcNAZ7dJ5F2heic8AheXvbFZ7XI5V+h28nrYZ7Qoyy4dItq8OodjnYD270mvX+ZudmrNV9cysqhW5Q==", + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.5.1.tgz", + "integrity": "sha512-wprhLejRtwN6h8ZgaqC0eYjGJ1uMdGPT/+b3eFncbG4NWuuP9QL+vWwRinu9waw7hkOLcpMJGVJAMgBwsPssMQ==", "dev": true, "license": "MIT", "dependencies": { "chalk": "^4.1.2", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.4.1", - "jest-pnp-resolver": "^1.2.3", - "jest-util": "30.4.1", - "jest-validate": "30.4.1", + "jest-haste-map": "30.5.1", + "jest-util": "30.5.1", + "jest-validate": "30.5.1", "slash": "^3.0.0", - "unrs-resolver": "^1.7.11" + "unrs-resolver": "^1.12.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-resolve-dependencies": { - "version": "30.4.2", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.4.2.tgz", - "integrity": "sha512-gDiVh1I+GxYzz9oXlyw+1wv6VOYX1WYxMOfjsA3iGKePV2oxmbHhwxfkALxNxYy1ciw6APWwkW2zZONwP97aEQ==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.5.2.tgz", + "integrity": "sha512-GMI0DP5enX9Z2qW2zFy1bOkrCfWAOPRJQ7qHt0pdfrxBPsgWxglMPrRSK2TX/wRWYxcPRSLFg3Z88qPOzHx7FQ==", "dev": true, "license": "MIT", "dependencies": { - "jest-regex-util": "30.4.0", - "jest-snapshot": "30.4.1" + "jest-regex-util": "30.5.0", + "jest-snapshot": "30.5.2" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -3823,34 +4199,34 @@ } }, "node_modules/jest-runner": { - "version": "30.4.2", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.4.2.tgz", - "integrity": "sha512-2dw0PslVYXxffXGpLo+Ejad+KcI1Qkjn7f4X4619gf21oCUmL+SPfjqIa/losUem3yEOvfNZe/F1HWUcNpODcg==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.5.2.tgz", + "integrity": "sha512-/B6px7hiiNhVSRwuv9AGn0nawYvwsHeQZ3ItTd5Gp2diFd0EOKaWVeWqcbw3L88vx1GfpCKjQkxWItZCTl11Rw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.4.1", - "@jest/environment": "30.4.1", - "@jest/test-result": "30.4.1", - "@jest/transform": "30.4.1", - "@jest/types": "30.4.1", + "@jest/console": "30.5.2", + "@jest/environment": "30.5.2", + "@jest/source-map": "30.5.2", + "@jest/test-result": "30.5.2", + "@jest/transform": "30.5.2", + "@jest/types": "30.5.1", "@types/node": "*", "chalk": "^4.1.2", "emittery": "^0.13.1", "exit-x": "^0.2.2", "graceful-fs": "^4.2.11", - "jest-docblock": "30.4.0", - "jest-environment-node": "30.4.1", - "jest-haste-map": "30.4.1", - "jest-leak-detector": "30.4.1", - "jest-message-util": "30.4.1", - "jest-resolve": "30.4.1", - "jest-runtime": "30.4.2", - "jest-util": "30.4.1", - "jest-watcher": "30.4.1", - "jest-worker": "30.4.1", - "p-limit": "^3.1.0", - "source-map-support": "0.5.13" + "jest-docblock": "30.5.0", + "jest-environment-node": "30.5.2", + "jest-haste-map": "30.5.1", + "jest-leak-detector": "30.5.1", + "jest-message-util": "30.5.1", + "jest-resolve": "30.5.1", + "jest-runtime": "30.5.2", + "jest-util": "30.5.1", + "jest-watcher": "30.5.2", + "jest-worker": "30.5.1", + "p-limit": "^3.1.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -3889,44 +4265,34 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/jest-runner/node_modules/source-map-support": { - "version": "0.5.13", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", - "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, "node_modules/jest-runtime": { - "version": "30.4.2", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.4.2.tgz", - "integrity": "sha512-3/5e8iPz2k/VLqlr8DgTftYyLUv8Su3FkCAO2/Od81UsUTpSxOrS6O5x5KkoQwyUjmpYyDJKeyAvg2T2nvpNkQ==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.5.2.tgz", + "integrity": "sha512-0paUAHBlheai10A3XSy9Xh+YBG0IROy+/qzcDPzQ/nbbXrznQzeWy5qniICWK6d3W3scaRQCVAbXdcK0ibBq4w==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.4.1", - "@jest/fake-timers": "30.4.1", - "@jest/globals": "30.4.1", - "@jest/source-map": "30.0.1", - "@jest/test-result": "30.4.1", - "@jest/transform": "30.4.1", - "@jest/types": "30.4.1", + "@jest/environment": "30.5.2", + "@jest/fake-timers": "30.5.2", + "@jest/globals": "30.5.2", + "@jest/source-map": "30.5.2", + "@jest/test-result": "30.5.2", + "@jest/transform": "30.5.2", + "@jest/types": "30.5.1", "@types/node": "*", "chalk": "^4.1.2", - "cjs-module-lexer": "^2.1.0", + "cjs-module-lexer": "^2.2.0", "collect-v8-coverage": "^1.0.2", - "glob": "^10.5.0", + "es-module-lexer": "^2.1.0", + "glob": "^13.0.6", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.4.1", - "jest-message-util": "30.4.1", - "jest-mock": "30.4.1", - "jest-regex-util": "30.4.0", - "jest-resolve": "30.4.1", - "jest-snapshot": "30.4.1", - "jest-util": "30.4.1", + "jest-haste-map": "30.5.1", + "jest-message-util": "30.5.1", + "jest-mock": "30.5.2", + "jest-regex-util": "30.5.0", + "jest-resolve": "30.5.1", + "jest-snapshot": "30.5.2", + "jest-util": "30.5.1", "slash": "^3.0.0", "strip-bom": "^4.0.0" }, @@ -3968,9 +4334,9 @@ } }, "node_modules/jest-snapshot": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.4.1.tgz", - "integrity": "sha512-tEOkkfOMppUyeiHwjZswOQ3lcnoTnws/q5FnGIaeIh/jmoU0ZlgMYRR8sTlTj+nNGCoJ0RDq6SfxGxCsyMTPmw==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.5.2.tgz", + "integrity": "sha512-jKIdes25AnAH73dOCi2qE6J6vHJ6L2vyBS11M89kT50DVJcLTf2AOjvcc+2vVaN8lUbY5v2R83aqXo3Ro9fqYA==", "dev": true, "license": "MIT", "dependencies": { @@ -3979,20 +4345,20 @@ "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/types": "^7.27.3", - "@jest/expect-utils": "30.4.1", - "@jest/get-type": "30.1.0", - "@jest/snapshot-utils": "30.4.1", - "@jest/transform": "30.4.1", - "@jest/types": "30.4.1", + "@jest/expect-utils": "30.5.2", + "@jest/get-type": "30.5.0", + "@jest/snapshot-utils": "30.5.1", + "@jest/transform": "30.5.2", + "@jest/types": "30.5.1", "babel-preset-current-node-syntax": "^1.2.0", "chalk": "^4.1.2", - "expect": "30.4.1", + "expect": "30.5.2", "graceful-fs": "^4.2.11", - "jest-diff": "30.4.1", - "jest-matcher-utils": "30.4.1", - "jest-message-util": "30.4.1", - "jest-util": "30.4.1", - "pretty-format": "30.4.1", + "jest-diff": "30.5.2", + "jest-matcher-utils": "30.5.2", + "jest-message-util": "30.5.1", + "jest-util": "30.5.1", + "pretty-format": "30.5.1", "semver": "^7.7.2", "synckit": "^0.11.8" }, @@ -4034,13 +4400,13 @@ } }, "node_modules/jest-util": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", - "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.5.1.tgz", + "integrity": "sha512-yKuxmNy2rSbTXw+3SIPanJo+nV4/BS1p26v44IYBFMsswSQySfMMcPHErnOncda7i9HEz0q605rIhSTBVgrZTg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.4.1", + "@jest/types": "30.5.1", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", @@ -4085,18 +4451,18 @@ } }, "node_modules/jest-validate": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.4.1.tgz", - "integrity": "sha512-PDWi4SOwLnwqNDfHZjOcsEFyZ4fc/2W2gVL3DEoyqnB6jCQMLRtfBong8s6omIw3lI0HWOus12xfnFmQtjW3fw==", + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.5.1.tgz", + "integrity": "sha512-i/buJ56wTpxihE93hQYNMfdOThS87+HGvLZzZJmU4xggPcdTYQq051iwALLCHp3q+SkCGH+EFejQZz5EbBSkkg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/get-type": "30.1.0", - "@jest/types": "30.4.1", + "@jest/get-type": "30.5.0", + "@jest/types": "30.5.1", "camelcase": "^6.3.0", "chalk": "^4.1.2", "leven": "^3.1.0", - "pretty-format": "30.4.1" + "pretty-format": "30.5.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -4149,19 +4515,19 @@ } }, "node_modules/jest-watcher": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.4.1.tgz", - "integrity": "sha512-/l9UonmvCwjHH7d2h3iAwIloLc1H0S8mJZ/LNK3i86hqwPAz8otUJjP9MfYtz9Tt77Su5FD2xGjZn8d31IZHlw==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.5.2.tgz", + "integrity": "sha512-rqRgg4R11GA9m1UsEZwQeixHsgZReCn6TOHAHtIH33yXrIvoX0OGPA20Yzzjkz7X40fZvcRSxEpQppYPcWzmcQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/test-result": "30.4.1", - "@jest/types": "30.4.1", + "@jest/test-result": "30.5.2", + "@jest/types": "30.5.1", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", "emittery": "^0.13.1", - "jest-util": "30.4.1", + "jest-util": "30.5.1", "string-length": "^4.0.2" }, "engines": { @@ -4202,15 +4568,15 @@ } }, "node_modules/jest-worker": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.4.1.tgz", - "integrity": "sha512-SHynN/q/QD++iNyvMdy+WMmbCGk8jIsNcRxycXbWubSOhvo6T+j2afcfUSl+3hYsiBebOTo0cT7c2H7CXugu1g==", + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.5.1.tgz", + "integrity": "sha512-Cbxh5v7AoLuFRmFJSM4/aHdQ68rjXvUWr716EE0Dh3I7T+T/3FgFKhOERGXHcU2Meftq9+9zxPM3TSNyI9D+HA==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", "@ungap/structured-clone": "^1.3.0", - "jest-util": "30.4.1", + "jest-util": "30.5.1", "merge-stream": "^2.0.0", "supports-color": "^8.1.1" }, @@ -4394,16 +4760,6 @@ "dev": true, "license": "ISC" }, - "node_modules/makeerror": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", - "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tmpl": "1.0.5" - } - }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -4462,12 +4818,13 @@ "license": "MIT" }, "node_modules/napi-postinstall": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", - "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.7.tgz", + "integrity": "sha512-jyU4cU4kVAmy/3qjP+DcoQTNXfINHc+dFw54gGqWC8nu0mpV0kOnk7kff+J+Q18SbX0k3Np8Rm7yFjd3phatNQ==", "dev": true, "license": "MIT", "bin": { + "napi-patch": "lib/patch.js", "napi-postinstall": "lib/cli.js" }, "engines": { @@ -4491,6 +4848,13 @@ "dev": true, "license": "MIT" }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT" + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -4728,16 +5092,16 @@ } }, "node_modules/pretty-format": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", - "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.5.1.tgz", + "integrity": "sha512-byhRAPguVKMQIj4kjJwJ5lAskVhfuiSdiYl/aLTWpgkGEmic2jYhJh1yE9ih8Ox44Xg9ccCT11/S6QrzSJuNrg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "30.4.1", - "ansi-styles": "^5.2.0", - "react-is-18": "npm:react-is@^18.3.1", - "react-is-19": "npm:react-is@^19.2.5" + "@jest/react-is-18": "npm:react-is@^18.3.1", + "@jest/react-is-19": "npm:react-is@^19.2.5", + "@jest/schemas": "30.5.0", + "ansi-styles": "^5.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -4811,22 +5175,6 @@ "node": ">=0.10.0" } }, - "node_modules/react-is-18": { - "name": "react-is", - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, - "node_modules/react-is-19": { - "name": "react-is", - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", - "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", - "dev": true, - "license": "MIT" - }, "node_modules/registry-auth-token": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-5.1.1.tgz", @@ -5143,23 +5491,16 @@ "node": ">=18" } }, - "node_modules/tmpl": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", - "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", - "dev": true, - "license": "BSD-3-Clause" - }, "node_modules/ts-jest": { - "version": "29.4.12", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.12.tgz", - "integrity": "sha512-Ov6ClY53Fflh6BGAnY2DlTq1hYDrTycz2PVTXBWFW2CU+9zrEqAp9fWdGXl42EXO5RLSFAcAZ2JFKbP+zBTFfw==", + "version": "29.4.15", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.15.tgz", + "integrity": "sha512-s2qmEUYFRqbjz1wv0EmccNPVOb45HOFpFuhzQJLKShYVisuTbCFPIIAIV2EuK5rIxb4BejLDbH7dnBZfYjybUQ==", "dev": true, "license": "MIT", "dependencies": { "bs-logger": "^0.2.6", "fast-json-stable-stringify": "^2.1.0", - "handlebars": "^4.7.9", + "handlebars": "^4.7.10", "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", @@ -5174,7 +5515,7 @@ "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" }, "peerDependencies": { - "@babel/core": ">=7.0.0-beta.0 <8", + "@babel/core": ">=7.0.0-beta.0 <9", "@jest/transform": "^29.0.0 || ^30.0.0", "@jest/types": "^29.0.0 || ^30.0.0", "babel-jest": "^29.0.0 || ^30.0.0", @@ -5581,16 +5922,6 @@ "node": ">=10.12.0" } }, - "node_modules/walker": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", - "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "makeerror": "1.0.12" - } - }, "node_modules/when-exit": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.5.tgz", diff --git a/package.json b/package.json index ae6e9cfd..42591b3c 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ }, "dependencies": { "@rage-against-the-pixel/unity-releases-api": "^1.0.5", - "commander": "^14.0.3", + "commander": "^15.0.0", "glob": "^13.0.6", "semver": "^7.8.5", "source-map-support": "^0.5.21", @@ -65,8 +65,8 @@ "@types/node": "^24.13.3", "@types/semver": "^7.8.0", "@types/update-notifier": "^6.0.8", - "jest": "^30.4.2", - "ts-jest": "^29.4.12", + "jest": "^30.5.2", + "ts-jest": "^29.4.15", "ts-node": "^10.9.2", "typescript": "^6.0.3", "yaml": "^2.9.1" diff --git a/src/cli.ts b/src/cli.ts index 8f4e716c..78e0f4c1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -67,9 +67,9 @@ program.command('license-version') program.command('activate-license') .description('Activate a Unity license. Tries the official Unity CLI first, then the Unity Licensing Client with your Unity ID email and password.') .option('-l, --license ', 'License type (personal, professional, floating). Required.') - .option('-e, --email ', 'Email associated with the Unity account. Used when the Unity CLI cannot activate a personal or professional license.') - .option('-p, --password ', 'Password for the Unity account. Used when the Unity CLI cannot activate a personal or professional license.') - .option('-s, --serial ', 'License serial number. Required when activating a professional license.') + .option('-e, --email ', 'Email associated with the Unity account. Used when the Unity CLI cannot activate a personal or professional license. Defaults to the UNITY_USERNAME environment variable.') + .option('-p, --password ', 'Password for the Unity account. Used when the Unity CLI cannot activate a personal or professional license. Defaults to the UNITY_PASSWORD environment variable, which keeps it off the command line.') + .option('-s, --serial ', 'License serial number. Required when activating a professional license. Defaults to the UNITY_SERIAL environment variable.') .option('-c, --config ', 'Path to the configuration file, raw JSON, or base64 encoded JSON string. Required when activating a floating license.') .option('-f, --license-file ', 'A cached license file (Unity_lic.ulf) from an earlier activation on a machine with the same identity: a path, the raw XML, or base64. Defaults to the UNITY_LICENSE environment variable. Falls back to the email and password when it cannot be used.') .option('--verbose', 'Enable verbose logging.') @@ -79,6 +79,16 @@ program.command('activate-license') Logger.instance.logLevel = LogLevel.DEBUG; } + // environment variables keep credentials out of the process command line + options.email ??= process.env.UNITY_USERNAME || undefined; + options.password ??= process.env.UNITY_PASSWORD || undefined; + options.serial ??= process.env.UNITY_SERIAL || undefined; + + // Mask credentials in CI environments before any potential logging + Logger.instance.maskCredential(options.email); + Logger.instance.maskCredential(options.password); + Logger.instance.maskCredential(options.serial); + Logger.instance.debugOptions(options); const licenseStr: string = options.license?.toString()?.trim(); @@ -97,13 +107,9 @@ program.command('activate-license') if (licenseType === LicenseType.professional && !options.serial) { options.serial = await PromptForSecretInput('Serial: '); + Logger.instance.maskCredential(options.serial); } - // Mask credentials in CI environments before any potential logging - Logger.instance.maskCredential(options.email); - Logger.instance.maskCredential(options.password); - Logger.instance.maskCredential(options.serial); - const token = await ActivateLicense({ licenseType, servicesConfig: options.config, @@ -130,6 +136,10 @@ program.command('return-license') Logger.instance.logLevel = LogLevel.DEBUG; } + const token: string | undefined = options.token?.toString()?.trim() || undefined; + + // Mask token in CI environments before any potential logging + Logger.instance.maskCredential(token); Logger.instance.debugOptions(options); const licenseStr: string = options.license?.toString()?.trim(); @@ -146,11 +156,6 @@ program.command('return-license') process.exit(1); } - const token: string | undefined = options.token?.toString()?.trim() || undefined; - - // Mask token in CI environments before any potential logging - Logger.instance.maskCredential(token); - await ReturnLicense(licenseType, token); process.exit(0); }); diff --git a/src/github-actions-ci.ts b/src/github-actions-ci.ts index 2575532c..9c53e6c9 100644 --- a/src/github-actions-ci.ts +++ b/src/github-actions-ci.ts @@ -69,7 +69,12 @@ export class GitHubActionsLoggerProvider implements ILoggerProvider { } public mask(message: string): void { - process.stdout.write(`::add-mask::${message}\n`); + // add-mask covers one line, so a multi-line value is masked line by line + for (const line of message.split(/\r?\n/)) { + if (line.length > 0) { + process.stdout.write(`::add-mask::${line}\n`); + } + } } public setEnvironmentVariable(name: string, value: string): void { @@ -82,7 +87,8 @@ export class GitHubActionsLoggerProvider implements ILoggerProvider { public setOutput(name: string, value: string): void { const githubOutput = process.env.GITHUB_OUTPUT; - if (githubOutput) { + // a line break would let the value set other outputs + if (githubOutput && !/[\r\n]/.test(`${name}${value}`)) { fs.appendFileSync(githubOutput, `${name}=${value}\n`, { encoding: 'utf8' }); } } diff --git a/src/license-client.ts b/src/license-client.ts index 73665db7..314be3d3 100644 --- a/src/license-client.ts +++ b/src/license-client.ts @@ -8,8 +8,11 @@ import { ResolveGlobToPath, DownloadFile, Exec, + FindProcessesByName, FindProcessesUnder, KillProcess, + SanitizeLogText, + Sha256FileHex, GetTempDir, ResolvePathCandidates, redactSensitiveLiterals, @@ -53,6 +56,7 @@ export class LicensingClient { private licenseClientPath: string | undefined; private readonly secrets: string[] = []; + private isLicenseClientPatched = false; /** Where the 4.x/5.x license link lives, and the folders the client may have written Unity_lic.ulf to. */ private legacyLicense: { linkPath: string; commonDirectory: string; patchedDirectory: string } | undefined; private licenseVersion: string | undefined; @@ -122,7 +126,7 @@ export class LicensingClient { private static async getHubBundledClient(): Promise { const hubVersion = '3.12.0'; const arch = process.arch === 'arm64' ? 'arm64' : 'x64'; - const cacheDirectory = path.join(os.tmpdir(), `UnityHubLicensingClient-${hubVersion}-${arch}`); + const cacheDirectory = await LicensingClient.privateDirectory(`hub-client-${hubVersion}-${arch}`); const findClient = async () => (await fs.promises.readdir(cacheDirectory, { recursive: true })) .map(entry => path.join(cacheDirectory, entry.toString())) .find(entry => path.basename(entry) === 'Unity.Licensing.Client' && fs.statSync(entry).isFile()); @@ -137,7 +141,12 @@ export class LicensingClient { const dmgPath = path.join(GetTempDir(), `UnityHubSetup-${hubVersion}-${arch}.dmg`); const mountPoint = path.join(GetTempDir(), `UnityHub-${hubVersion}-${arch}`); - await DownloadFile(`https://public-cdn.cloud.unity3d.com/hub/${hubVersion}/UnityHubSetup-${arch}.dmg`, dmgPath); + // pinned so a tampered download never reaches the client that receives the Unity ID + const dmgSha256: { [arch: string]: string } = { + 'x64': '1b5e9698b6353cb814618ea12c6ca014d50fc33efe086da6b67e5b442a77bb3e', + 'arm64': '0026d98a375d926090b144ca3d54e982612aa24a59404e004e333dc34c4b832a', + }; + await DownloadFile(`https://public-cdn.cloud.unity3d.com/hub/${hubVersion}/UnityHubSetup-${arch}.dmg`, dmgPath, { sha256: dmgSha256[arch]! }); await fs.promises.mkdir(mountPoint, { recursive: true }); try { @@ -184,7 +193,10 @@ export class LicensingClient { // missing or not writable } - for (const args of [['-n', 'mkdir', '-p', directory], ['-n', 'chmod', '777', directory]]) { + // owned by the current user rather than world-writable + const user = os.userInfo().username; + + for (const args of [['-n', 'mkdir', '-p', directory], ['-n', 'chown', user, directory], ['-n', 'chmod', '755', directory]]) { const exitCode = await new Promise((resolve) => { const child = spawn('sudo', args, { stdio: 'ignore' }); child.on('error', () => resolve(1)); @@ -198,6 +210,22 @@ export class LicensingClient { } } + /** + * Gets a folder under ~/.unity-cli/licensing that only the current user can read and write, creating it when needed. + * Used instead of the shared temp folder for the patched licensing client and the license it writes. + * @param name The folder name. + */ + private static async privateDirectory(name: string): Promise { + const root = path.join(os.homedir(), '.unity-cli', 'licensing'); + await fs.promises.mkdir(root, { recursive: true, mode: 0o700 }); + + if (process.platform !== 'win32') { + await fs.promises.chmod(root, 0o700); + } + + return path.join(root, name); + } + /** * The folder that holds the whole licensing client: the bundle root for a macOS bundle (…/Contents/MacOS/client), otherwise the executable's folder. */ @@ -508,17 +536,23 @@ export class LicensingClient { this.licenseClientPath = await this.init(); } - if (this.licenseVersion === '6.x') { - return; // no patching needed + if (this.licenseVersion === '6.x' || this.isLicenseClientPatched) { + return; // no patching needed, or already done for this instance } const clientRoot = LicensingClient.clientRootDirectory(this.licenseClientPath); - const patchedDirectory = path.join(os.tmpdir(), `UnityLicensingClient-${this.licenseVersion.replace('.', '_')}`); + // a private per-user folder rather than the shared temp folder: it holds a client that receives the Unity ID and the license it writes + const patchedDirectory = await LicensingClient.privateDirectory(`patched-client-${this.licenseVersion.replace('.', '_')}`); const patchedClientPath = path.join(patchedDirectory, path.relative(clientRoot, this.licenseClientPath)); + const sourceMarker = path.join(patchedDirectory, '.source-sha256'); + const sourceHash = await Sha256FileHex(this.licenseClientPath); + const isCurrent = fs.existsSync(sourceMarker) && (await fs.promises.readFile(sourceMarker, 'utf-8')).trim() === sourceHash; - if (await fs.promises.mkdir(patchedDirectory, { recursive: true }) === undefined) { - this.logger.debug('Unity Licensing Client was already patched, reusing'); + if (isCurrent) { + this.logger.debug('Unity Licensing Client was already patched from the same client, reusing'); } else { + await fs.promises.rm(patchedDirectory, { recursive: true, force: true }); + await fs.promises.mkdir(patchedDirectory, { recursive: true, mode: 0o700 }); // copy the whole client, then patch whichever binary holds the "6.x" license version: // older clients ship it in Unity.Licensing.EntitlementResolver.dll, the single-file client the Unity CLI downloads embeds it in the executable await fs.promises.cp(clientRoot, patchedDirectory, { recursive: true, verbatimSymlinks: true }); @@ -574,9 +608,13 @@ export class LicensingClient { }); } } + + // records which client the copy came from, so a newer client is patched again instead of reused + await fs.promises.writeFile(sourceMarker, sourceHash, { encoding: 'utf-8', mode: 0o600 }); } this.licenseClientPath = patchedClientPath; + this.isLicenseClientPatched = true; const unityCommonDir = this.getUnityCommonDir(); const legacyLicenseFile = path.join(unityCommonDir, `Unity_v${this.licenseVersion}.ulf`); this.legacyLicense ??= { linkPath: legacyLicenseFile, commonDirectory: unityCommonDir, patchedDirectory }; @@ -706,15 +744,7 @@ export class LicensingClient { * The line is split on separator characters and each piece is checked on its own, which stays linear on any input. */ private static sanitizeLogLine(line: string, secrets: string[]): string { - return redactSensitiveLiterals(line, secrets) - .split(/([\s"'<>=,;()[\]]+)/) - .map(part => { - const at = part.indexOf('@'); - const isEmail = at > 0 && part.lastIndexOf('.') > at + 1; - const isToken = part.length >= 32 && /^[A-Za-z0-9+_.-]+$/.test(part); - return isEmail || isToken ? '*****' : part; - }) - .join(''); + return SanitizeLogText(line, secrets); } /** @@ -746,18 +776,9 @@ export class LicensingClient { return; } - const [command, args] = process.platform === 'win32' - ? ['taskkill', ['/F', '/IM', 'Unity.Licensing.Client.exe']] - : ['pkill', ['-f', 'Unity.Licensing.Client']]; - - await new Promise((resolve) => { - const child = spawn(command, args, { stdio: 'ignore' }); - child.on('error', () => resolve()); - child.on('close', (code) => { - Logger.instance.debug(`${command} exited with code ${code}`); - resolve(); - }); - }); + // matched on the executable's own name, so a process that only mentions it on its command line is left alone + const clients = await FindProcessesByName('Unity.Licensing.Client'); + await Promise.all(clients.map(client => KillProcess(client).catch(() => undefined))); } private maskSerialInOutput(output: string): string { @@ -896,12 +917,13 @@ export class LicensingClient { const tokenMatch = output.match(/with token:\s*"(?[\w-]+)"/); if (!tokenMatch || !tokenMatch.groups || !tokenMatch.groups['token']) { - throw new Error(`Failed to acquire floating license lease: No token found in output.\n ${output}`); + throw new Error(`Failed to acquire floating license lease: No token found in output.\n ${SanitizeLogText(output)}`); } const token = tokenMatch.groups['token']; this.logger.CI_mask(token); - this.logger.info(output); + // the token is returned to the caller (and printed with --json); the log never shows it + this.logger.info(redactSensitiveLiterals(output, [token])); return token; } default: { // personal and professional license activation diff --git a/src/logging.ts b/src/logging.ts index 5129627a..6c29daf3 100644 --- a/src/logging.ts +++ b/src/logging.ts @@ -693,6 +693,8 @@ export class Logger { 'username', 'servicesConfig', 'serviceaccountkey', + // licenseFile can hold the license XML itself + 'licensefile', ]; /** diff --git a/src/unity-cli.ts b/src/unity-cli.ts index 05edc6e1..824aa8ab 100644 --- a/src/unity-cli.ts +++ b/src/unity-cli.ts @@ -7,6 +7,7 @@ import { DownloadFile, GetTempDir, HttpsGetText, + SanitizeLogText, Sha256FileHex, redactSensitiveLiterals, } from './utilities'; @@ -290,7 +291,8 @@ export class UnityCli { public async RunJson(args: string[], options: UnityCliRunOptions = {}): Promise<{ exitCode: number; envelope: UnityCliEnvelope | undefined; stdout: string; stderr: string }> { const result = await this.Run([...args, '--format', 'json'], { ...options, stream: false }); const envelope = parseEnvelope(result.stdout); - this.logger.debug(`unity ${redactSensitiveLiterals(args.join(' '), options.redactLiterals)} [${result.exitCode}]:\n${result.stdout}${stripHint(result.stderr)}`); + // JSON results can carry account details and tokens (license activate, status), so the debug copy is sanitized + this.logger.debug(`unity ${redactSensitiveLiterals(args.join(' '), options.redactLiterals)} [${result.exitCode}]:\n${SanitizeLogText(`${result.stdout}${stripHint(result.stderr)}`, options.redactLiterals)}`); return { exitCode: result.exitCode, envelope, stdout: result.stdout, stderr: result.stderr }; } diff --git a/src/unity-editor.ts b/src/unity-editor.ts index 8ebc7945..15911c22 100644 --- a/src/unity-editor.ts +++ b/src/unity-editor.ts @@ -243,7 +243,10 @@ export class UnityEditor { const logPath: string = GetArgumentValueAsString('-logFile', command.args); editorLogPath = logPath; - logTail = TailLogFile(logPath, command.projectPath); + // values of sensitive flags are masked in CI and redacted from everything printed from the Editor log + const sensitiveValues = command.args.filter((_, i) => i > 0 && UnityEditor.SENSITIVE_FLAGS.includes(command.args[i - 1]!)); + sensitiveValues.forEach(value => this.logger.maskCredential(value)); + logTail = TailLogFile(logPath, command.projectPath, sensitiveValues); // Scrub sensitive arguments before logging const scrubbedArgs = this.scrubSensitiveArgs(command.args); @@ -482,7 +485,8 @@ export class UnityEditor { return [ 'run', projectPath, - '--editor-path', editorPath, + // on macOS unity run wants the .app bundle, not the executable inside it + '--editor-path', editorPath.replace(/(\.app)[\\/]Contents[\\/]MacOS[\\/][^\\/]+$/, '$1'), '--log-file', logPath, '--no-tail', '--', diff --git a/src/unity-installer.ts b/src/unity-installer.ts index 7014cfbd..42f1b395 100644 --- a/src/unity-installer.ts +++ b/src/unity-installer.ts @@ -9,6 +9,7 @@ import { UnityVersion } from './unity-version'; import { FindAbsolutePath, UnityCli } from './unity-cli'; import { DownloadFile, + DownloadOptions, Exec, GetTempDir, } from './utilities'; @@ -30,11 +31,24 @@ set -e if [ ! -f /etc/apt/sources.list.d/jammy.list ]; then echo "deb https://archive.ubuntu.com/ubuntu jammy main universe" > /etc/apt/sources.list.d/jammy.list fi +# jammy only supplies packages the host release lacks; it never replaces the host's own packages +printf 'Package: *\\nPin: release n=jammy\\nPin-Priority: 100\\n' > /etc/apt/preferences.d/unity-cli-jammy apt-get update apt-get install -y --no-install-recommends xvfb ffmpeg libgtk2.0-0 libglu1-mesa libgconf-2-4 libncurses5 pulseaudio apt-get clean `; +/** + * SHA-256 of the fixed installers unity-cli downloads, recorded from the files Unity and Ubuntu serve today. + * A download that does not match is deleted instead of installed. + */ +const PINNED_DOWNLOAD_SHA256: { [url: string]: string } = { + 'https://beta.unity3d.com/download/UnitySetup-4.7.2.exe': '30e462dfe9c2ef176e4d1c78742ab777099ae5cfab3b46502af6c426b7e4e8a7', + 'https://beta.unity3d.com/download/unity-4.7.2.dmg': '2fcaa916624b2eecc1686a943d5b7806fb5bdaf01df7b4a544103f114b85f40b', + 'https://archive.ubuntu.com/ubuntu/pool/main/o/openssl/libssl1.0.0_1.0.2g-1ubuntu4.20_amd64.deb': '8003f9866297dd0fe77cc771470a06803d1d1b860350ad91dd212e38c36a20d2', + 'https://archive.ubuntu.com/ubuntu/pool/main/o/openssl/libssl1.1_1.1.0g-2ubuntu4_amd64.deb': '82e424d50e144320319414a1964d3a980abde4bc39fc7d8c2885819328cb5cb4', +}; + /** Unity 5.x target support installer names, keyed by module id. */ const ARCHIVE_MODULE_NAMES: { [module: string]: string } = { 'android': 'Android', @@ -62,6 +76,9 @@ const ARCHIVE_MODULE_NAMES: { [module: string]: string } = { export class UnityInstaller { private readonly logger: Logger = Logger.instance; private cli: UnityCli | undefined; + /** The 5.x release being installed from the archive, and its installers' published integrity values. */ + private archiveVersion: UnityVersion | undefined; + private archiveIntegrity: Map | undefined; /** * @param cli The official Unity CLI to use. Installed on demand when omitted. @@ -343,6 +360,8 @@ export class UnityInstaller { } UnityInstaller.assertArchiveVersion(unityVersion); + this.archiveVersion = unityVersion; + this.archiveIntegrity = undefined; const installPath = await this.getLegacyInstallPath(); const installDir = path.join(installPath, unityVersion.version); const baseUrl = `https://download.unity3d.com/download_unity/${unityVersion.changeset}`; @@ -404,6 +423,72 @@ export class UnityInstaller { } } + /** + * Gets the checksum a download must match before it is installed: a pinned SHA-256 for the fixed installers, + * or the Unity Releases API integrity value for 5.x archive installers. Fails closed when neither is known. + * @param url The download URL. + */ + private async getDownloadOptions(url: string): Promise { + const pinned = PINNED_DOWNLOAD_SHA256[url]; + + if (pinned) { + return { sha256: pinned }; + } + + const archive = url.match(/^https:\/\/download\.unity3d\.com\/download_unity\/([0-9a-f]{12})\//); + + if (archive && this.archiveVersion) { + if (!this.archiveIntegrity) { + this.archiveIntegrity = await UnityInstaller.getReleaseIntegrity(this.archiveVersion); + } + + const integrity = this.archiveIntegrity.get(url); + + if (integrity) { + return { integrity }; + } + } + + throw new Error(`No checksum is known for ${url}, so it is not installed`); + } + + /** + * Maps each installer URL of a release (editor and modules) to the integrity value the Unity Releases API publishes for it. + * @param unityVersion The exact release. + */ + private static async getReleaseIntegrity(unityVersion: UnityVersion): Promise> { + const integrity = new Map(); + const releasesClient = new UnityReleasesClient(); + const { data, error } = await releasesClient.api.Release.getUnityReleases({ + url: '/unity/editor/release/v1/releases', + query: { version: unityVersion.version, limit: 5 }, + } as GetUnityReleasesData); + + if (error) { + throw new Error(`Failed to read the checksums for Unity ${unityVersion.version}: ${JSON.stringify(error)}`); + } + + const collect = (entries: unknown): void => { + if (!Array.isArray(entries)) { return; } + + for (const entry of entries as { url?: string; integrity?: string; modules?: unknown }[]) { + if (entry.url && entry.integrity) { + integrity.set(entry.url, entry.integrity); + } + + collect(entry.modules); + } + }; + + for (const release of data?.results ?? []) { + if (release.version === unityVersion.version) { + collect((release as unknown as { downloads?: unknown }).downloads); + } + } + + return integrity; + } + private getArchiveModuleNames(unityVersion: UnityVersion, modules: string[]): string[] { const hostModule = process.platform === 'win32' ? 'Windows' : process.platform === 'darwin' ? 'Mac' : 'Linux'; const names: string[] = []; @@ -430,7 +515,7 @@ export class UnityInstaller { } const installerPath = tempInstallerPath('.exe'); - await DownloadFile(url, installerPath); + await DownloadFile(url, installerPath, await this.getDownloadOptions(url)); this.logger.info(`Running ${path.basename(url)}...`); try { @@ -455,7 +540,7 @@ export class UnityInstaller { private async installMacPackages(urls: string[]): Promise { for (const url of urls) { const pkgPath = tempInstallerPath('.pkg'); - await DownloadFile(url, pkgPath); + await DownloadFile(url, pkgPath, await this.getDownloadOptions(url)); try { await Exec('sudo', ['installer', '-pkg', pkgPath, '-target', '/'], { silent: true, showCommand: true }); @@ -510,7 +595,7 @@ export class UnityInstaller { await fs.promises.rm(src, { recursive: true, force: true }); } - await fs.promises.chmod(targetPath, 0o777); + await fs.promises.chmod(targetPath, 0o755); } /** @@ -549,7 +634,7 @@ export class UnityInstaller { case 'darwin': { const url = `https://beta.unity3d.com/download/unity-${unityVersion.version}.dmg`; const installerPath = tempInstallerPath('.dmg'); - await DownloadFile(url, installerPath); + await DownloadFile(url, installerPath, await this.getDownloadOptions(url)); let mountPoint = ''; try { @@ -618,7 +703,8 @@ export class UnityInstaller { if (debName) { const downloadPath = tempInstallerPath('.deb'); - await DownloadFile(`https://archive.ubuntu.com/ubuntu/pool/main/o/openssl/${debName}`, downloadPath); + const url = `https://archive.ubuntu.com/ubuntu/pool/main/o/openssl/${debName}`; + await DownloadFile(url, downloadPath, await this.getDownloadOptions(url)); try { await Exec('sudo', ['dpkg', '-i', downloadPath]); diff --git a/src/unity-logging.ts b/src/unity-logging.ts index fe37b3ae..8f4d1ec1 100644 --- a/src/unity-logging.ts +++ b/src/unity-logging.ts @@ -10,6 +10,7 @@ import { import { Delay, isStdoutTTY, + redactSensitiveLiterals, WaitForFileToBeUnlocked } from './utilities'; import { @@ -1175,7 +1176,7 @@ async function writeUtpTelemetryLog(filePath: string, entries: UTP[], logger: Lo * @param projectPath The path to the project (used for log annotation). * @returns An object containing the tail promise and signalEnd function. */ -export function TailLogFile(logPath: string, projectPath: string | undefined): LogTailResult { +export function TailLogFile(logPath: string, projectPath: string | undefined, redactLiterals: readonly string[] = []): LogTailResult { let logEnded = false; let lastSize = 0; const logPollingInterval = 250; @@ -1248,7 +1249,8 @@ export function TailLogFile(logPath: string, projectPath: string | undefined): L }; const processLogLine = (rawLine: string): void => { - const line = rawLine.trim(); + // the Editor log echoes its command line, so values like -password are redacted before anything is printed or parsed + const line = redactSensitiveLiterals(rawLine, redactLiterals).trim(); if (!line) { return; } // Attempt to parse telemetry utp JSON diff --git a/src/utilities.ts b/src/utilities.ts index 3ff00632..c6461b95 100644 --- a/src/utilities.ts +++ b/src/utilities.ts @@ -371,19 +371,50 @@ export async function extractZipNative( */ export async function HttpsGetText(url: string): Promise { return new Promise((resolve, reject) => { - https.get(url, (response) => { + const request = https.get(url, { timeout: DOWNLOAD_IDLE_TIMEOUT_MS }, (response) => { if (response.statusCode !== 200) { reject(new Error(`GET ${url} failed: HTTP ${response.statusCode}`)); response.resume(); return; } const chunks: Buffer[] = []; - response.on('data', (c: Buffer) => chunks.push(c)); + let size = 0; + response.on('data', (c: Buffer) => { + size += c.length; + + // text responses here are small manifests; anything larger is not what was asked for + if (size > MAX_TEXT_RESPONSE_BYTES) { + request.destroy(new Error(`GET ${url} returned more than ${MAX_TEXT_RESPONSE_BYTES} bytes`)); + return; + } + + chunks.push(c); + }); response.on('end', () => resolve(Buffer.concat(chunks).toString('utf8').trim())); - }).on('error', reject); + }); + request.on('timeout', () => request.destroy(new Error(`GET ${url} timed out`))); + request.on('error', reject); }); } +/** A download fails when no data arrives for this long. */ +const DOWNLOAD_IDLE_TIMEOUT_MS = 2 * 60 * 1000; + +/** The largest response HttpsGetText accepts. */ +const MAX_TEXT_RESPONSE_BYTES = 10 * 1024 * 1024; + +export interface DownloadOptions { + /** The expected SHA-256 (hex) of the file; the download fails and is deleted when it does not match. */ + sha256?: string; + /** + * An integrity value from the Unity Releases API, such as `md5-` or `sha256-`, checked the same way. + * Unity base64-encodes either the hex digest or the raw digest bytes; both are accepted. + */ + integrity?: string; + /** Creates the file executable (0o755) instead of 0o644. */ + executable?: boolean; +} + /** * Computes the SHA-256 digest of a file as a lowercase hex string. */ @@ -404,19 +435,20 @@ export async function Sha256FileHex(filePath: string): Promise { * Requires HTTP status 200 before writing. Verifies the file is readable after download. * @param url The URL to download from. * @param downloadPath The path to save the downloaded file. - * @throws An error if the download fails or the file is not accessible after download. + * @param options Integrity and mode options. + * @throws An error if the download fails, its SHA-256 does not match, or the file is not accessible after download. */ -export async function DownloadFile(url: string, downloadPath: string): Promise { +export async function DownloadFile(url: string, downloadPath: string, options: DownloadOptions = {}): Promise { logger.ci(`Downloading from ${url} to ${downloadPath}...`); await fs.promises.mkdir(path.dirname(downloadPath), { recursive: true }); await new Promise((resolve, reject) => { - https.get(url, (response) => { + const request = https.get(url, { timeout: DOWNLOAD_IDLE_TIMEOUT_MS }, (response) => { if (response.statusCode !== 200) { response.resume(); reject(new Error(`GET ${url} failed: HTTP ${response.statusCode}`)); return; } - const file = fs.createWriteStream(downloadPath, { mode: 0o755 }); + const file = fs.createWriteStream(downloadPath, { mode: options.executable ? 0o755 : 0o644 }); const fail = (err: Error) => { file.destroy(); void fs.promises.unlink(downloadPath).catch(() => undefined); @@ -428,7 +460,9 @@ export async function DownloadFile(url: string, downloadPath: string): Promise { file.close(() => resolve()); }); - }).on('error', (error) => { + }); + request.on('timeout', () => request.destroy(new Error(`GET ${url} timed out`))); + request.on('error', (error) => { void fs.promises.unlink(downloadPath).catch(() => undefined); reject(error); }); @@ -436,6 +470,111 @@ export async function DownloadFile(url: string, downloadPath: string): Promise setTimeout(r, 100)); await fs.promises.access(downloadPath, fs.constants.R_OK); + + if (options.sha256) { + const actual = await Sha256FileHex(downloadPath); + + if (actual.toLowerCase() !== options.sha256.toLowerCase()) { + await fs.promises.rm(downloadPath, { force: true }); + throw new Error(`SHA-256 mismatch for ${url}: expected ${options.sha256}, got ${actual}`); + } + } + + if (options.integrity) { + const separator = options.integrity.indexOf('-'); + const algorithm = options.integrity.slice(0, separator).toLowerCase(); + const encoded = options.integrity.slice(separator + 1); + + if (separator <= 0 || !['md5', 'sha1', 'sha256', 'sha384', 'sha512'].includes(algorithm)) { + await fs.promises.rm(downloadPath, { force: true }); + throw new Error(`Unsupported integrity value for ${url}: ${options.integrity}`); + } + + const decoded = Buffer.from(encoded, 'base64'); + const asText = decoded.toString('utf8'); + const expected = /^[0-9a-f]+$/i.test(asText) ? asText.toLowerCase() : decoded.toString('hex'); + const actual = await HashFileHex(downloadPath, algorithm); + + if (actual !== expected) { + await fs.promises.rm(downloadPath, { force: true }); + throw new Error(`${algorithm.toUpperCase()} mismatch for ${url}: expected ${expected}, got ${actual}`); + } + } +} + +/** + * Computes a file digest as a lowercase hex string. + * @param filePath The file to hash. + * @param algorithm A Node crypto hash name, such as sha256 or md5. + */ +export async function HashFileHex(filePath: string, algorithm: string): Promise { + const hash = crypto.createHash(algorithm); + const stream = fs.createReadStream(filePath); + return new Promise((resolve, reject) => { + stream.on('data', (chunk: string | Buffer) => hash.update(chunk)); + stream.on('end', () => resolve(hash.digest('hex'))); + stream.on('error', reject); + }); +} + +/** + * Redacts known secrets, email addresses and long token-like strings (JWTs, access and refresh tokens) from log text. + * Each line is split on separator characters and every piece is checked on its own, which stays linear on any input. + * @param text The text to sanitize. + * @param secrets Literal values to redact. + */ +export function SanitizeLogText(text: string, secrets: readonly string[] = []): string { + return redactSensitiveLiterals(text, secrets) + .split(/([\s"'<>=,;()[\]]+)/) + .map(part => { + const at = part.indexOf('@'); + const isEmail = at > 0 && part.lastIndexOf('.') > at + 1; + const isToken = part.length >= 32 && /^[A-Za-z0-9+_.-]+$/.test(part); + return isEmail || isToken ? '*****' : part; + }) + .join(''); +} + +/** + * Lists running processes whose executable file name is `name` (e.g. Unity.Licensing.Client), wherever it lives. + * @param name The executable file name, without .exe. + */ +export async function FindProcessesByName(name: string): Promise { + const results: ProcInfo[] = []; + const target = name.toLowerCase(); + + try { + if (process.platform === 'win32') { + const output = await Exec('powershell', [ + '-NoProfile', + '-Command', + 'Get-CimInstance Win32_Process | ForEach-Object { "$($_.ProcessId)`t$($_.ParentProcessId)`t$($_.Name)" }' + ], { silent: true, showCommand: false }); + + for (const line of output.split(/\r?\n/)) { + const [pid, ppid, executable] = line.split('\t'); + + if (pid && ppid && executable && executable.toLowerCase().replace(/\.exe$/, '') === target) { + results.push({ pid: parseInt(pid, 10), ppid: parseInt(ppid, 10), name: executable }); + } + } + } else { + const output = await Exec('ps', ['-axo', 'pid=,ppid=,comm='], { silent: true, showCommand: false }); + + for (const line of output.split('\n')) { + const match = line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/); + + // comm is the executable path on macOS and a name cut to 15 characters on Linux + if (match && (path.basename(match[3]!).toLowerCase() === target || target.startsWith(match[3]!.toLowerCase()) && match[3]!.length >= 15)) { + results.push({ pid: parseInt(match[1]!, 10), ppid: parseInt(match[2]!, 10), name: match[3]! }); + } + } + } + } catch (error) { + logger.debug(`Failed to list processes named ${name}: ${error}`); + } + + return results.filter(proc => proc.pid !== process.pid); } /** diff --git a/tests/unity-editor.test.ts b/tests/unity-editor.test.ts index c8263ed8..5abbc192 100644 --- a/tests/unity-editor.test.ts +++ b/tests/unity-editor.test.ts @@ -24,6 +24,11 @@ describe('UnityEditor', () => { }); describe('GetUnityRunArgs', () => { + it('passes the .app bundle on macOS', () => { + const args = UnityEditor.GetUnityRunArgs('/Applications/Unity/Hub/Editor/6000.0.0f1/Unity.app/Contents/MacOS/Unity', '/project', '/logs/run.log', ['-quit']); + expect(args[3]).toBe('/Applications/Unity/Hub/Editor/6000.0.0f1/Unity.app'); + }); + it('lets unity run own -batchmode, -quit, -projectPath and -logFile', () => { const args = UnityEditor.GetUnityRunArgs('/editor/Unity', '/project', '/logs/run.log', [ '-projectPath', '/project', From b87f316fc80596fa87147694bfeb5495c023d549 Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:07:46 -0400 Subject: [PATCH 35/60] read the patched client marker without a separate exists check --- src/license-client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/license-client.ts b/src/license-client.ts index 314be3d3..2385d3b6 100644 --- a/src/license-client.ts +++ b/src/license-client.ts @@ -546,7 +546,7 @@ export class LicensingClient { const patchedClientPath = path.join(patchedDirectory, path.relative(clientRoot, this.licenseClientPath)); const sourceMarker = path.join(patchedDirectory, '.source-sha256'); const sourceHash = await Sha256FileHex(this.licenseClientPath); - const isCurrent = fs.existsSync(sourceMarker) && (await fs.promises.readFile(sourceMarker, 'utf-8')).trim() === sourceHash; + const isCurrent = (await fs.promises.readFile(sourceMarker, 'utf-8').catch(() => '')).trim() === sourceHash; if (isCurrent) { this.logger.debug('Unity Licensing Client was already patched from the same client, reusing'); From a5934718c88bbfdd15d62f75f46289a04837fef5 Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Mon, 26 Oct 2026 04:38:14 -0400 Subject: [PATCH 36/60] add the newest unity 6 streams and the unity 7 alpha to the matrix and drop unity's internal gradle repos on android --- .github/scripts/unity-release-discovery.cjs | 45 ++++----------------- .github/workflows/build-options.json | 5 ++- .github/workflows/unity-build.yml | 23 ++++++++++- .github/workflows/unity-preview-canary.yml | 43 -------------------- docs/unity-version-support.md | 11 +++-- 5 files changed, 39 insertions(+), 88 deletions(-) delete mode 100644 .github/workflows/unity-preview-canary.yml diff --git a/.github/scripts/unity-release-discovery.cjs b/.github/scripts/unity-release-discovery.cjs index 6c5d87e6..c6c0fb1f 100644 --- a/.github/scripts/unity-release-discovery.cjs +++ b/.github/scripts/unity-release-discovery.cjs @@ -1,5 +1,5 @@ /** - * Weekly Unity release discovery: compare Releases API tips to CI matrix + canary pin. + * Weekly Unity release discovery: compare Releases API tips to the CI matrix. * Usage: node .github/scripts/unity-release-discovery.cjs * Env: GITHUB_STEP_SUMMARY (optional), OPEN_ISSUE=1 + GH_TOKEN/GITHUB_TOKEN to open/update drift issue. */ @@ -9,8 +9,8 @@ const { execFileSync } = require('child_process'); const repoRoot = path.join(__dirname, '..', '..'); const apiBase = 'https://services.api.unity.com/unity/editor/release/v1/releases'; -const minors = ['6000.0', '6000.1', '6000.2', '6000.3', '6000.4', '6000.5', '6000.6', '6000.7']; -const ISSUE_TITLE = 'unity-release-drift: matrix / canary vs Unity Releases API'; +const minors = ['6000.0', '6000.1', '6000.2', '6000.3', '6000.4', '6000.5', '6000.6', '6000.7', '6000.8', '7000.0', '7000.1']; +const ISSUE_TITLE = 'unity-release-drift: matrix vs Unity Releases API'; const ISSUE_LABEL = 'unity-release-drift'; async function fetchLatest(versionPrefix) { @@ -36,17 +36,6 @@ function loadMatrixVersions() { return buildOptions['unity-version'] || []; } -function loadCanaryPin() { - const yml = fs.readFileSync( - path.join(repoRoot, '.github', 'workflows', 'unity-preview-canary.yml'), - 'utf8' - ); - const version = (yml.match(/"unity-version":\s*"([^"]+)"/) || [])[1]; - const changeset = (yml.match(/"changeset":\s*"([^"]+)"/) || [])[1]; - const channel = (yml.match(/"channel":\s*"([^"]+)"/) || [])[1]; - return { version, changeset, channel }; -} - function pickTip(rows, preferStreams) { for (const stream of preferStreams) { const hit = rows.find(r => String(r.stream).toUpperCase() === stream); @@ -57,12 +46,12 @@ function pickTip(rows, preferStreams) { async function main() { const matrixVersions = loadMatrixVersions(); - const canary = loadCanaryPin(); const tips = []; for (const minor of minors) { try { const rows = await fetchLatest(minor); + if (rows.length === 0) continue; // the stream has no release yet const tip = pickTip(rows, ['LTS', 'SUPPORTED', 'BETA', 'ALPHA']); tips.push({ minor, tip, count: rows.length }); } catch (err) { @@ -79,12 +68,6 @@ async function main() { lines.push(`- \`${v}\``); } lines.push(''); - lines.push('### Preview canary pin'); - lines.push(''); - lines.push(`- version: \`${canary.version || 'n/a'}\``); - lines.push(`- changeset: \`${canary.changeset || 'n/a'}\``); - lines.push(`- channel: \`${canary.channel || 'n/a'}\``); - lines.push(''); lines.push('### API tips by minor'); lines.push(''); lines.push('| Minor | Tip version | Stream | Changeset |'); @@ -101,16 +84,10 @@ async function main() { lines.push(''); const drifts = []; - const tip60006 = tips.find(t => t.minor === '6000.6')?.tip; - if (tip60006 && canary.version && tip60006.version !== canary.version) { - drifts.push( - `Canary pin ${canary.version} is behind API tip ${tip60006.version} (${tip60006.stream}, ${tip60006.shortRevision}).` - ); - } + // every stream is tracked, alphas and betas included: the matrix resolves each row to the newest build of its stream for (const row of tips) { if (!row.tip) continue; const stream = String(row.tip.stream || '').toUpperCase(); - if (stream !== 'LTS' && stream !== 'SUPPORTED') continue; const minorKey = row.minor; // e.g. 6000.5 const covered = matrixVersions.some(v => { if (v === minorKey) return true; @@ -130,7 +107,7 @@ async function main() { if (drifts.length === 0) { lines.push('### Drift'); lines.push(''); - lines.push('No matrix/canary drift detected against API tips.'); + lines.push('No matrix drift detected against API tips.'); } else { lines.push('### Drift'); lines.push(''); @@ -142,7 +119,7 @@ async function main() { const body = lines.join('\n'); console.log(body); - // Step summary: local matrix/canary + drift count only (no HTTP-tainted API tip fields). + // Step summary: local matrix + drift count only (no HTTP-tainted API tip fields). if (process.env.GITHUB_STEP_SUMMARY) { const summaryPath = path.resolve(process.env.GITHUB_STEP_SUMMARY); const runnerTemp = process.env.RUNNER_TEMP ? path.resolve(process.env.RUNNER_TEMP) : ''; @@ -157,16 +134,10 @@ async function main() { '', ...matrixVersions.map(v => `- \`${v}\``), '', - '### Preview canary pin', - '', - `- version: \`${canary.version || 'n/a'}\``, - `- changeset: \`${canary.changeset || 'n/a'}\``, - `- channel: \`${canary.channel || 'n/a'}\``, - '', '### Drift', '', drifts.length === 0 - ? 'No matrix/canary drift detected against API tips.' + ? 'No matrix drift detected against API tips.' : `${drifts.length} drift item(s) detected. See the job log for API tip details.`, '', ].join('\n'); diff --git a/.github/workflows/build-options.json b/.github/workflows/build-options.json index 9178fb3a..c8f747d9 100644 --- a/.github/workflows/build-options.json +++ b/.github/workflows/build-options.json @@ -18,7 +18,10 @@ "6000.2", "6000.3", "6000.4", - "6000.5" + "6000.5", + "6000.6", + "6000.7", + "7000.0" ], "include": [ { diff --git a/.github/workflows/unity-build.yml b/.github/workflows/unity-build.yml index b97476cc..0d85565b 100644 --- a/.github/workflows/unity-build.yml +++ b/.github/workflows/unity-build.yml @@ -73,7 +73,9 @@ jobs: --build-targets "${MATRIX_BUILD_TARGET}" --json ) - CHANNEL="${MATRIX_CHANNEL}" + # each row tests the newest build of its stream: a final release still wins once the stream has one, + # while streams that only have betas or alphas so far (such as 6000.7 and 7000.0) resolve to those + CHANNEL="${MATRIX_CHANNEL:-f,b,a}" CHANGESET="${MATRIX_CHANGESET}" if [ -n "${CHANNEL}" ]; then setup_cmd+=(--channel "${CHANNEL}") @@ -236,6 +238,25 @@ jobs: sed -i 's/AndroidTargetSdkVersion: [0-9]*/AndroidTargetSdkVersion: 32/' "${UNITY_PROJECT_PATH}/ProjectSettings/ProjectSettings.asset" # ensure android dependencies are installed before UTP/build batches unity-cli setup-unity -p "${UNITY_PROJECT_PATH}" -m android + # 6000.0.85f1 and 6000.3.27f1 generate Gradle projects that list Unity's internal Artifactory first; its host does not + # resolve outside Unity, and Gradle fails the plugin lookup instead of moving on to Google's repository + - name: Drop Unity-internal Gradle repositories + if: ${{ matrix.build-target == 'Android' && matrix.unity-version != 'none' }} + run: | + mkdir -p "${HOME}/.gradle/init.d" + cat > "${HOME}/.gradle/init.d/drop-unity-internal-repos.gradle" <<'EOF' + def isInternal = { repo -> repo instanceof MavenArtifactRepository && (repo.url?.host ?: '').endsWith('.bf.unity3d.com') } + def drop = { repos, where -> + repos.findAll(isInternal).each { repo -> + println "Dropped the Unity-internal repository ${repo.url} from ${where}" + repos.remove(repo) + } + } + settingsEvaluated { settings -> + drop(settings.pluginManagement.repositories, 'pluginManagement') + drop(settings.dependencyResolutionManagement.repositories, 'dependencyResolutionManagement') + } + EOF - name: Compute safe artifact name id: artifact-name env: diff --git a/.github/workflows/unity-preview-canary.yml b/.github/workflows/unity-preview-canary.yml deleted file mode 100644 index fc0c9423..00000000 --- a/.github/workflows/unity-preview-canary.yml +++ /dev/null @@ -1,43 +0,0 @@ -# Non-blocking preview canary: newest Unity beta line (6000.6) with explicit channel + changeset. -# Does not gate PR merges. TMP/buildpipeline issues may keep this red until upstream fix ships. -name: unity-preview-canary -on: - schedule: - - cron: '0 6 * * 1' # weekly Monday 06:00 UTC - workflow_dispatch: -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true -jobs: - canary: - name: preview canary - uses: ./.github/workflows/unity-build.yml - permissions: - contents: read - checks: write - secrets: - UNITY_USERNAME: ${{ secrets.UNITY_USERNAME }} - UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} - with: - utp-test-profile: normal - matrix: >- - { - "include": [ - { - "name": "ubuntu-latest StandaloneLinux64", - "os": "ubuntu-latest", - "unity-version": "6000.6.0b7", - "changeset": "53d4abb44f07", - "channel": "b", - "build-target": "StandaloneLinux64" - }, - { - "name": "windows-latest StandaloneWindows64", - "os": "windows-latest", - "unity-version": "6000.6.0b7", - "changeset": "53d4abb44f07", - "channel": "b", - "build-target": "StandaloneWindows64" - } - ] - } diff --git a/docs/unity-version-support.md b/docs/unity-version-support.md index ad3ba20f..4f6db23a 100644 --- a/docs/unity-version-support.md +++ b/docs/unity-version-support.md @@ -1,20 +1,19 @@ # Unity version support -This repo tests Unity editors on CI in three tiers: +This repo tests Unity editors on CI in two tiers: | Tier | What it means | How CI selects | |------|----------------|----------------| -| **Supported** | Blocking integration matrix; must stay green for releases | [`.github/workflows/build-options.json`](../.github/workflows/build-options.json) via `setup-unity` with default `--channel f` (stable/`f` only) | -| **Preview** | Best-effort beta canary; may be red (e.g. package TMP import) | [`.github/workflows/unity-preview-canary.yml`](../.github/workflows/unity-preview-canary.yml) — pinned FQ beta + `--channel b`, `continue-on-error` | +| **Tested** | Integration matrix, from 4.7.2 through the newest Unity 6 beta and the Unity 7 alpha | [`.github/workflows/build-options.json`](../.github/workflows/build-options.json) via `setup-unity --channel f,b,a`: each row runs the newest build of its stream, and a final release wins once the stream has one | | **Untested** | Not in CI; still installable | Fully-qualified `-u 6000.x.yfN -c ` or partial + `--channel b\|a` | ## Resolving versions - Partial versions (`6000.5`, `2021.3.x`) resolve only within the requested channel(s). Default is **stable (`f`)**. -- If no matching stable release exists (e.g. `6000.6` while only betas ship), `setup-unity` **fails closed** instead of letting the installer guess a beta. +- If no matching stable release exists (e.g. `6000.7` while only betas ship), `setup-unity` **fails closed** instead of letting the installer guess a beta. - Use `--channel b` (or `a`) when you intentionally want pre-release streams. -- Weekly [unity-release-discovery](../.github/workflows/unity-release-discovery.yml) compares the Releases API to the matrix/canary pin and opens/updates a `unity-release-drift` issue when tips move. +- Weekly [unity-release-discovery](../.github/workflows/unity-release-discovery.yml) compares the Releases API to the matrix and opens/updates a `unity-release-drift` issue when a stream is missing from it. ## Dependencies -Integration UTP batches install `com.utilities.buildpipeline` from OpenUPM **unpinned** (latest). Preview canary exercises Validate + TMP essentials import against that package. +Integration UTP batches install `com.utilities.buildpipeline` from OpenUPM **unpinned** (latest), so the beta and alpha rows also exercise Validate + TMP essentials import against that package. From 0d4a8ee2470f5717086c50e24808dd6f7530606b Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Mon, 26 Oct 2026 04:50:10 -0400 Subject: [PATCH 37/60] stop setting UNITY_THISISABUILDMACHINE, which points gradle at unity's internal repos --- .github/workflows/unity-build.yml | 19 ------------------- src/unity-editor.ts | 1 - 2 files changed, 20 deletions(-) diff --git a/.github/workflows/unity-build.yml b/.github/workflows/unity-build.yml index 0d85565b..262ba5ce 100644 --- a/.github/workflows/unity-build.yml +++ b/.github/workflows/unity-build.yml @@ -238,25 +238,6 @@ jobs: sed -i 's/AndroidTargetSdkVersion: [0-9]*/AndroidTargetSdkVersion: 32/' "${UNITY_PROJECT_PATH}/ProjectSettings/ProjectSettings.asset" # ensure android dependencies are installed before UTP/build batches unity-cli setup-unity -p "${UNITY_PROJECT_PATH}" -m android - # 6000.0.85f1 and 6000.3.27f1 generate Gradle projects that list Unity's internal Artifactory first; its host does not - # resolve outside Unity, and Gradle fails the plugin lookup instead of moving on to Google's repository - - name: Drop Unity-internal Gradle repositories - if: ${{ matrix.build-target == 'Android' && matrix.unity-version != 'none' }} - run: | - mkdir -p "${HOME}/.gradle/init.d" - cat > "${HOME}/.gradle/init.d/drop-unity-internal-repos.gradle" <<'EOF' - def isInternal = { repo -> repo instanceof MavenArtifactRepository && (repo.url?.host ?: '').endsWith('.bf.unity3d.com') } - def drop = { repos, where -> - repos.findAll(isInternal).each { repo -> - println "Dropped the Unity-internal repository ${repo.url} from ${where}" - repos.remove(repo) - } - } - settingsEvaluated { settings -> - drop(settings.pluginManagement.repositories, 'pluginManagement') - drop(settings.dependencyResolutionManagement.repositories, 'dependencyResolutionManagement') - } - EOF - name: Compute safe artifact name id: artifact-name env: diff --git a/src/unity-editor.ts b/src/unity-editor.ts index 15911c22..1c92c923 100644 --- a/src/unity-editor.ts +++ b/src/unity-editor.ts @@ -262,7 +262,6 @@ export class UnityEditor { : undefined; const baseEditorEnv: NodeJS.ProcessEnv = { ...process.env, - UNITY_THISISABUILDMACHINE: '1', DISABLE_EMBEDDED_BUILD_PIPELINE_PLUGIN_LOGGING: '1', ...(linuxEnvOverrides ?? {}) }; From e21abfb3bb00ec147b1dc6b5fedd3fbcaaccb735 Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Mon, 26 Oct 2026 05:40:16 -0400 Subject: [PATCH 38/60] install the latest official cli on first use, restore the upm commands, and forward the v3 project and uninstall commands to unity --- .github/workflows/unity-build.yml | 13 +- README.md | 91 +++-- src/cli.ts | 372 +++++++++++++++++++-- src/license-client.ts | 2 +- src/unity-cli.ts | 16 +- src/upm-cli.ts | 531 ++++++++++++++++++++++++++++++ tests/upm-cli.test.ts | 34 ++ 7 files changed, 1003 insertions(+), 56 deletions(-) create mode 100644 src/upm-cli.ts create mode 100644 tests/upm-cli.test.ts diff --git a/.github/workflows/unity-build.yml b/.github/workflows/unity-build.yml index 262ba5ce..b11d1af4 100644 --- a/.github/workflows/unity-build.yml +++ b/.github/workflows/unity-build.yml @@ -65,8 +65,17 @@ jobs: - name: Setup Unity timeout-minutes: 30 run: | - unity-cli setup-cli --json - if [ "${MATRIX_UNITY_VERSION}" != "none" ]; then + unity-cli upm-install --json + UPM_VER=$(unity-cli upm-version | tr -d '\n\r') + echo "UPM CLI version: ${UPM_VER}" + if ! [[ "${UPM_VER}" =~ ^v[0-9] ]]; then + echo "::error::upm-version expected a v-prefixed release (e.g. v9.0.0); got: ${UPM_VER}" + exit 1 + fi + if [ "${MATRIX_UNITY_VERSION}" = "none" ]; then + # the official CLI installs on first use; license-context needs it and no editor + unity-cli license-context + else setup_cmd=( unity-cli setup-unity --unity-version "${MATRIX_UNITY_VERSION}" diff --git a/README.md b/README.md index 8d525907..4d47c490 100644 --- a/README.md +++ b/README.md @@ -13,11 +13,11 @@ In unity's infinite wisdom, they decided to rip off yet another one of my open s v4 is the last feature release. The repository stays open for security and crash fixes only, and there are no new commands. `@rage-against-the-pixel/unity-cli` v3 and v4 stay on npm. -Use Unity's official CLI for everything it covers. See [Use the Unity CLI](https://docs.unity.com/en-us/unity-cli/use-unity-cli). That includes Hub-free editor installs and uninstalls, modules, project create and open, templates, builds, tests, serial, floating and offline licenses, and service-account auth. Pack and sign UPM packages with Unity's [Unity Package Manager CLI](https://docs.unity3d.com/Manual/upm-cli.html). - +Use Unity's official CLI for everything it covers. See [Use the Unity CLI](https://docs.unity.com/en-us/unity-cli/use-unity-cli). That includes Hub-free editor installs and uninstalls, modules, project create and open, templates, builds, tests, serial, floating and offline licenses, and service-account auth. unity-cli v4 still covers: -- Installing and pinning the official Unity CLI, with SHA-256 verification, for CI runners. +- Installing the official Unity CLI on first use, with SHA-256 verification, for CI runners. +- Installing the Unity Package Manager CLI and packing signed packages with it. - License activation and return with a Unity ID email and password, used when the official CLI cannot activate, such as a personal license on a headless runner. - Unity 4.x and 5.x editors: installs from Unity's archives and the license file those editors read. - Android target SDK setup for a project. @@ -38,7 +38,7 @@ Send feature requests that the official CLI already covers to Unity. - [Installation](#installation) - [Usage](#usage) - [Common Commands](#common-commands) - - [Setup Unity CLI](#setup-unity-cli) + - [Official Unity CLI](#official-unity-cli) - [Auth](#auth) - [License Version](#license-version) - [Activate License](#activate-license) @@ -50,25 +50,31 @@ Send feature requests that the official CLI already covers to Unity. - [Setup Unity Editor](#setup-unity-editor) - [Run Unity Editor Commands](#run-unity-editor-commands) - [Unity Editor Logs](#unity-editor-logs) + - [Deprecated Commands](#deprecated-commands) + - [Unity Package Manager](#unity-package-manager) + - [Install Unity Package Manager](#install-unity-package-manager) + - [UPM Version](#upm-version) + - [Pack a Unity Package](#pack-a-unity-package) - [Logging](#logging) - [Local cli](#local-cli) - [Github Actions](#github-actions) ## Upgrading from v3 -v4 removes the commands the official CLI covers. Call `unity` for them instead. +v4 drops the Hub. Commands that need the official CLI install it on first use. -| v3 command | v4 replacement | +| v3 command | v4 | | --- | --- | -| `install-all-tools`, `hub-install` | `unity-cli setup-cli` (no Hub needed) | -| `hub`, `hub-path`, `hub-version`, `hub-logs`, `package-manager-logs` | `unity env`, `unity logs`, `unity doctor` | -| `uninstall-unity` | `unity uninstall --yes` | -| `list-project-templates` | `unity templates list --editor ` | -| `create-project` | `unity projects create --editor-version --template --no-cloud` | -| `open-project` | `unity open` | -| `upm-install`, `upm-version`, `upm-pack`, `sign-package` | Unity's [Package Manager CLI](https://docs.unity3d.com/Manual/upm-cli.html) | +| `install-all-tools`, `hub-install` | Removed. The official CLI installs on first use, with no Hub needed | +| `hub`, `hub-path`, `hub-version`, `hub-logs`, `package-manager-logs` | Removed. Use `unity env`, `unity logs`, `unity doctor` | +| `sign-package` | Removed. Use `upm-pack` | +| `uninstall-unity` | Deprecated, same options, forwards to `unity uninstall --yes` | +| `list-project-templates` | Deprecated, same options, forwards to `unity templates list --editor ` | +| `create-project` | Deprecated, same options, forwards to `unity projects create --path --editor-version --template --no-cloud` | +| `open-project` | Deprecated, same options, forwards to `unity open ` | +| `upm-install`, `upm-version`, `upm-pack` | Unchanged | -`setup-unity` exports `UNITY_CLI_PATH` instead of `UNITY_HUB_PATH`. Unknown commands are not forwarded to `unity`. +`setup-unity` exports `UNITY_CLI_PATH` instead of `UNITY_HUB_PATH`. Only the commands above are forwarded; unknown commands are not passed to `unity`. ## Installation @@ -103,16 +109,14 @@ With options always using double dashes (`--option`) and arguments passed direct unity-cli --help ``` -### Setup Unity CLI +### Official Unity CLI -`setup-cli [options]`: Install the official Unity CLI, or reuse it when the requested version is already installed. It downloads the exact release from Unity's CDN, checks its SHA-256, caches it in the runner tool cache (`unity-cli//`), adds it to the `PATH`, and exports `UNITY_CLI_PATH` and `UNITY_CLI_VERSION`. It also sets `UNITY_NON_INTERACTIVE`, `UNITY_NO_UPDATE_CHECK` and `UNITY_NO_CONSENT_PROMPT` so the CLI never prompts or updates itself mid-job. +Every command that needs the official Unity CLI installs it on first use. It downloads the latest release from Unity's CDN, checks the SHA-256 that Unity's release manifest lists, caches it in the runner tool cache (`unity-cli//`), adds it to the `PATH`, and exports `UNITY_CLI_PATH` and `UNITY_CLI_VERSION`. Later steps in the same job read `UNITY_CLI_VERSION` and reuse that copy. It also sets `UNITY_NON_INTERACTIVE`, `UNITY_NO_UPDATE_CHECK` and `UNITY_NO_CONSENT_PROMPT` so the CLI never prompts or updates itself mid-job. -- `--unity-cli-version `: An exact official Unity CLI version (e.g. `1.0.0-beta.14`) or `latest`. Defaults to an installed CLI, then the version unity-cli is tested against. -- `--verbose`: Enable verbose logging. -- `--json`: Prints the last line of output as JSON string. +To use a specific release, pass `--unity-cli-version ` to any command or set `UNITY_CLI_VERSION`: ```bash -unity-cli setup-cli --unity-cli-version 1.0.0-beta.14 +unity-cli --unity-cli-version 1.0.0-beta.14 setup-unity --unity-version 6000 ``` ### Auth @@ -205,7 +209,6 @@ unity-cli licensing-audit-logs - `-b`, `--build-targets ` The Unity build target to get/install as comma-separated values (e.g. `iOS,Android`). - `-m`, `--modules ` The Unity module to get/install as comma-separated values (e.g. `ios,android`). - `-i`, `--install-path ` The path to install the Unity Editor to. By default, it will be installed to the official CLI's install path. -- `--unity-cli-version ` The official Unity CLI version to install when it is missing. - `--verbose` Enable verbose logging. - `--json` Prints the last line of output as JSON string. @@ -247,6 +250,52 @@ unity-cli run --unity-project -quit -batchmode -executeMethod unity-cli editor-logs ``` +### Deprecated Commands + +`uninstall-unity`, `list-project-templates`, `create-project` and `open-project` keep their v3 options, print a deprecation warning that names the replacement, and forward to the official Unity CLI (see [Upgrading from v3](#upgrading-from-v3)). `create-project` still accepts a template regex and picks the first matching template id. Run `unity-cli --help` for the options. + +### Unity Package Manager + +Unity publishes the pack and sign tool as its own CLI. See [Unity Package Manager CLI workflow](https://docs.unity3d.com/Manual/upm-cli.html). The commands below call that same `upm` binary. + +#### Install Unity Package Manager + +`upm-install [options]`: Download and install the Unity Package Manager cli (pack/sign). + +- `--auto-update`: Automatically updates the upm cli if it is already installed and a newer release is available. Cannot be used with `--version`. +- `--version `: Specify to install a specific version of the Unity Package Manager cli. Cannot be used with `--auto-update`. +- `--json`: Print version and managed paths as JSON. +- `--verbose`: Enable verbose logging. + +```bash +unity-cli upm-install --auto-update +``` + +#### UPM Version + +`upm-version`: Print the Unity Package Manager cli version. + +```bash +unity-cli upm-version +``` + +#### Pack a Unity Package + +**Prerequisites:** In the [Unity Cloud Dashboard](https://cloud.unity.com/), create a **service account** on the organization you use for signing. When you assign **organization** access, open **Manage organization roles**, set the **Package Manager** role to **Package Manager Package Signer**, and save. Put the generated key id and secret in `UPM_SERVICE_ACCOUNT_KEY_ID` and `UPM_SERVICE_ACCOUNT_KEY_SECRET` (or your CI secret store). Copy **Organization ID** from **Administration** → **Settings** in that same org. If you have multiple orgs, switch to the correct one in the dashboard before creating keys or copying the id. + +`upm-pack [options]`: Sign and pack a Unity package. + +- `--source `: An absolute or relative path to the root folder of the custom package to pack. This is the folder that contains the package manifest file (package.json). (optional; defaults to the current working directory). +- `--destination `: The output path for the signed tarball. If you specify a folder that doesn’t exist, it will be created for you. Note: If you omit this parameter, the tarball will be placed in the current working directory. +- `--verbose`: Enable verbose logging. + +> [!NOTE] +> Set `UNITY_ORGANIZATION_ID` or `UNITY_ORG_ID`, `UPM_SERVICE_ACCOUNT_KEY_ID`, and `UPM_SERVICE_ACCOUNT_KEY_SECRET`, or leave them unset in an interactive terminal to be prompted securely. + +```bash +unity-cli upm-pack --source --destination +``` + ## Logging ### Local cli diff --git a/src/cli.ts b/src/cli.ts index 78e0f4c1..e6a8e90d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -22,6 +22,7 @@ import { ReturnLicense, } from './license-client'; import { PromptForSecretInput } from './utilities'; +import { UpmCli, UpmPackOptions } from './upm-cli'; const pkgPath = path.join(__dirname, '..', 'package.json'); const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); @@ -30,28 +31,15 @@ const program = new Command(); program.name('unity-cli') .description('Bridges the gaps in the official Unity CLI (unity) for CI: Unity ID licensing, Unity 4.x and 5.x editors, Android SDK setup and Editor log annotations.') - .version(pkg.version); - -program.command('setup-cli') - .description('Install the official Unity CLI (unity), or reuse it when the requested version is already installed.') - .option('--unity-cli-version ', 'An exact official Unity CLI version (e.g. 1.0.0-beta.14) or latest. Defaults to an installed CLI, then the version unity-cli is tested against.') - .option('--verbose', 'Enable verbose logging.') - .option('--json', 'Prints the last line of output as JSON string.') - .action(async (options) => { - if (options.verbose) { - Logger.instance.logLevel = LogLevel.DEBUG; + .version(pkg.version) + .option('--unity-cli-version ', 'The official Unity CLI version to use (e.g. 1.0.0-beta.14). Defaults to the UNITY_CLI_VERSION environment variable, then the latest release. Commands install it on first use.') + .hook('preAction', (command) => { + const version = command.opts().unityCliVersion?.toString()?.trim(); + + if (version) { + // every command resolves the official CLI through UnityCli.Get(), which reads the request from here + process.env.UNITY_CLI_VERSION = version; } - - Logger.instance.debugOptions(options); - const cli = await UnityCli.Get(options.unityCliVersion?.toString()?.trim() || undefined); - - if (options.json) { - process.stdout.write(`\n${JSON.stringify({ UNITY_CLI_PATH: cli.executable, UNITY_CLI_VERSION: cli.version })}\n`); - } else { - process.stdout.write(`${cli.executable}\n`); - } - - process.exit(0); }); program.commandsGroup('Auth:'); @@ -200,7 +188,6 @@ program.command('setup-unity') .option('-b, --build-targets ', 'The Unity build target to get/install as comma-separated values (e.g. iOS,Android).') .option('-m, --modules ', 'The Unity module to get/install as comma-separated values (e.g. ios,android).') .option('-i, --install-path ', 'The path to install the Unity Editor to. By default, it will be installed to the Unity CLI install path.') - .option('--unity-cli-version ', 'The official Unity CLI version to install when it is missing (e.g. 1.0.0-beta.14, latest).') .option('--verbose', 'Enable verbose logging.') .option('--json', 'Prints the last line of output as JSON string.') .action(async (options) => { @@ -267,7 +254,7 @@ program.command('setup-unity') } } - const unityCli = await UnityCli.Get(options.unityCliVersion?.toString()?.trim() || undefined); + const unityCli = await UnityCli.Get(); const installer = new UnityInstaller(unityCli); if (options.installPath) { @@ -431,6 +418,345 @@ program.command('editor-logs') process.exit(0); }); + +program.commandsGroup('Deprecated, forwarded to the official Unity CLI:'); + +program.command('uninstall-unity') + .description('[Deprecated] Uninstall a Unity Editor version. Forwards to: unity uninstall --yes') + .option('-e, --unity-editor ', 'The path to the Unity Editor executable. If unspecified, -u, --unity-version or the UNITY_EDITOR_PATH environment variable must be set.') + .option('-u, --unity-version ', 'The Unity version to uninstall (e.g. 2020.3.1f1, 6000.2). If unspecified, then --unity-editor must be specified.') + .option('-c, --changeset ', 'Ignored; the official CLI uninstalls by version.') + .option('-a, --arch ', 'The Unity architecture to uninstall (x86_64, arm64).') + .option('--verbose', 'Enable verbose logging.') + .action(async (options) => { + if (options.verbose) { + Logger.instance.logLevel = LogLevel.DEBUG; + } + + Logger.instance.debugOptions(options); + const version = await forwardedEditorVersion(options); + + if (!version) { + Logger.instance.error('You must specify the Unity version or editor path with -u, --unity-version, -e, --unity-editor or UNITY_EDITOR_PATH.'); + process.exit(1); + } + + const args = ['uninstall', version, '--yes']; + const arch = options.arch?.toString()?.trim(); + + if (arch) { + args.push('--architecture', arch); + } + + await forwardToUnityCli('uninstall-unity', args); + }); + +program.command('list-project-templates') + .description('[Deprecated] List available Unity project templates for an editor. Forwards to: unity templates list --editor ') + .option('-u, --unity-version ', 'The Unity version to list templates for (e.g. 2022.3.1f1, 6000.2). If unspecified, then --unity-editor must be specified.') + .option('-e, --unity-editor ', 'The path to the Unity Editor executable. If unspecified, -u, --unity-version or the UNITY_EDITOR_PATH environment variable must be set.') + .option('--verbose', 'Enable verbose logging.') + .option('--json', 'Prints the output as JSON.') + .action(async (options) => { + if (options.verbose) { + Logger.instance.logLevel = LogLevel.DEBUG; + } + + Logger.instance.debugOptions(options); + const version = await forwardedEditorVersion(options, true); + await forwardToUnityCli('list-project-templates', ['templates', 'list', ...(version ? ['--editor', version] : [])], options.json === true); + }); + +program.command('create-project') + .description('[Deprecated] Create a new Unity project. Forwards to: unity projects create --path --editor-version --template --no-cloud') + .option('-n, --name ', 'The name of the new Unity project. If unspecified, the project will be created in the specified path or the current working directory.') + .option('-p, --path ', 'The path to create the new Unity project. If unspecified, the current working directory will be used.') + .option('-t, --template ', 'The name of the template package to use for creating the unity project. Supports regex patterns.', 'com.unity.template.3d(-cross-platform)?') + .option('-u, --unity-version ', 'The Unity version to create the project with (e.g. 2022.3.1f1, 6000.2). If unspecified, then --unity-editor must be specified.') + .option('-e, --unity-editor ', 'The path to the Unity Editor executable. If unspecified, -u, --unity-version, or the UNITY_EDITOR_PATH environment variable must be set.') + .option('--verbose', 'Enable verbose logging.') + .option('--json', 'Prints the output as JSON.') + .action(async (options) => { + if (options.verbose) { + Logger.instance.logLevel = LogLevel.DEBUG; + } + + Logger.instance.debugOptions(options); + const version = await forwardedEditorVersion(options, true); + const target = path.resolve(options.path?.toString()?.trim() || process.cwd()); + const name = options.name?.toString()?.trim(); + const args = ['projects', 'create', name || path.basename(target), '--path', name ? target : path.dirname(target)]; + + if (version) { + args.push('--editor-version', version); + } + + const template = options.template.toString().trim(); + let templateId = template; + + // v3 matched templates with a regex; the official CLI takes an exact template id + if (!/^[\w.-]+$/.test(template)) { + const cli = await UnityCli.Get(); + const { envelope } = await cli.RunJson<{ name?: string; id?: string }[]>(['templates', 'list', ...(version ? ['--editor', version] : [])]); + const pattern = new RegExp(`^(?:${template})$`); + const ids = (envelope?.data ?? []).map(t => t.name || t.id).filter((id): id is string => !!id); + const match = ids.find(id => pattern.test(id)); + + if (!match) { + Logger.instance.error(`No project template matches ${template}. Available: ${ids.join(', ')}`); + process.exit(1); + } + + templateId = match; + } + + args.push('--template', templateId, '--no-cloud'); + await forwardToUnityCli('create-project', args, options.json === true); + }); + +program.command('open-project') + .description('[Deprecated] Open a Unity project in the Unity Editor. Forwards to: unity open ') + .option('-p, --unity-project ', 'The path to a Unity project. If unspecified, the UNITY_PROJECT_PATH environment variable or the current working directory will be used.') + .option('-u, --unity-version ', 'The Unity version to open the project with. If specified, it will override the version read from the project.') + .option('-t, --build-target ', 'The Unity build target to switch the project to (e.g. StandaloneWindows64, StandaloneOSX, iOS, Android).') + .option('--verbose', 'Enable verbose logging.') + .action(async (options) => { + if (options.verbose) { + Logger.instance.logLevel = LogLevel.DEBUG; + } + + Logger.instance.debugOptions(options); + const projectPath = options.unityProject?.toString()?.trim() || process.env.UNITY_PROJECT_PATH || process.cwd(); + const args = ['open', projectPath]; + const version = options.unityVersion?.toString()?.trim(); + const buildTarget = options.buildTarget?.toString()?.trim(); + + if (version) { + args.push('--editor-version', version); + } + + if (buildTarget) { + args.push('--build-target', buildTarget); + } + + await forwardToUnityCli('open-project', args); + }); + +/** + * The editor version a forwarded v3 command names, from --unity-version or the editor at --unity-editor / UNITY_EDITOR_PATH. + * Partial versions such as 2021.3.x become 2021.3, the short form `unity uninstall` accepts; with `exact`, they resolve + * to the newest matching release instead, because `unity templates list` and `unity projects create` need a full version. + */ +async function forwardedEditorVersion(options: { unityVersion?: string; unityEditor?: string }, exact: boolean = false): Promise { + const version = options.unityVersion?.toString()?.trim(); + + if (version) { + if (!exact) { + return version.replace(/\.[x*]$/i, ''); + } + + const unityVersion = new UnityVersion(version); + + if (unityVersion.isFullyQualified()) { + return unityVersion.version; + } + + return (await new UnityInstaller(await UnityCli.Get()).resolveVersion(unityVersion)).version; + } + + const editorPath = options.unityEditor?.toString()?.trim() || process.env.UNITY_EDITOR_PATH; + return editorPath ? new UnityEditor(editorPath).version.version : undefined; +} + +/** Warns that a v3 command is deprecated, runs its official Unity CLI replacement and exits with that exit code. */ +async function forwardToUnityCli(command: string, args: string[], json: boolean = false): Promise { + Logger.instance.warn(`unity-cli ${command} is deprecated and will be removed. Use the official Unity CLI instead: unity ${args.join(' ')}`); + const cli = await UnityCli.Get(); + const result = await cli.Run(json ? [...args, '--json'] : args, { stream: !json }); + + if (json) { + process.stdout.write(`${result.stdout.trim()}\n`); + } + + process.exit(result.exitCode); +} + +program.commandsGroup("Unity Package Manager:"); + +program.command('upm-install') + .description('Download and install the Unity Package Manager cli (pack/sign).') + .option('--verbose', 'Enable verbose logging.') + .option('--auto-update', 'Automatically updates the upm cli if it is already installed and a newer release is available. Cannot be used with --version.') + .option('--version ', 'Specify to install a specific version of the Unity Package Manager cli. Cannot be used with --auto-update.') + .option('--json', 'Print version and managed paths as JSON.') + .action(async (options) => { + if (options.verbose) { + Logger.instance.logLevel = LogLevel.DEBUG; + } + + Logger.instance.debugOptions(options); + + try { + if (options.autoUpdate === true && options.version) { + Logger.instance.error('Cannot use --auto-update with --version.'); + process.exit(1); + } + + const upm = new UpmCli(); + let requestedVersion = options.version?.toString()?.trim(); + if (options.autoUpdate === true) { + const currentVersion = upm.GetInstalledReleaseTag(); + const latestVersion = await upm.GetLatestReleaseTag(); + + if (currentVersion && !upm.IsUpdateAvailable(latestVersion)) { + Logger.instance.info(`Upm cli is already up to date (${currentVersion}).`); + requestedVersion = currentVersion; + } else { + if (currentVersion) { + Logger.instance.info(`Updating upm cli ${currentVersion} -> ${latestVersion}...`); + } + requestedVersion = latestVersion; + } + } + + const ver = await upm.Install({ + version: requestedVersion, + skipIfInstalled: true + }); + + await upm.Version(ver); + const exe = upm.executable; + + if (options.json) { + process.stdout.write(`\n${JSON.stringify({ + UPM_VERSION: ver, + UPM_CLI_PATH: exe, + UPM_MANAGED_ROOT: upm.managedRoot, + })}\n`); + } else { + process.stdout.write(`Upm cli ${ver} installed.\n`); + process.stdout.write(`${exe}\n`); + } + + process.exit(0); + } catch (error) { + Logger.instance.error(`${error}`); + process.exit(1); + } + }); + +program.command('upm-version') + .description('Print the Unity Package Manager cli version.') + .action(async () => { + try { + const upmCli = new UpmCli(); + const version = await upmCli.Version(); + process.stdout.write(`v${version.version}\n`); + process.exit(0); + } catch (error) { + Logger.instance.error(`${error}`); + process.exit(1); + } + }); + +interface UpmPackCliOptions { + source?: string; + destination?: string; + verbose?: boolean; +} + +program.command('upm-pack') + .description('Sign and pack a Unity package.') + .option('--source ', 'An absolute or relative path to the root folder of the custom package to pack. This is the folder that contains the package manifest file (package.json). (optional; defaults to the current working directory).') + .option('--destination ', 'The output path for the signed tarball. If you specify a folder that doesn\'t exist, it will be created for you. Note: If you omit this parameter, the tarball will be placed in the current working directory.') + .option('--verbose', 'Enable verbose logging.') + .action(async (options: UpmPackCliOptions) => { + if (options.verbose) { + Logger.instance.logLevel = LogLevel.DEBUG; + } + + Logger.instance.debugOptions({ options }); + + try { + const upm = new UpmCli(); + await upm.PromptInstallOrUpdateWhenInteractive(); + + let serviceAccountKeyId = process.env.UPM_SERVICE_ACCOUNT_KEY_ID?.trim(); + + if (!serviceAccountKeyId) { + serviceAccountKeyId = (await PromptForSecretInput('UPM_SERVICE_ACCOUNT_KEY_ID: ')).trim(); + } + + if (!serviceAccountKeyId) { + Logger.instance.error( + 'UPM_SERVICE_ACCOUNT_KEY_ID is required. Set the environment variable or enter a value when prompted.' + ); + process.exit(1); + } + + let serviceAccountKeySecret = process.env.UPM_SERVICE_ACCOUNT_KEY_SECRET?.trim(); + + if (!serviceAccountKeySecret) { + serviceAccountKeySecret = (await PromptForSecretInput('UPM_SERVICE_ACCOUNT_KEY_SECRET: ')).trim(); + } + + if (!serviceAccountKeySecret) { + Logger.instance.error( + 'UPM_SERVICE_ACCOUNT_KEY_SECRET is required. Set the environment variable or enter a value when prompted.' + ); + process.exit(1); + } + + process.env.UPM_SERVICE_ACCOUNT_KEY_ID = serviceAccountKeyId; + Logger.instance.maskCredential(serviceAccountKeyId); + process.env.UPM_SERVICE_ACCOUNT_KEY_SECRET = serviceAccountKeySecret; + Logger.instance.maskCredential(serviceAccountKeySecret); + + let orgId = process.env.UNITY_ORGANIZATION_ID?.trim() || process.env.UNITY_ORG_ID?.trim(); + const dest = options.destination?.toString()?.trim(); + + if (!orgId) { + orgId = (await PromptForSecretInput('UNITY_ORGANIZATION_ID: ')).trim(); + } + + if (!orgId) { + Logger.instance.error( + 'Organization ID is required. Set UNITY_ORGANIZATION_ID or UNITY_ORG_ID, or enter a value when prompted.' + ); + process.exit(1); + } + + Logger.instance.maskCredential(orgId); + + const redactLiterals = [orgId, serviceAccountKeyId, serviceAccountKeySecret].filter( + (s): s is string => typeof s === 'string' && s.trim().length > 0 + ); + + const packOptions: UpmPackOptions = { + organizationId: orgId, + }; + + if (dest) { + packOptions.destination = dest; + } + + const sourceArg = options.source?.toString()?.trim(); + packOptions.packageDirectory = + sourceArg && sourceArg.length > 0 ? path.resolve(sourceArg) : process.cwd(); + + await upm.Pack(packOptions, { + silent: false, + showCommand: Logger.instance.logLevel === LogLevel.DEBUG, + redactLiterals, + }); + + process.exit(0); + } catch (error) { + Logger.instance.error(`${error}`); + process.exit(1); + } + }); + void program.parseAsync(process.argv).catch((err: unknown) => { Logger.instance.error(err instanceof Error ? err.message : String(err)); process.exit(1); diff --git a/src/license-client.ts b/src/license-client.ts index 2385d3b6..80679a79 100644 --- a/src/license-client.ts +++ b/src/license-client.ts @@ -106,7 +106,7 @@ export class LicensingClient { } if (!licenseClientPath) { - throw new Error('Failed to find Unity.Licensing.Client. Install the Unity CLI with "unity-cli setup-cli", or set UNITY_LICENSING_CLIENT_PATH.'); + throw new Error('Failed to find Unity.Licensing.Client. Set UNITY_LICENSING_CLIENT_PATH to its location.'); } await LicensingClient.ensureMacLicenseDirectory(); diff --git a/src/unity-cli.ts b/src/unity-cli.ts index 824aa8ab..2246bb3b 100644 --- a/src/unity-cli.ts +++ b/src/unity-cli.ts @@ -12,9 +12,6 @@ import { redactSensitiveLiterals, } from './utilities'; -/** The official Unity CLI version unity-cli is tested against. Callers own the version they install. */ -export const DEFAULT_UNITY_CLI_VERSION = '1.0.0-beta.14'; - const CDN_BASE = 'https://public-cdn.cloud.unity3d.com/hub/prod/cli/'; const TOOL_NAME = 'unity-cli'; const EXE_NAME = process.platform === 'win32' ? 'unity.exe' : 'unity'; @@ -63,11 +60,12 @@ export class UnityCli { /** * Returns the official Unity CLI, installing it when it is missing or is not the requested version. - * @param version An exact version such as 1.0.0-beta.14, or `latest`. Defaults to an already installed CLI, then {@link DEFAULT_UNITY_CLI_VERSION}. + * @param version An exact version such as 1.0.0-beta.14, or `latest`. Defaults to UNITY_CLI_VERSION, then `latest`. + * Every install exports UNITY_CLI_VERSION, so later steps in the same job reuse the version the first one resolved. */ public static async Get(version: string | undefined = undefined): Promise { configureEnvironment(); - const requested = version ? normalizeVersion(version) : undefined; + const requested = normalizeVersion(version || process.env.UNITY_CLI_VERSION || 'latest'); // latest resolves the manifest first, so an outdated CLI is not reused if (requested === 'latest') { @@ -76,7 +74,7 @@ export class UnityCli { const existing = await UnityCli.Find(); - if (existing && (!requested || existing.version === requested)) { + if (existing && existing.version === requested) { Logger.instance.debug(`Using Unity CLI ${existing.version} at ${existing.executable}`); return exportCli(existing); } @@ -85,7 +83,7 @@ export class UnityCli { Logger.instance.info(`Found Unity CLI ${existing.version} at ${existing.executable}, but ${requested} was requested`); } - return await UnityCli.Install(requested ?? DEFAULT_UNITY_CLI_VERSION); + return await UnityCli.Install(requested); } /** @@ -128,7 +126,7 @@ export class UnityCli { * Downloads an exact official Unity CLI release from Unity's CDN, verifies its SHA-256 and caches it. * @param version An exact version such as 1.0.0-beta.14, or `latest`. */ - public static async Install(version: string = DEFAULT_UNITY_CLI_VERSION): Promise { + public static async Install(version: string = 'latest'): Promise { configureEnvironment(); const requested = normalizeVersion(version); const manifest = await fetchManifest(requested); @@ -532,7 +530,7 @@ function normalizeVersion(version: string): string { const normalized = trimmed.replace(/^v/i, ''); if (!VERSION_PATTERN.test(normalized)) { - throw new Error(`Invalid Unity CLI version: ${version}. Use an exact version such as ${DEFAULT_UNITY_CLI_VERSION}, or latest.`); + throw new Error(`Invalid Unity CLI version: ${version}. Use an exact version such as 1.0.0-beta.14, or latest.`); } return normalized; diff --git a/src/upm-cli.ts b/src/upm-cli.ts new file mode 100644 index 00000000..9ed65fe8 --- /dev/null +++ b/src/upm-cli.ts @@ -0,0 +1,531 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + SemVer, + coerce, + compare, + parse, + valid, +} from 'semver'; +import { + Logger, + LogLevel +} from './logging'; +import { + DeleteDirectory, + DownloadFile, + Exec, + ExecOptions, + extractZipNative, + GetTempDir, + HttpsGetText, + isInteractiveTerminalSession, + PromptYesNo, + Sha256FileHex, +} from './utilities'; + +export interface EnsureUpmInstalledOptions { + version?: string; + skipIfInstalled?: boolean; +} + +/** Arguments for {@link UpmCli.Pack} (mapped to `UnityPackageManager pack …`). */ +export interface UpmPackOptions { + /** Unity Cloud organization id. */ + organizationId: string; + /** Output path for the packed artifact. */ + destination?: string; + /** Folder containing `package.json` to pack; when omitted the child process cwd applies. */ + packageDirectory?: string; +} + +/** + * Managed Unity Package Manager CLI (unity-cli–installed `UnityPackageManager`): + * parameterless constructor resolves roots and executable preference, {@link Install} manages downloads, {@link Exec} runs the binary. + */ +export class UpmCli { + /** Root directory for managed installs (~/.unity-cli/upm). */ + public readonly managedRoot: string; + + private readonly logger: Logger = Logger.instance; + + constructor() { + this.managedRoot = path.join(os.homedir(), '.unity-cli', 'upm'); + } + + private static getCdnBaseUrl(): string { + const override = process.env.UPM_CDN_BASE_URL?.trim(); + if (override && override.length > 0) { + return `${override.replace(/\/$/, '')}/upm-cli`; + } + return 'https://cdn.packages.unity.com/upm-cli'; + } + + /** + * HTTPS URL under the UPM CLI CDN for a release file. Caller must validate `tag` (e.g. {@link UpmCli.validateVersionFormat}); + * path segments are encoded to avoid tainted file-derived strings reaching the network unchecked (CodeQL js/file-access-to-http). + */ + private static buildUpmReleaseAssetUrl(cdnBase: string, tag: string, fileName: string): string { + const root = new URL(`${cdnBase.replace(/\/$/, '')}/`); + return new URL(`releases/${encodeURIComponent(tag)}/${encodeURIComponent(fileName)}`, root).href; + } + + private static buildUpmLatestTxtUrl(cdnBase: string): string { + return new URL('latest.txt', new URL(`${cdnBase.replace(/\/$/, '')}/`)).href; + } + + private static normalizeSemver(version: string): string | undefined { + const normalized = valid(version); + if (normalized) { + return normalized; + } + const coerced = coerce(version); + return coerced?.version; + } + + private static parseVerifiedSemVerFromLine(line: string): SemVer | null { + const t = line.trim(); + if (!t) { + return null; + } + const direct = valid(t); + if (direct) { + const parsed = parse(direct, false); + if (parsed && valid(parsed.version)) { + return parsed; + } + } + const coerced = coerce(t); + if (coerced && valid(coerced)) { + return coerced; + } + return null; + } + + private static parseCliVersionStdout(output: string): SemVer { + const trimmed = output.trim(); + if (!trimmed) { + throw new Error('Upm cli --version produced empty output.'); + } + const lines = trimmed.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0); + for (let i = lines.length - 1; i >= 0; i--) { + const version = UpmCli.parseVerifiedSemVerFromLine(lines[i]!); + if (version) { + return version; + } + } + const fallback = UpmCli.parseVerifiedSemVerFromLine(trimmed); + if (fallback) { + return fallback; + } + throw new Error(`Failed to parse upm cli version: ${JSON.stringify(trimmed)}`); + } + + private getVersionInstallDir(version: string): string { + const t = version.trim(); + this.validateVersionFormat(t); + if (t.includes('..') || path.normalize(t) !== t) { + throw new Error(`Invalid upm cli release tag for path use: ${version}`); + } + const dir = path.join(this.managedRoot, t); + const resolvedDir = path.resolve(dir); + const resolvedRoot = path.resolve(this.managedRoot); + const rootPrefix = resolvedRoot.endsWith(path.sep) ? resolvedRoot : `${resolvedRoot}${path.sep}`; + if (resolvedDir !== resolvedRoot && !resolvedDir.startsWith(rootPrefix)) { + throw new Error('Resolved UPM install directory left managed root.'); + } + return dir; + } + + private getCurrentVersionFilePath(): string { + return path.join(this.managedRoot, 'current-version.txt'); + } + + private getPlatformId(): string { + const plat = process.platform; + const arch = process.arch; + + if (plat === 'win32') { + if (arch === 'arm64') { + return 'windows-arm64'; + } + return 'windows-x64'; + } + + if (plat === 'darwin') { + if (arch === 'arm64') { + return 'macos-arm64'; + } + return 'macos-x64'; + } + + if (plat === 'linux') { + if (arch === 'arm64') { + return 'linux-arm64'; + } + return 'linux-x64'; + } + + throw new Error(`Unsupported platform for upm cli: ${plat} ${arch}`); + } + + private validateVersionFormat(version: string): void { + const t = version.trim(); + if (!t.startsWith('v') || !valid(t)) { + throw new Error(`Invalid upm cli version format: ${version}. Expected a semver release tag with leading v (e.g. v9.27.0).`); + } + } + + private findPrimaryExecutable(installDir: string): string { + if (process.platform === 'win32') { + const exe = path.join(installDir, 'UnityPackageManager.exe'); + if (fs.existsSync(exe)) { + return exe; + } + } else { + const bin = path.join(installDir, 'UnityPackageManager'); + if (fs.existsSync(bin)) { + return bin; + } + } + throw new Error(`Could not find UnityPackageManager binary under ${installDir}`); + } + + /** Optional executable override (mirrors `UNITY_CLI_PATH` for the official Unity CLI). */ + private getExecutablePathOverride(): string | undefined { + const p = process.env.UPM_CLI_PATH?.trim(); + return p && p.length > 0 ? path.normalize(p) : undefined; + } + + private executableOverrideIsUsable(): boolean { + const p = this.getExecutablePathOverride(); + if (!p) { + return false; + } + try { + fs.accessSync(p, fs.constants.R_OK | fs.constants.X_OK); + return true; + } catch { + return false; + } + } + + /** + * Release tag of the managed install from `current-version.txt`, if present and valid. + */ + public GetInstalledReleaseTag(): string | undefined { + const currentFile = this.getCurrentVersionFilePath(); + if (!fs.existsSync(currentFile)) { + return undefined; + } + try { + const version = fs.readFileSync(currentFile, 'utf8').trim(); + if (!version) { + return undefined; + } + this.validateVersionFormat(version); + return version; + } catch { + return undefined; + } + } + + /** + * Path to the primary `UnityPackageManager` binary for a managed release, or `undefined` if missing. + */ + public ResolveManagedPrimaryPath(version?: string): string | undefined { + let v = version?.trim() || this.GetInstalledReleaseTag(); + + if (!v) { + return undefined; + } + + const installDir = this.getVersionInstallDir(v); + try { + return this.findPrimaryExecutable(installDir); + } catch { + return undefined; + } + } + + /** + * Resolved path used to spawn the UPM CLI: `UPM_CLI_PATH` override when set, otherwise the managed primary binary. + * @throws If nothing usable is installed. + */ + public GetExecutablePath(): string { + const overridePath = this.getExecutablePathOverride(); + if (overridePath) { + fs.accessSync(overridePath, fs.constants.R_OK | fs.constants.X_OK); + return overridePath; + } + const managed = this.ResolveManagedPrimaryPath(); + if (!managed) { + throw new Error('Upm cli is not installed. Run `unity-cli upm-install` first.'); + } + fs.accessSync(managed, fs.constants.R_OK | fs.constants.X_OK); + return managed; + } + + /** Path used to spawn the UPM CLI (may reflect `UPM_CLI_PATH` or the managed install). */ + public get executable(): string { + return this.GetExecutablePath(); + } + + public async GetLatestReleaseTag(): Promise { + const cdn = UpmCli.getCdnBaseUrl(); + const latestUrl = UpmCli.buildUpmLatestTxtUrl(cdn); + const version = (await HttpsGetText(latestUrl)).trim(); + this.validateVersionFormat(version); + return version; + } + + /** True if `latestTag` is newer than the installed managed release, or nothing is installed yet. */ + public IsUpdateAvailable(latestTag: string): boolean { + const current = this.GetInstalledReleaseTag(); + if (!current) { + return true; + } + const normalizedCurrent = UpmCli.normalizeSemver(current); + const normalizedLatest = UpmCli.normalizeSemver(latestTag); + if (normalizedCurrent && normalizedLatest) { + return compare(normalizedLatest, normalizedCurrent) > 0; + } + return latestTag.trim() !== current.trim(); + } + + /** + * Installs or updates the managed UPM CLI. + * @returns Installed release tag (e.g. v9.27.0). + */ + public async Install(options?: EnsureUpmInstalledOptions): Promise { + const cdn = UpmCli.getCdnBaseUrl(); + let version = options?.version?.trim(); + + if (!version || version.length === 0) { + version = await this.GetLatestReleaseTag(); + } + + version = version.trim(); + + const installDir = this.getVersionInstallDir(version); + const markerPath = path.join(installDir, '.unity-cli-upm-installed'); + + if (options?.skipIfInstalled !== false && fs.existsSync(markerPath)) { + try { + this.findPrimaryExecutable(installDir); + const recordedTag = path.basename(installDir); + await fs.promises.writeFile(this.getCurrentVersionFilePath(), `${recordedTag}\n`, 'utf8'); + return version; + } catch { + // reinstall + } + } + + const platform = this.getPlatformId(); + const zipName = `upm-${platform}.zip`; + const zipUrl = UpmCli.buildUpmReleaseAssetUrl(cdn, version, zipName); + const checksumUrl = UpmCli.buildUpmReleaseAssetUrl(cdn, version, `${zipName}.sha256`); + + const tempRoot = path.join(GetTempDir(), `unity-cli-upm-${Date.now()}`); + const resolvedTempRoot = path.resolve(tempRoot); + const zipPath = path.join(resolvedTempRoot, zipName); + const checksumPath = path.join(resolvedTempRoot, `${zipName}.sha256`); + + try { + this.logger.info(`Installing upm cli ${version} (${platform})...`); + await DownloadFile(zipUrl, zipPath); + await DownloadFile(checksumUrl, checksumPath); + + const checksumContent = (await fs.promises.readFile(checksumPath, 'utf8')).trim(); + const expectedHash = checksumContent.split(/\s+/)[0]?.toLowerCase(); + if (!expectedHash) { + throw new Error(`Could not read SHA-256 from ${checksumPath}`); + } + + const actualHash = (await Sha256FileHex(zipPath)).toLowerCase(); + if (actualHash !== expectedHash) { + throw new Error(`SHA-256 mismatch for upm cli zip. Expected ${expectedHash}, got ${actualHash}`); + } + + await DeleteDirectory(installDir); + await fs.promises.mkdir(installDir, { recursive: true }); + await extractZipNative(zipPath, installDir, { + zipUnder: resolvedTempRoot, + destUnder: path.resolve(this.managedRoot), + }, { + silent: false, + showCommand: this.logger.logLevel === LogLevel.DEBUG + }); + + const primary = this.findPrimaryExecutable(installDir); + if (process.platform !== 'win32') { + try { + fs.chmodSync(primary, 0o755); + } catch { + // ignore + } + } + + const wrapperUnix = path.join(installDir, 'upm'); + if (process.platform !== 'win32' && fs.existsSync(wrapperUnix)) { + try { + fs.chmodSync(wrapperUnix, 0o755); + } catch { + // ignore + } + } + + await fs.promises.writeFile(markerPath, `${new Date().toISOString()}\n`, 'utf8'); + const recordedTag = path.basename(installDir); + await fs.promises.writeFile(this.getCurrentVersionFilePath(), `${recordedTag}\n`, 'utf8'); + + return version; + } finally { + await DeleteDirectory(tempRoot); + } + } + + /** + * When running in an interactive terminal, may prompt to install a missing UPM CLI or update to the latest CDN release. + * When not interactive, logs a warning if the running binary is older than the CDN latest (no install). + * Compares the running binary ({@link Version}) to {@link GetLatestReleaseTag} (including when {@code UPM_CLI_PATH} overrides the managed install). + * Call before {@link GetExecutablePath} / {@link Exec} for an optional install/update (e.g. pack). + */ + public async PromptInstallOrUpdateWhenInteractive(): Promise { + const overrideUsable = this.executableOverrideIsUsable(); + const managedExe = this.ResolveManagedPrimaryPath(); + const hasExecutable = overrideUsable || managedExe !== undefined; + + if (!hasExecutable) { + if (isInteractiveTerminalSession()) { + const install = await PromptYesNo( + 'The upm cli is not installed. Download and install it now?', + true + ); + if (install) { + await this.Install({ skipIfInstalled: false }); + } + } + return; + } + + try { + const latestTag = await this.GetLatestReleaseTag(); + const latestSem = UpmCli.parseVerifiedSemVerFromLine(latestTag); + if (!latestSem) { + return; + } + + const installedSem = await this.Version(); + if (compare(latestSem, installedSem) <= 0) { + return; + } + + const usingOverride = overrideUsable; + + if (!isInteractiveTerminalSession()) { + if (usingOverride) { + this.logger.warn( + `The upm cli (UPM_CLI_PATH) reports ${installedSem.version}, but ${latestTag} is available on the CDN. This run still uses UPM_CLI_PATH; update that binary or unset it and run unity-cli upm-install to use the managed release.`, + ); + } else { + this.logger.warn( + `The upm cli (${installedSem.version}) is older than the latest release (${latestTag}). Run unity-cli upm-install or unity-cli upm-install --auto-update to update.`, + ); + } + return; + } + + const prompt = usingOverride + ? `Your upm cli (UPM_CLI_PATH) reports ${installedSem.version}, but ${latestTag} is available. Install the latest to the managed location now? This run will keep using UPM_CLI_PATH until you unset it or point it at the new binary.` + : `A newer upm cli version is available (${installedSem.version} -> ${latestTag}). Install it now?`; + + const shouldUpdate = await PromptYesNo(prompt, !usingOverride); + if (!shouldUpdate) { + return; + } + + await this.Install({ + version: latestTag, + skipIfInstalled: false, + }); + + if (usingOverride) { + this.logger.warn( + `Installed upm cli ${latestTag} under ${this.managedRoot}. Unset UPM_CLI_PATH (or update it) so subsequent commands use the new install.`, + ); + } + } catch (error) { + this.logger.debug(`Failed to check for upm cli updates: ${error}`); + } + } + + /** + * Executes the UPM CLI with the given arguments. + */ + public async Exec(args: string[], options: ExecOptions = { silent: this.logger.logLevel > LogLevel.CI, showCommand: this.logger.logLevel <= LogLevel.CI }): Promise { + const exe = this.GetExecutablePath(); + if (exe.includes(path.sep)) { + fs.accessSync(exe, fs.constants.R_OK | fs.constants.X_OK); + } + return Exec(exe, args, options); + } + + /** + * Runs `--version` and returns the verified semver from the binary. + * @param expectedReleaseTag When set (e.g. from {@link Install}), ensures the reported semver matches this CDN release tag. + */ + public async Version(expectedReleaseTag?: string): Promise { + const raw = await this.Exec(['--version'], { + silent: true, + showCommand: this.logger.logLevel === LogLevel.DEBUG, + }); + const version = UpmCli.parseCliVersionStdout(raw); + if (expectedReleaseTag !== undefined && expectedReleaseTag.trim().length > 0) { + const tag = expectedReleaseTag.trim(); + const expected = UpmCli.parseVerifiedSemVerFromLine(tag); + if (!expected) { + throw new Error(`Invalid installed upm cli release tag: ${expectedReleaseTag}`); + } + if (compare(version, expected) !== 0) { + throw new Error( + `Upm cli binary version mismatch: binary reported ${version.version} (--version), expected ${expected.version} (${expectedReleaseTag}).` + ); + } + } + return version; + } + + /** + * Runs the UPM CLI `pack` subcommand (builds argv from {@link UpmPackOptions}, then {@link Exec}). + */ + public async Pack(options: UpmPackOptions, execOptions?: ExecOptions): Promise { + const orgId = options.organizationId.trim(); + + if (!orgId) { + throw new Error('UpmCli.Pack requires a non-empty organizationId.'); + } + + const args: string[] = []; + + if (this.logger.logLevel === LogLevel.DEBUG) { + args.push('--log-level', '5', '--console-log-level', '5'); + } + + args.push('pack', '--organization-id', orgId); + const dest = options.destination?.trim(); + + if (dest && dest.length > 0) { + args.push('--destination', dest); + } + + const dir = options.packageDirectory?.trim(); + + if (dir && dir.length > 0) { + args.push(dir); + } + + return this.Exec(args, execOptions); + } +} diff --git a/tests/upm-cli.test.ts b/tests/upm-cli.test.ts new file mode 100644 index 00000000..95d831d5 --- /dev/null +++ b/tests/upm-cli.test.ts @@ -0,0 +1,34 @@ +import * as path from 'path'; +import * as os from 'os'; +import { UpmCli } from '../src/upm-cli'; + +describe('UpmCli', () => { + it('managedRoot is under home', () => { + const upm = new UpmCli(); + expect(upm.managedRoot).toBe(path.join(os.homedir(), '.unity-cli', 'upm')); + }); + + it('IsUpdateAvailable is true when nothing is installed', () => { + const spy = jest.spyOn(UpmCli.prototype, 'GetInstalledReleaseTag'); + spy.mockReturnValue(undefined); + try { + const upm = new UpmCli(); + expect(upm.IsUpdateAvailable('v9.27.0')).toBe(true); + } finally { + spy.mockRestore(); + } + }); + + it('IsUpdateAvailable compares release tags when a release is recorded', () => { + const spy = jest.spyOn(UpmCli.prototype, 'GetInstalledReleaseTag'); + spy.mockReturnValue('v9.28.0'); + try { + const upm = new UpmCli(); + expect(upm.IsUpdateAvailable('v9.29.0')).toBe(true); + expect(upm.IsUpdateAvailable('v9.28.0')).toBe(false); + expect(upm.IsUpdateAvailable('v9.27.0')).toBe(false); + } finally { + spy.mockRestore(); + } + }); +}); From d7c14d5db6638cb5806cbdeadfc7885bac0cda7d Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Mon, 26 Oct 2026 05:45:01 -0400 Subject: [PATCH 39/60] bound the linux apt dependency install with timeouts and stream its output --- src/unity-installer.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/unity-installer.ts b/src/unity-installer.ts index 42f1b395..b6ec6f3e 100644 --- a/src/unity-installer.ts +++ b/src/unity-installer.ts @@ -33,8 +33,11 @@ if [ ! -f /etc/apt/sources.list.d/jammy.list ]; then fi # jammy only supplies packages the host release lacks; it never replaces the host's own packages printf 'Package: *\\nPin: release n=jammy\\nPin-Priority: 100\\n' > /etc/apt/preferences.d/unity-cli-jammy -apt-get update -apt-get install -y --no-install-recommends xvfb ffmpeg libgtk2.0-0 libglu1-mesa libgconf-2-4 libncurses5 pulseaudio +export DEBIAN_FRONTEND=noninteractive +# a stalled mirror must not hold the job: apt retries slow requests, and each step has a hard limit +APT_OPTS="-o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 -o Acquire::Retries=3 -o DPkg::Lock::Timeout=120" +timeout 300 apt-get $APT_OPTS update +timeout 600 apt-get $APT_OPTS install -y --no-install-recommends xvfb ffmpeg libgtk2.0-0 libglu1-mesa libgconf-2-4 libncurses5 pulseaudio apt-get clean `; @@ -680,7 +683,8 @@ export class UnityInstaller { private async installLinuxDependencies(unityVersion: UnityVersion): Promise { if (process.env.UNITY_CLI_LINUX_DEPENDENCIES_INSTALLED !== 'true') { try { - await Exec('sudo', ['sh', '-c', LINUX_EDITOR_DEPENDENCIES_SCRIPT], { silent: true, showCommand: true }); + // streamed, so a slow mirror shows where it stalls + await Exec('sudo', ['sh', '-c', LINUX_EDITOR_DEPENDENCIES_SCRIPT], { silent: false, showCommand: true }); process.env.UNITY_CLI_LINUX_DEPENDENCIES_INSTALLED = 'true'; this.logger.CI_setEnvironmentVariable('UNITY_CLI_LINUX_DEPENDENCIES_INSTALLED', 'true'); } catch (error) { From c1d67b2a9f36c437664574be86c522a16c692bdd Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Mon, 26 Oct 2026 06:22:35 -0400 Subject: [PATCH 40/60] install modules from the project's build profiles, and keep the release drift report in the run summary --- .github/scripts/unity-release-discovery.cjs | 111 ++++-------------- .github/workflows/unity-release-discovery.yml | 4 - README.md | 2 + docs/unity-version-support.md | 2 +- src/cli.ts | 9 ++ src/unity-project.ts | 66 +++++++++++ tests/unity-project-build-profiles.test.ts | 53 +++++++++ 7 files changed, 156 insertions(+), 91 deletions(-) create mode 100644 tests/unity-project-build-profiles.test.ts diff --git a/.github/scripts/unity-release-discovery.cjs b/.github/scripts/unity-release-discovery.cjs index c6c0fb1f..f92f7d68 100644 --- a/.github/scripts/unity-release-discovery.cjs +++ b/.github/scripts/unity-release-discovery.cjs @@ -1,17 +1,14 @@ /** * Weekly Unity release discovery: compare Releases API tips to the CI matrix. * Usage: node .github/scripts/unity-release-discovery.cjs - * Env: GITHUB_STEP_SUMMARY (optional), OPEN_ISSUE=1 + GH_TOKEN/GITHUB_TOKEN to open/update drift issue. + * Env: GITHUB_STEP_SUMMARY (optional): the report is appended to the run summary, and drift is a warning annotation. */ const fs = require('fs'); const path = require('path'); -const { execFileSync } = require('child_process'); const repoRoot = path.join(__dirname, '..', '..'); const apiBase = 'https://services.api.unity.com/unity/editor/release/v1/releases'; const minors = ['6000.0', '6000.1', '6000.2', '6000.3', '6000.4', '6000.5', '6000.6', '6000.7', '6000.8', '7000.0', '7000.1']; -const ISSUE_TITLE = 'unity-release-drift: matrix vs Unity Releases API'; -const ISSUE_LABEL = 'unity-release-drift'; async function fetchLatest(versionPrefix) { const url = `${apiBase}?version=${encodeURIComponent(versionPrefix)}&limit=10`; @@ -21,12 +18,24 @@ async function fetchLatest(versionPrefix) { } const data = await res.json(); const results = Array.isArray(data.results) ? data.results : []; - return results.map(r => ({ - version: r.version, - stream: r.stream, - shortRevision: r.shortRevision, - releaseDate: r.releaseDate, - })); + // the report is markdown in the run summary, so only fields rebuilt from validated parts reach it + return results.map(normalizeRelease).filter(Boolean); +} + +const STREAMS = { LTS: 'LTS', SUPPORTED: 'SUPPORTED', TECH: 'TECH', BETA: 'BETA', ALPHA: 'ALPHA' }; +const CHANNELS = { a: 'a', b: 'b', c: 'c', f: 'f', p: 'p', x: 'x' }; + +function normalizeRelease(r) { + const version = /^(\d{1,4})\.(\d{1,3})\.(\d{1,3})([abcfpx])(\d{1,3})$/.exec(String(r?.version ?? '')); + const revision = /^[0-9a-f]{12}$/.exec(String(r?.shortRevision ?? '')); + const stream = STREAMS[String(r?.stream ?? '').toUpperCase()] ?? 'OTHER'; + if (!version) return null; + const [, major, minor, patch, channel, build] = version; + return { + version: `${Number(major)}.${Number(minor)}.${Number(patch)}${CHANNELS[channel]}${Number(build)}`, + stream, + shortRevision: revision ? revision[0] : '', + }; } function loadMatrixVersions() { @@ -74,7 +83,7 @@ async function main() { lines.push('|-------|-------------|--------|-----------|'); for (const row of tips) { if (!row.tip) { - lines.push(`| ${row.minor} | _(error)_ | | ${row.error || ''} |`); + lines.push(`| ${row.minor} | _(error)_ | | |`); continue; } lines.push( @@ -119,83 +128,13 @@ async function main() { const body = lines.join('\n'); console.log(body); - // Step summary: local matrix + drift count only (no HTTP-tainted API tip fields). - if (process.env.GITHUB_STEP_SUMMARY) { - const summaryPath = path.resolve(process.env.GITHUB_STEP_SUMMARY); - const runnerTemp = process.env.RUNNER_TEMP ? path.resolve(process.env.RUNNER_TEMP) : ''; - const underTemp = - runnerTemp && - (summaryPath === runnerTemp || summaryPath.startsWith(runnerTemp + path.sep)); - if (underTemp || process.env.GITHUB_ACTIONS === 'true') { - const localSummary = [ - '## Unity release discovery', - '', - '### Blocking matrix (`build-options.json`)', - '', - ...matrixVersions.map(v => `- \`${v}\``), - '', - '### Drift', - '', - drifts.length === 0 - ? 'No matrix drift detected against API tips.' - : `${drifts.length} drift item(s) detected. See the job log for API tip details.`, - '', - ].join('\n'); - fs.appendFileSync(summaryPath, localSummary); - } + // drift shows as a warning on the run; the report itself only goes to the run summary + for (const d of drifts) { + console.log(`::warning title=Unity release drift::${d}`); } - if (process.env.OPEN_ISSUE === '1' && drifts.length > 0) { - const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN; - if (!token) { - console.error('OPEN_ISSUE=1 but no GH_TOKEN/GITHUB_TOKEN'); - process.exitCode = 1; - return; - } - try { - const existing = execFileSync( - 'gh', - ['issue', 'list', '--label', ISSUE_LABEL, '--state', 'open', '--json', 'number,title', '--limit', '5'], - { encoding: 'utf8' } - ); - const issues = JSON.parse(existing || '[]'); - const hit = issues.find(i => i.title === ISSUE_TITLE); - const issueBody = [ - 'Automated drift report from `unity-release-discovery`.', - '', - ...drifts.map(d => `- ${d}`), - '', - body, - ].join('\n'); - try { - execFileSync('gh', ['label', 'create', ISSUE_LABEL, '--force'], { stdio: 'ignore' }); - } catch { - // label may already exist or lack permission; issue create can omit it - } - if (hit) { - execFileSync('gh', ['issue', 'comment', String(hit.number), '--body', issueBody], { - stdio: 'inherit', - }); - console.log(`Updated issue #${hit.number}`); - } else { - try { - execFileSync( - 'gh', - ['issue', 'create', '--title', ISSUE_TITLE, '--label', ISSUE_LABEL, '--body', issueBody], - { stdio: 'inherit' } - ); - } catch { - execFileSync( - 'gh', - ['issue', 'create', '--title', ISSUE_TITLE, '--body', issueBody], - { stdio: 'inherit' } - ); - } - } - } catch (err) { - console.error(`Issue upsert failed: ${err.message || err}`); - process.exitCode = 1; - } + if (process.env.GITHUB_STEP_SUMMARY) { + fs.appendFileSync(path.resolve(process.env.GITHUB_STEP_SUMMARY), `${body}\n`); } } diff --git a/.github/workflows/unity-release-discovery.yml b/.github/workflows/unity-release-discovery.yml index 0cfe63a3..630689af 100644 --- a/.github/workflows/unity-release-discovery.yml +++ b/.github/workflows/unity-release-discovery.yml @@ -5,7 +5,6 @@ on: workflow_dispatch: permissions: contents: read - issues: write jobs: discover: runs-on: ubuntu-latest @@ -16,7 +15,4 @@ jobs: with: node-version: '24' - name: Discover Unity releases vs matrix - env: - OPEN_ISSUE: '1' - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: node .github/scripts/unity-release-discovery.cjs diff --git a/README.md b/README.md index 4d47c490..a3b85985 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,8 @@ unity-cli licensing-audit-logs - `--verbose` Enable verbose logging. - `--json` Prints the last line of output as JSON string. +With only `--unity-project`, it installs the modules for every build profile saved under the project's `Assets` folder (Unity 6 and newer). `--modules` or `--build-targets` replace that list. + It exports `UNITY_CLI_PATH`, `UNITY_EDITOR_PATH`, `UNITY_EDITOR_VERSION`, `UNITY_EDITOR_CHANGESET`, `UNITY_EDITOR_CHANNELS` and, with a project, `UNITY_PROJECT_PATH`. With a project and the `android` module, it also installs the project's Android target SDK. Installs the latest Unity 6 version with Android and iOS modules: diff --git a/docs/unity-version-support.md b/docs/unity-version-support.md index 4f6db23a..97445472 100644 --- a/docs/unity-version-support.md +++ b/docs/unity-version-support.md @@ -12,7 +12,7 @@ This repo tests Unity editors on CI in two tiers: - Partial versions (`6000.5`, `2021.3.x`) resolve only within the requested channel(s). Default is **stable (`f`)**. - If no matching stable release exists (e.g. `6000.7` while only betas ship), `setup-unity` **fails closed** instead of letting the installer guess a beta. - Use `--channel b` (or `a`) when you intentionally want pre-release streams. -- Weekly [unity-release-discovery](../.github/workflows/unity-release-discovery.yml) compares the Releases API to the matrix and opens/updates a `unity-release-drift` issue when a stream is missing from it. +- Weekly [unity-release-discovery](../.github/workflows/unity-release-discovery.yml) compares the Releases API to the matrix, writes the report to the run summary, and warns on the run when a stream is missing from it. ## Dependencies diff --git a/src/cli.ts b/src/cli.ts index e6a8e90d..6077798f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -220,6 +220,15 @@ program.command('setup-unity') const modules: string[] = options.modules ? options.modules.split(/[ ,]+/).filter(Boolean) : []; const buildTargets: string[] = options.buildTargets ? options.buildTargets.split(/[ ,]+/).filter(Boolean) : []; + + // with only a project, its saved build profiles pick the modules; --modules or --build-targets override them + if (unityProject && modules.length === 0 && buildTargets.length === 0) { + buildTargets.push(...unityProject.GetBuildProfileTargets()); + + if (buildTargets.length > 0) { + Logger.instance.info(`Installing modules for the project's build profiles: ${buildTargets.join(', ')}`); + } + } const moduleBuildTargetMap = UnityInstaller.GetPlatformTargetModuleMap(); for (const target of buildTargets) { diff --git a/src/unity-project.ts b/src/unity-project.ts index 8d304513..0cb7a46e 100644 --- a/src/unity-project.ts +++ b/src/unity-project.ts @@ -76,6 +76,72 @@ export class UnityProject { this.version = new UnityVersion(match.groups!.version!, match.groups?.changeset, undefined); } + /** + * The build targets of the build profiles saved under the project's Assets folder (Unity 6 and newer). + * @returns Build target names such as Android or StandaloneWindows64, without duplicates. + */ + public GetBuildProfileTargets(): string[] { + const targets = new Set(); + const pending = [path.join(this.projectPath, 'Assets')]; + + while (pending.length > 0) { + const dir = pending.pop()!; + let entries: fs.Dirent[]; + + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + const entryPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + pending.push(entryPath); + continue; + } + + if (!entry.isFile() || !entry.name.endsWith('.asset') || fs.statSync(entryPath).size > 1024 * 1024) { + continue; + } + + const target = UnityProject.readBuildProfileTarget(fs.readFileSync(entryPath, 'utf-8')); + + if (target) { + targets.add(target); + } + } + } + + return [...targets]; + } + + /** BuildTarget enum values a build profile stores, by name. */ + private static readonly BuildTargetNames: { [value: number]: string } = { + 2: 'StandaloneOSX', + 5: 'StandaloneWindows', + 9: 'iOS', + 13: 'Android', + 19: 'StandaloneWindows64', + 20: 'WebGL', + 21: 'WSAPlayer', + 24: 'StandaloneLinux64', + 37: 'tvOS', + 47: 'VisionOS', + }; + + /** The build target of a BuildProfile asset, or undefined for any other asset. */ + private static readBuildProfileTarget(asset: string): string | undefined { + // BuildProfile is a built-in MonoBehaviour: script fileID 15003 in the editor's built-in guid + if (!asset.includes('fileID: 15003, guid: 0000000000000000e000000000000000') || !asset.includes('m_PlatformBuildProfile:')) { + return undefined; + } + + const match = /^ {2}m_BuildTarget: (\d+)\r?$/m.exec(asset); + return match ? UnityProject.BuildTargetNames[Number(match[1])] ?? `BuildTarget(${match[1]})` : undefined; + } + /** * Gets the Unity project located at the specified path, or the current working directory if no path is provided. * @param projectPath The path to the Unity project. If undefined, the current working directory is used. diff --git a/tests/unity-project-build-profiles.test.ts b/tests/unity-project-build-profiles.test.ts new file mode 100644 index 00000000..f710c381 --- /dev/null +++ b/tests/unity-project-build-profiles.test.ts @@ -0,0 +1,53 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { UnityProject } from '../src/unity-project'; + +const buildProfile = (name: string, target: number) => `%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_Script: {fileID: 15003, guid: 0000000000000000e000000000000000, type: 0} + m_Name: ${name} + m_AssetVersion: 1 + m_BuildTarget: ${target} + m_Subtarget: 0 + m_PlatformId: 84a3bb9e7420477f885e98145999eb20 + m_PlatformBuildProfile: + rid: 1 + m_OverrideGlobalSceneList: 0 + m_PlayerSettingsYaml: + m_Settings: + - line: '| m_BuildTargetIcons: []' + - line: '| - m_BuildTarget: Android' +`; + +describe('UnityProject.GetBuildProfileTargets', () => { + let projectPath: string; + + beforeEach(() => { + projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'unity-project-')); + fs.mkdirSync(path.join(projectPath, 'ProjectSettings')); + fs.writeFileSync(path.join(projectPath, 'ProjectSettings', 'ProjectVersion.txt'), 'm_EditorVersion: 6000.3.27f1\n'); + fs.mkdirSync(path.join(projectPath, 'Assets', 'Settings', 'Build Profiles'), { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(projectPath, { recursive: true, force: true }); + }); + + it('returns each saved build profile target once', () => { + const profiles = path.join(projectPath, 'Assets', 'Settings', 'Build Profiles'); + fs.writeFileSync(path.join(profiles, 'Web.asset'), buildProfile('Web', 20)); + fs.writeFileSync(path.join(profiles, 'Android Dev.asset'), buildProfile('Android Dev', 13)); + fs.writeFileSync(path.join(profiles, 'Android Release.asset'), buildProfile('Android Release', 13).replace(/\n/g, '\r\n')); + fs.writeFileSync(path.join(projectPath, 'Assets', 'Other.asset'), 'MonoBehaviour:\n m_BuildTarget: 9\n'); + + expect(new UnityProject(projectPath).GetBuildProfileTargets().sort()).toEqual(['Android', 'WebGL']); + }); + + it('returns nothing without build profiles', () => { + expect(new UnityProject(projectPath).GetBuildProfileTargets()).toEqual([]); + }); +}); From 91e64bb775d66bb4188da25b25839083cfb4ad3d Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Mon, 26 Oct 2026 06:25:30 -0400 Subject: [PATCH 41/60] install the linux editor's required packages before the optional ffmpeg and pulseaudio --- src/unity-installer.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/unity-installer.ts b/src/unity-installer.ts index b6ec6f3e..104b84af 100644 --- a/src/unity-installer.ts +++ b/src/unity-installer.ts @@ -37,7 +37,9 @@ export DEBIAN_FRONTEND=noninteractive # a stalled mirror must not hold the job: apt retries slow requests, and each step has a hard limit APT_OPTS="-o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 -o Acquire::Retries=3 -o DPkg::Lock::Timeout=120" timeout 300 apt-get $APT_OPTS update -timeout 600 apt-get $APT_OPTS install -y --no-install-recommends xvfb ffmpeg libgtk2.0-0 libglu1-mesa libgconf-2-4 libncurses5 pulseaudio +# what the editor cannot start without comes first; ffmpeg and pulseaudio pull in about 70 MB more and are only for video and audio +timeout 600 apt-get $APT_OPTS install -y --no-install-recommends xvfb libgtk2.0-0 libglu1-mesa libgconf-2-4 libncurses5 +timeout 900 apt-get $APT_OPTS install -y --no-install-recommends ffmpeg pulseaudio || echo "Skipped ffmpeg and pulseaudio: the install did not finish" apt-get clean `; From 0dba5bfcf1259da9451d87c436f5cc70bf0e1d77 Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Mon, 26 Oct 2026 06:33:26 -0400 Subject: [PATCH 42/60] match create-project templates without compiling a regex, and read build profiles through one file handle --- README.md | 2 +- src/cli.ts | 37 +++++++++++++++++++++---------------- src/unity-project.ts | 25 +++++++++++++++++++++++-- 3 files changed, 45 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index a3b85985..fbe92a42 100644 --- a/README.md +++ b/README.md @@ -254,7 +254,7 @@ unity-cli editor-logs ### Deprecated Commands -`uninstall-unity`, `list-project-templates`, `create-project` and `open-project` keep their v3 options, print a deprecation warning that names the replacement, and forward to the official Unity CLI (see [Upgrading from v3](#upgrading-from-v3)). `create-project` still accepts a template regex and picks the first matching template id. Run `unity-cli --help` for the options. +`uninstall-unity`, `list-project-templates`, `create-project` and `open-project` keep their v3 options, print a deprecation warning that names the replacement, and forward to the official Unity CLI (see [Upgrading from v3](#upgrading-from-v3)). `create-project --template` takes a template id or id prefix; v3 regex patterns are no longer compiled, and only their leading literal part is matched. Run `unity-cli --help` for the options. ### Unity Package Manager diff --git a/src/cli.ts b/src/cli.ts index 6077798f..fce661d6 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -480,7 +480,7 @@ program.command('create-project') .description('[Deprecated] Create a new Unity project. Forwards to: unity projects create --path --editor-version --template --no-cloud') .option('-n, --name ', 'The name of the new Unity project. If unspecified, the project will be created in the specified path or the current working directory.') .option('-p, --path ', 'The path to create the new Unity project. If unspecified, the current working directory will be used.') - .option('-t, --template ', 'The name of the template package to use for creating the unity project. Supports regex patterns.', 'com.unity.template.3d(-cross-platform)?') + .option('-t, --template ', 'The template package id to create the project from; an id prefix picks the first matching template.', 'com.unity.template.3d') .option('-u, --unity-version ', 'The Unity version to create the project with (e.g. 2022.3.1f1, 6000.2). If unspecified, then --unity-editor must be specified.') .option('-e, --unity-editor ', 'The path to the Unity Editor executable. If unspecified, -u, --unity-version, or the UNITY_EDITOR_PATH environment variable must be set.') .option('--verbose', 'Enable verbose logging.') @@ -501,22 +501,27 @@ program.command('create-project') } const template = options.template.toString().trim(); - let templateId = template; - - // v3 matched templates with a regex; the official CLI takes an exact template id - if (!/^[\w.-]+$/.test(template)) { - const cli = await UnityCli.Get(); - const { envelope } = await cli.RunJson<{ name?: string; id?: string }[]>(['templates', 'list', ...(version ? ['--editor', version] : [])]); - const pattern = new RegExp(`^(?:${template})$`); - const ids = (envelope?.data ?? []).map(t => t.name || t.id).filter((id): id is string => !!id); - const match = ids.find(id => pattern.test(id)); - - if (!match) { - Logger.instance.error(`No project template matches ${template}. Available: ${ids.join(', ')}`); - process.exit(1); - } + // v3 took a regex here; a pattern is never compiled from the argument, so its leading literal part + // (com.unity.template.3d for the v3 default) is matched as an exact id, then as an id prefix + const prefix = /^[\w.-]+/.exec(template)?.[0]; + + if (!prefix) { + Logger.instance.error(`Invalid project template: ${template}. Use a template id such as com.unity.template.3d.`); + process.exit(1); + } - templateId = match; + if (prefix !== template) { + Logger.instance.warn(`Template patterns are no longer supported; matching templates that start with ${prefix}.`); + } + + const cli = await UnityCli.Get(); + const { envelope } = await cli.RunJson<{ name?: string; id?: string }[]>(['templates', 'list', ...(version ? ['--editor', version] : [])]); + const ids = (envelope?.data ?? []).map(t => t.name || t.id).filter((id): id is string => !!id); + const templateId = ids.find(id => id === prefix) ?? ids.find(id => id.startsWith(prefix)); + + if (!templateId) { + Logger.instance.error(`No project template matches ${prefix}. Available: ${ids.join(', ')}`); + process.exit(1); } args.push('--template', templateId, '--no-cloud'); diff --git a/src/unity-project.ts b/src/unity-project.ts index 0cb7a46e..96f35e90 100644 --- a/src/unity-project.ts +++ b/src/unity-project.ts @@ -102,11 +102,11 @@ export class UnityProject { continue; } - if (!entry.isFile() || !entry.name.endsWith('.asset') || fs.statSync(entryPath).size > 1024 * 1024) { + if (!entry.isFile() || !entry.name.endsWith('.asset')) { continue; } - const target = UnityProject.readBuildProfileTarget(fs.readFileSync(entryPath, 'utf-8')); + const target = UnityProject.readBuildProfileTarget(UnityProject.readSmallFile(entryPath)); if (target) { targets.add(target); @@ -117,6 +117,27 @@ export class UnityProject { return [...targets]; } + /** Reads a file of up to 1 MB through one open handle, so the size check and the read see the same file. Larger or unreadable files read as empty. */ + private static readSmallFile(filePath: string): string { + let fd: number | undefined; + + try { + fd = fs.openSync(filePath, 'r'); + + if (fs.fstatSync(fd).size > 1024 * 1024) { + return ''; + } + + return fs.readFileSync(fd, 'utf-8'); + } catch { + return ''; + } finally { + if (fd !== undefined) { + fs.closeSync(fd); + } + } + } + /** BuildTarget enum values a build profile stores, by name. */ private static readonly BuildTargetNames: { [value: number]: string } = { 2: 'StandaloneOSX', From 7a4238fd090ce5306fb07fba78c7124171fc9175 Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Mon, 26 Oct 2026 08:04:58 -0400 Subject: [PATCH 43/60] keep pulseaudio in the required linux packages so fmod finds an audio device --- src/unity-installer.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/unity-installer.ts b/src/unity-installer.ts index 104b84af..4bccc075 100644 --- a/src/unity-installer.ts +++ b/src/unity-installer.ts @@ -37,9 +37,10 @@ export DEBIAN_FRONTEND=noninteractive # a stalled mirror must not hold the job: apt retries slow requests, and each step has a hard limit APT_OPTS="-o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 -o Acquire::Retries=3 -o DPkg::Lock::Timeout=120" timeout 300 apt-get $APT_OPTS update -# what the editor cannot start without comes first; ffmpeg and pulseaudio pull in about 70 MB more and are only for video and audio -timeout 600 apt-get $APT_OPTS install -y --no-install-recommends xvfb libgtk2.0-0 libglu1-mesa libgconf-2-4 libncurses5 -timeout 900 apt-get $APT_OPTS install -y --no-install-recommends ffmpeg pulseaudio || echo "Skipped ffmpeg and pulseaudio: the install did not finish" +# what the editor needs comes first: without pulseaudio, FMOD logs audio device errors on 2019 and 2020 editors; +# ffmpeg pulls in about 60 MB more and is only for video, so a slow mirror may skip it +timeout 600 apt-get $APT_OPTS install -y --no-install-recommends xvfb libgtk2.0-0 libglu1-mesa libgconf-2-4 libncurses5 pulseaudio +timeout 900 apt-get $APT_OPTS install -y --no-install-recommends ffmpeg || echo "Skipped ffmpeg: the install did not finish" apt-get clean `; From 20b74fcaab28acb716dd08d72e74b24401229144 Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Mon, 26 Oct 2026 09:59:33 -0400 Subject: [PATCH 44/60] give the linux apt install room for a slow mirror --- .github/workflows/unity-build.yml | 4 ++-- src/unity-installer.ts | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/unity-build.yml b/.github/workflows/unity-build.yml index b11d1af4..ff071a41 100644 --- a/.github/workflows/unity-build.yml +++ b/.github/workflows/unity-build.yml @@ -24,7 +24,7 @@ jobs: build: name: ${{ matrix.name }} runs-on: ${{ matrix.os }} - timeout-minutes: 45 # successful rows take about 10 minutes; a hung editor or licensing call must not hold the matrix + timeout-minutes: 60 # successful rows take about 10 minutes, Linux rows up to 30 on a slow apt mirror; a hung editor or licensing call must not hold the matrix strategy: matrix: ${{ fromJSON(inputs.matrix) }} fail-fast: false @@ -63,7 +63,7 @@ jobs: npm run link unity-cli --version - name: Setup Unity - timeout-minutes: 30 + timeout-minutes: 40 # Linux editor packages alone may take 25 minutes on a slow apt mirror run: | unity-cli upm-install --json UPM_VER=$(unity-cli upm-version | tr -d '\n\r') diff --git a/src/unity-installer.ts b/src/unity-installer.ts index 4bccc075..aa1441d7 100644 --- a/src/unity-installer.ts +++ b/src/unity-installer.ts @@ -39,8 +39,9 @@ APT_OPTS="-o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 -o Acquire: timeout 300 apt-get $APT_OPTS update # what the editor needs comes first: without pulseaudio, FMOD logs audio device errors on 2019 and 2020 editors; # ffmpeg pulls in about 60 MB more and is only for video, so a slow mirror may skip it -timeout 600 apt-get $APT_OPTS install -y --no-install-recommends xvfb libgtk2.0-0 libglu1-mesa libgconf-2-4 libncurses5 pulseaudio -timeout 900 apt-get $APT_OPTS install -y --no-install-recommends ffmpeg || echo "Skipped ffmpeg: the install did not finish" +# pulseaudio alone pulls in about 38 MB, which the runners' Azure mirror has taken over 10 minutes to serve +timeout 1200 apt-get $APT_OPTS install -y --no-install-recommends xvfb libgtk2.0-0 libglu1-mesa libgconf-2-4 libncurses5 pulseaudio +timeout 300 apt-get $APT_OPTS install -y --no-install-recommends ffmpeg || echo "Skipped ffmpeg: the install did not finish" apt-get clean `; From c9bd850b081e0825029112a2601e6af3bad7b731 Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:56:36 -0400 Subject: [PATCH 45/60] fall back to the account when a cached license file resolves no entitlements --- src/license-client.ts | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src/license-client.ts b/src/license-client.ts index 80679a79..06ca9596 100644 --- a/src/license-client.ts +++ b/src/license-client.ts @@ -1006,6 +1006,19 @@ export class LicensingClient { return licenses; } + /** + * Whether Unity's licensing servers resolve any entitlement for the active license. + * A license file that was returned or replaced elsewhere still activates locally, but resolves none. + */ + public async HasResolvableEntitlements(): Promise { + try { + return /Product Name: \S/.test(await this.exec(['--showEntitlements'], true)); + } catch (error) { + this.logger.debug(`Failed to read the license entitlements: ${error}`); + return false; + } + } + private async activateLicense(licenseType: LicenseType, username: string, password: string, serial: string | undefined = undefined): Promise { const args = [ `--activate-ulf`, @@ -1303,8 +1316,16 @@ async function activateFromLicenseFile(licenseFile: string): Promise { const { exitCode, envelope } = await cli.RunJson(['license', 'activate', '--file', tempFile], { timeoutMs: UNITY_CLI_LICENSE_TIMEOUT_MS }); if (exitCode === 0 && envelope?.success !== false && await isLicenseActive(cli)) { - logger.info('Successfully activated from the cached license file with the Unity CLI'); - return true; + // the file activates offline even after its seat was returned or replaced, so ask the licensing servers too + if (await new LicensingClient().HasResolvableEntitlements()) { + logger.info('Successfully activated from the cached license file with the Unity CLI'); + return true; + } + + logger.warn('The cached license file activated, but Unity resolves no entitlements for it; it was likely returned or replaced elsewhere. Activating with the account instead.'); + // remove the dead license, so the account activation writes a fresh one instead of seeing a license already in place + await fs.promises.rm(GetLicenseFilePath(), { force: true }).catch(error => logger.debug(`Failed to remove the dead license file: ${error}`)); + return false; } logger.warn(`unity license activate --file exited with code ${exitCode}${formatEnvelopeErrors(envelope)}, placing the license file directly...`); From d641cf17edc6bf60472b6869b031ca8fe6bdecf6 Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:56:36 -0400 Subject: [PATCH 46/60] temporarily test only 6000.5 through 7000.0 while macos licensing is fixed --- .github/workflows/build-options.json | 45 ++-------------------------- 1 file changed, 2 insertions(+), 43 deletions(-) diff --git a/.github/workflows/build-options.json b/.github/workflows/build-options.json index c8f747d9..0c202b53 100644 --- a/.github/workflows/build-options.json +++ b/.github/workflows/build-options.json @@ -5,19 +5,6 @@ "macos-latest" ], "unity-version": [ - "4.7.2", - "5.6.7f1 (e80cc3114ac1)", - "2017.4.40f1", - "2018", - "2019.x", - "2020.*", - "2021.3.x", - "2022.3.*", - "6000.0.x", - "6000.1.*", - "6000.2", - "6000.3", - "6000.4", "6000.5", "6000.6", "6000.7", @@ -70,33 +57,5 @@ "build-target": "iOS" } ], - "exclude": [ - { - "os": "ubuntu-latest", - "unity-version": "4.7.2" - }, - { - "os": "macos-latest", - "unity-version": "4.7.2" - }, - { - "os": "windows-latest", - "build-target": "Android", - "unity-version": "4.7.2" - }, - { - "os": "ubuntu-latest", - "unity-version": "5.6.7f1 (e80cc3114ac1)" - }, - { - "os": "macos-latest", - "build-target": "iOS", - "unity-version": "5.6.7f1 (e80cc3114ac1)" - }, - { - "os": "windows-latest", - "build-target": "Android", - "unity-version": "5.6.7f1 (e80cc3114ac1)" - } - ] -} \ No newline at end of file + "exclude": [] +} From 30e6137fdcc3c964d26540727a246114be02153e Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:00:54 -0400 Subject: [PATCH 47/60] skip a cached license file issued to a different machine identity --- src/license-client.ts | 52 ++++++++++++++++++++++++++++++------------- 1 file changed, 36 insertions(+), 16 deletions(-) diff --git a/src/license-client.ts b/src/license-client.ts index 06ca9596..d2190214 100644 --- a/src/license-client.ts +++ b/src/license-client.ts @@ -1007,15 +1007,16 @@ export class LicensingClient { } /** - * Whether Unity's licensing servers resolve any entitlement for the active license. - * A license file that was returned or replaced elsewhere still activates locally, but resolves none. + * The machine binding values the licensing client reports for this machine (Legacy.MachineBinding1, 2, ...). + * @returns The values, or an empty list when the client cannot report them. */ - public async HasResolvableEntitlements(): Promise { + public async GetMachineBindings(): Promise { try { - return /Product Name: \S/.test(await this.exec(['--showEntitlements'], true)); + const context = await this.exec(['--showContext'], true); + return [...context.matchAll(/Legacy\.MachineBinding\d+[ \t]+(\S+)/g)].map(match => match[1]!); } catch (error) { - this.logger.debug(`Failed to read the license entitlements: ${error}`); - return false; + this.logger.debug(`Failed to read the machine bindings: ${error}`); + return []; } } @@ -1173,6 +1174,7 @@ export async function ActivateLicense(options: ActivateOptions): Promise { + try { + const content = await fs.promises.readFile(GetLicenseFilePath(), 'utf-8'); + const bindings = await new LicensingClient().GetMachineBindings(); + + if (bindings.length > 0) { + Logger.instance.info(`The license file is bound to this machine's identity: ${bindings.some(binding => content.includes(binding)) ? 'yes' : 'no'}`); + } + } catch (error) { + Logger.instance.debug(`Could not compare the license file with this machine's identity: ${error}`); + } +} + /** * Returns a Unity license through the official Unity CLI first, then the Unity Licensing Client. * @param licenseType The type of license to return. @@ -1304,6 +1323,15 @@ async function activateFromLicenseFile(licenseFile: string): Promise { throw new Error('The license file is not a Unity license (Unity_lic.ulf).'); } + // a license file is bound to the machine it was issued to: it still activates offline on another machine, but the + // licensing servers resolve no entitlements for it, and the editor fails later. Runner images change that identity. + const bindings = await new LicensingClient().GetMachineBindings(); + + if (bindings.length > 0 && !bindings.some(binding => content.includes(binding))) { + logger.warn('The cached license file was issued to a different machine identity, likely before a runner image change. Activating with the account instead.'); + return false; + } + logger.info('Activating from the cached license file...'); const tempDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'unity-license-')); const tempFile = path.join(tempDirectory, 'Unity_lic.ulf'); @@ -1316,16 +1344,8 @@ async function activateFromLicenseFile(licenseFile: string): Promise { const { exitCode, envelope } = await cli.RunJson(['license', 'activate', '--file', tempFile], { timeoutMs: UNITY_CLI_LICENSE_TIMEOUT_MS }); if (exitCode === 0 && envelope?.success !== false && await isLicenseActive(cli)) { - // the file activates offline even after its seat was returned or replaced, so ask the licensing servers too - if (await new LicensingClient().HasResolvableEntitlements()) { - logger.info('Successfully activated from the cached license file with the Unity CLI'); - return true; - } - - logger.warn('The cached license file activated, but Unity resolves no entitlements for it; it was likely returned or replaced elsewhere. Activating with the account instead.'); - // remove the dead license, so the account activation writes a fresh one instead of seeing a license already in place - await fs.promises.rm(GetLicenseFilePath(), { force: true }).catch(error => logger.debug(`Failed to remove the dead license file: ${error}`)); - return false; + logger.info('Successfully activated from the cached license file with the Unity CLI'); + return true; } logger.warn(`unity license activate --file exited with code ${exitCode}${formatEnvelopeErrors(envelope)}, placing the license file directly...`); From 767965ff0b4c1d15f87e2f4ac3dce078b6cadbd1 Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:45:05 -0400 Subject: [PATCH 48/60] run unity rows grouped by os with a windows seat cap, cache linux editor packages, and rerun marked license failures --- .github/workflows/integration-tests.yml | 27 +++- .github/workflows/rerun-integration-tests.yml | 16 +++ .github/workflows/rerun-marked-failures.yml | 134 ++++++++++++++++++ .github/workflows/unity-build.yml | 75 +++++++--- 4 files changed, 231 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/rerun-integration-tests.yml create mode 100644 .github/workflows/rerun-marked-failures.yml diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 69da2f1a..f96c78cc 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -41,7 +41,8 @@ jobs: id: setup-jobs with: build-options: ./.github/workflows/build-options.json - group-by: unity-version + # macOS and Linux rows share one license seat per OS and run all at once; each Windows row takes its own seat + group-by: os outputs: jobs: ${{ steps.setup-jobs.outputs.jobs }} validate: @@ -54,12 +55,13 @@ jobs: strategy: matrix: ${{ fromJSON(needs.setup.outputs.jobs) }} fail-fast: false - max-parallel: 1 secrets: inherit uses: ./.github/workflows/unity-build.yml with: matrix: ${{ toJSON(matrix.jobs.matrix) }} utp-test-profile: normal + # the UNITY_WINDOWS_SEATS repo variable raises the Windows cap without a workflow change + max-parallel: ${{ matrix.jobs.name == 'windows-latest' && fromJSON(vars.UNITY_WINDOWS_SEATS || '2') || 256 }} validate-negative-scenarios: if: github.event.pull_request.draft == false name: build negative-scenarios @@ -69,8 +71,27 @@ jobs: secrets: inherit uses: ./.github/workflows/unity-build.yml with: - matrix: '{"include":[{"os":"ubuntu-latest","unity-version":"6000.1","build-target":"StandaloneLinux64","name":"negative-scenarios / ubuntu-latest StandaloneLinux64"}]}' + matrix: '{"include":[{"os":"ubuntu-latest","unity-version":"6000.1","build-target":"StandaloneLinux64","name":"negative-scenarios"}]}' utp-test-profile: negative + # one check that stands for the whole run, so rows can be regrouped or renamed without touching required checks + ci-result: + needs: [utp-batch-contract, setup, validate, validate-negative-scenarios] + if: always() + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + steps: + - name: Check results + env: + RESULTS: ${{ join(needs.*.result, ' ') }} + run: | + echo "Job results: ${RESULTS}" + for result in ${RESULTS}; do + if [ "${result}" = "failure" ] || [ "${result}" = "cancelled" ]; then + echo "::error::At least one job ended with ${result}" + exit 1 + fi + done timeline: needs: [setup, validate, validate-negative-scenarios] if: always() diff --git a/.github/workflows/rerun-integration-tests.yml b/.github/workflows/rerun-integration-tests.yml new file mode 100644 index 00000000..1f906c5a --- /dev/null +++ b/.github/workflows/rerun-integration-tests.yml @@ -0,0 +1,16 @@ +name: rerun-integration-tests +# workflow_run only fires for workflow files on the default branch, so this starts working once it is merged to main +on: + workflow_run: + workflows: [integration-tests] + types: [completed] +jobs: + rerun: + if: github.event.workflow_run.conclusion == 'failure' + permissions: + actions: write + checks: read + uses: ./.github/workflows/rerun-marked-failures.yml + with: + run-id: ${{ github.event.workflow_run.id }} + run-attempt: ${{ github.event.workflow_run.run_attempt }} diff --git a/.github/workflows/rerun-marked-failures.yml b/.github/workflows/rerun-marked-failures.yml new file mode 100644 index 00000000..75d70dfd --- /dev/null +++ b/.github/workflows/rerun-marked-failures.yml @@ -0,0 +1,134 @@ +name: rerun-marked-failures +# Reruns the failed jobs of a finished run when every failure was marked as retryable, such as no free Unity license +# seat or a transient download error. unity-cli marks those failures with an error annotation whose title is one of the +# markers below, so any repository whose jobs use unity-cli can call this from a workflow_run workflow of its own: +# +# on: +# workflow_run: +# workflows: [] +# types: [completed] +# jobs: +# rerun: +# if: github.event.workflow_run.conclusion == 'failure' +# permissions: +# actions: write +# checks: read +# uses: RageAgainstThePixel/unity-cli/.github/workflows/rerun-marked-failures.yml@v4 +# with: +# run-id: ${{ github.event.workflow_run.id }} +# run-attempt: ${{ github.event.workflow_run.run_attempt }} +# +# The run starts only once the watched run has finished, which is when its Windows seats are returned, so nothing waits +# or polls in between. +on: + workflow_call: + inputs: + run-id: + description: The finished run to check. + required: true + type: number + run-attempt: + description: The attempt of that run that finished. + required: true + type: number + max-attempts: + description: The last attempt that may be rerun; later attempts are left failed. + required: false + type: number + default: 3 + markers: + description: Comma-separated annotation titles that mark a failure as retryable. + required: false + type: string + default: unity-license-exhausted,unity-transient-failure + ignore-jobs: + description: Comma-separated names of jobs that only report other jobs' results, such as an aggregate check. + required: false + type: string + default: ci-result + delay-seconds: + description: Seconds to wait before the rerun, for seats that other repositories may still hold. + required: false + type: number + default: 0 +jobs: + rerun: + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + actions: write # to rerun the failed jobs + checks: read # to read the failed jobs' annotations + steps: + - name: Rerun marked failures + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + RUN_ID: ${{ inputs.run-id }} + RUN_ATTEMPT: ${{ inputs.run-attempt }} + MAX_ATTEMPTS: ${{ inputs.max-attempts }} + MARKERS: ${{ inputs.markers }} + IGNORE_JOBS: ${{ inputs.ignore-jobs }} + DELAY_SECONDS: ${{ inputs.delay-seconds }} + run: | + $ErrorActionPreference = 'Stop' + $repo = $env:GITHUB_REPOSITORY + $attempt = [int]$env:RUN_ATTEMPT + $markers = $env:MARKERS -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ } + $ignore = $env:IGNORE_JOBS -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ } + function Write-Summary([string]$text) { Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value $text } + # the REST API answers 5xx now and then, and always for 100 jobs with their steps in one page, so pages are + # kept small and a retry with backoff rides out the rest + function Invoke-GhApi([string]$path, [string]$jq) { + foreach ($try in 1..4) { + $out = gh api $path --paginate --jq $jq + if ($LASTEXITCODE -eq 0) { return $out } + if ($try -lt 4) { Start-Sleep -Seconds ([math]::Pow(2, $try)) } + } + throw "gh api $path failed after 4 attempts" + } + + $jobs = Invoke-GhApi "repos/$repo/actions/runs/$($env:RUN_ID)/attempts/$attempt/jobs?per_page=30" '.jobs[]' | + ForEach-Object { $_ | ConvertFrom-Json } + $failed = @($jobs | Where-Object { $_.conclusion -eq 'failure' -and $ignore -notcontains $_.name }) + if ($failed.Count -eq 0) { + Write-Summary "No failed jobs in run $($env:RUN_ID) attempt $attempt." + exit 0 + } + + $marked = @() + $unmarked = @() + foreach ($job in $failed) { + # a job's id is also its check run id + $titles = @(Invoke-GhApi "repos/$repo/check-runs/$($job.id)/annotations?per_page=100" '.[].title') + $hit = $titles | Where-Object { $markers -contains $_ } | Select-Object -First 1 + if ($hit) { $marked += [pscustomobject]@{ name = $job.name; marker = $hit } } + # a job that only failed because a job it needs failed carries no annotation of its own + elseif ($job.steps.Count -eq 0 -or -not ($job.steps | Where-Object { $_.conclusion -eq 'failure' })) { continue } + else { $unmarked += $job.name } + } + + Write-Summary "### Retryable failures in run $($env:RUN_ID) attempt $attempt" + Write-Summary '' + foreach ($m in $marked) { Write-Summary "- ``$($m.marker)``: $($m.name)" } + foreach ($u in $unmarked) { Write-Summary "- not retryable: $u" } + Write-Summary '' + + if ($marked.Count -eq 0) { + Write-Summary 'Nothing to rerun: no failure carries a retry marker.' + exit 0 + } + if ($unmarked.Count -gt 0) { + # a rerun repeats every failed job, so a real failure would only fail again at full cost + Write-Summary 'Not rerun: other jobs failed for reasons a rerun does not fix.' + exit 0 + } + if ($attempt -ge [int]$env:MAX_ATTEMPTS) { + Write-Summary "Not rerun: attempt $attempt reached the limit of $($env:MAX_ATTEMPTS)." + echo "::warning::Run $($env:RUN_ID) still has retryable failures after $attempt attempts" + exit 0 + } + + $delay = [int]$env:DELAY_SECONDS + if ($delay -gt 0) { Start-Sleep -Seconds $delay } + gh run rerun $env:RUN_ID --failed --repo $repo + Write-Summary "Reran the $($marked.Count) failed jobs as attempt $($attempt + 1)." diff --git a/.github/workflows/unity-build.yml b/.github/workflows/unity-build.yml index ff071a41..89271408 100644 --- a/.github/workflows/unity-build.yml +++ b/.github/workflows/unity-build.yml @@ -9,6 +9,11 @@ on: required: false type: string default: normal + max-parallel: + description: How many rows run at once. Only Windows rows need a cap, since each one takes its own license seat. + required: false + type: number + default: 256 secrets: UNITY_USERNAME: required: true @@ -22,12 +27,14 @@ on: required: false jobs: build: - name: ${{ matrix.name }} + # rows are grouped by OS, so the name carries the editor and target + name: ${{ matrix.unity-version }} ${{ matrix.build-target }} ${{ matrix.build-args }} runs-on: ${{ matrix.os }} - timeout-minutes: 60 # successful rows take about 10 minutes, Linux rows up to 30 on a slow apt mirror; a hung editor or licensing call must not hold the matrix + timeout-minutes: 45 # a hung editor or licensing call must not hold the matrix strategy: matrix: ${{ fromJSON(inputs.matrix) }} fail-fast: false + max-parallel: ${{ inputs.max-parallel }} permissions: contents: read checks: write # to publish unit test results via checks github api @@ -45,7 +52,10 @@ jobs: MATRIX_CHANNEL: ${{ matrix.channel }} MATRIX_CHANGESET: ${{ matrix.changeset }} steps: + # runs alongside checkout and the unity-cli build; the wait before the editor packages joins it - name: Free Disk Space + id: free-disk-space + background: true if: ${{ matrix.os == 'ubuntu-latest' && (matrix.unity-version != '2018' && matrix.unity-version != '2017.4.40f1') }} uses: endersonmenezes/free-disk-space@8b8cd4e2277836962939a8f3b4ea0ee4bc006156 # v4.0.1 with: @@ -56,14 +66,43 @@ jobs: - uses: actions/setup-node@v7 with: node-version: 24.x + cache: npm - name: Setup unity-cli run: | npm ci npm run build npm run link unity-cli --version + - name: Wait for Free Disk Space + wait: free-disk-space + # the editor's apt packages come from a mirror that has served them at about 40 kB/s, so the .deb files are kept + # in the Actions cache: an apt hook copies every package dpkg installs, and a later run puts them back into apt's + # archive before setup-unity, so apt finds them already downloaded + - name: Restore Linux editor packages + id: linux-packages + if: ${{ runner.os == 'Linux' && matrix.unity-version != 'none' }} + run: | + echo "key=unity-linux-packages-${ImageOS}-${ImageVersion}" >> "$GITHUB_OUTPUT" + echo "restore-key=unity-linux-packages-${ImageOS}-" >> "$GITHUB_OUTPUT" + mkdir -p "${RUNNER_TEMP}/apt-packages" + echo "DPkg::Pre-Install-Pkgs {\"xargs -r cp -n -t ${RUNNER_TEMP}/apt-packages/ || true\";};" | sudo tee /etc/apt/apt.conf.d/99unity-cli-keep-packages > /dev/null + - uses: actions/cache/restore@v6 + id: linux-packages-cache + if: ${{ steps.linux-packages.outcome == 'success' }} + with: + path: ${{ runner.temp }}/apt-packages + key: ${{ steps.linux-packages.outputs.key }} + restore-keys: ${{ steps.linux-packages.outputs.restore-key }} + - name: Seed apt with cached packages + if: ${{ steps.linux-packages-cache.outputs.cache-matched-key != '' }} + run: | + count=$(find "${RUNNER_TEMP}/apt-packages" -maxdepth 1 -name '*.deb' | wc -l | tr -d ' ') + echo "Restored ${count} cached packages" + if [ "${count}" -gt 0 ]; then + sudo cp -n "${RUNNER_TEMP}"/apt-packages/*.deb /var/cache/apt/archives/ + fi - name: Setup Unity - timeout-minutes: 40 # Linux editor packages alone may take 25 minutes on a slow apt mirror + timeout-minutes: 30 run: | unity-cli upm-install --json UPM_VER=$(unity-cli upm-version | tr -d '\n\r') @@ -94,6 +133,21 @@ jobs: fi "${setup_cmd[@]}" fi + - name: Collect Linux editor packages + id: linux-packages-collect + if: ${{ steps.linux-packages.outcome == 'success' && steps.linux-packages-cache.outputs.cache-hit != 'true' }} + run: | + sudo chown -R "$(id -u):$(id -g)" "${RUNNER_TEMP}/apt-packages" + count=$(find "${RUNNER_TEMP}/apt-packages" -maxdepth 1 -name '*.deb' | wc -l | tr -d ' ') + echo "Collected ${count} packages" + echo "count=${count}" >> "$GITHUB_OUTPUT" + - uses: actions/cache/save@v6 + # rows run in parallel, so several may try the same key; the first save wins and the rest only warn + if: ${{ steps.linux-packages-collect.outputs.count > 0 }} + continue-on-error: true + with: + path: ${{ runner.temp }}/apt-packages + key: ${{ steps.linux-packages.outputs.key }} - name: Verify UNITY_CLI_PATH and UNITY_EDITOR_PATH variables run: | echo "UNITY_CLI_PATH: ${UNITY_CLI_PATH}" @@ -336,21 +390,6 @@ jobs: if [ "$marker_found" -ne 0 ]; then exit 1 fi - - name: Uninstall Editor - if: ${{ matrix.unity-version != 'none' }} - timeout-minutes: 5 - continue-on-error: true - run: | - if [ -z "${UNITY_EDITOR_VERSION}" ]; then - echo "UNITY_EDITOR_VERSION is not set, skipping uninstall" - exit 0 - fi - major="${UNITY_EDITOR_VERSION%%.*}" - if [ "${major}" -lt 2017 ]; then - echo "Unity ${UNITY_EDITOR_VERSION} was not installed by the Unity CLI, skipping uninstall" - exit 0 - fi - unity uninstall "${UNITY_EDITOR_VERSION}" --yes - name: Print log paths if: ${{ matrix.unity-version != 'none' }} run: | From 0c7aa285630688228c5c3bfbe362cc2d9001a3f7 Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:45:58 -0400 Subject: [PATCH 49/60] mark license-exhausted and transient failures for reruns, and make ffmpeg opt-in on linux --- src/cli.ts | 9 +++---- src/failure-markers.ts | 45 +++++++++++++++++++++++++++++++++++ src/unity-installer.ts | 15 +++++++----- tests/failure-markers.test.ts | 27 +++++++++++++++++++++ 4 files changed, 86 insertions(+), 10 deletions(-) create mode 100644 src/failure-markers.ts create mode 100644 tests/failure-markers.test.ts diff --git a/src/cli.ts b/src/cli.ts index fce661d6..c7af8d01 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -23,6 +23,7 @@ import { } from './license-client'; import { PromptForSecretInput } from './utilities'; import { UpmCli, UpmPackOptions } from './upm-cli'; +import { ReportFailure } from './failure-markers'; const pkgPath = path.join(__dirname, '..', 'package.json'); const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); @@ -654,7 +655,7 @@ program.command('upm-install') process.exit(0); } catch (error) { - Logger.instance.error(`${error}`); + ReportFailure(error); process.exit(1); } }); @@ -668,7 +669,7 @@ program.command('upm-version') process.stdout.write(`v${version.version}\n`); process.exit(0); } catch (error) { - Logger.instance.error(`${error}`); + ReportFailure(error); process.exit(1); } }); @@ -766,12 +767,12 @@ program.command('upm-pack') process.exit(0); } catch (error) { - Logger.instance.error(`${error}`); + ReportFailure(error); process.exit(1); } }); void program.parseAsync(process.argv).catch((err: unknown) => { - Logger.instance.error(err instanceof Error ? err.message : String(err)); + ReportFailure(err); process.exit(1); }); diff --git a/src/failure-markers.ts b/src/failure-markers.ts new file mode 100644 index 00000000..6e9aab2f --- /dev/null +++ b/src/failure-markers.ts @@ -0,0 +1,45 @@ +import { Logger, LogLevel } from './logging'; + +/** Annotation title for a failure a later rerun can clear because a license seat was taken. */ +export const LICENSE_EXHAUSTED_MARKER = 'unity-license-exhausted'; + +/** Annotation title for a network or CDN failure a later rerun can clear. */ +export const TRANSIENT_FAILURE_MARKER = 'unity-transient-failure'; + +// literal alternations only, so matching stays linear on any message +// the licensing client's exit codes 17 and 19 cover a seat it could not take +const LICENSE_EXHAUSTED_PATTERN = /activations? limit|maximum number of activations|max activations|no free seats?|no available seats?|seat limit|0 free entitlements|Seat activation or deactivation failed|Acquiring license failed/i; +const TRANSIENT_PATTERN = /HTTP 5\d\d|Bad Gateway|Service Unavailable|Gateway Time-?out|ECONNRESET|ETIMEDOUT|EAI_AGAIN|ECONNREFUSED|socket hang up/i; + +/** + * The marker for a failure a rerun after other jobs finish can clear, if the message names one. + * @param message The error message. + * @returns The marker, or undefined for a failure a rerun would repeat. + */ +export function ClassifyFailure(message: string): string | undefined { + if (LICENSE_EXHAUSTED_PATTERN.test(message)) { + return LICENSE_EXHAUSTED_MARKER; + } + + if (TRANSIENT_PATTERN.test(message)) { + return TRANSIENT_FAILURE_MARKER; + } + + return undefined; +} + +/** + * Logs a command failure. Failures a rerun can clear become an error annotation titled with their marker, + * which the rerun workflow looks for; every other failure is logged as before. + * @param error The error that ended the command. + */ +export function ReportFailure(error: unknown): void { + const message = error instanceof Error ? error.message : String(error); + const marker = ClassifyFailure(message); + + if (marker) { + Logger.instance.annotate(LogLevel.ERROR, message, undefined, undefined, undefined, undefined, undefined, marker); + } else { + Logger.instance.error(message); + } +} diff --git a/src/unity-installer.ts b/src/unity-installer.ts index aa1441d7..192586cc 100644 --- a/src/unity-installer.ts +++ b/src/unity-installer.ts @@ -37,12 +37,13 @@ export DEBIAN_FRONTEND=noninteractive # a stalled mirror must not hold the job: apt retries slow requests, and each step has a hard limit APT_OPTS="-o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 -o Acquire::Retries=3 -o DPkg::Lock::Timeout=120" timeout 300 apt-get $APT_OPTS update -# what the editor needs comes first: without pulseaudio, FMOD logs audio device errors on 2019 and 2020 editors; -# ffmpeg pulls in about 60 MB more and is only for video, so a slow mirror may skip it -# pulseaudio alone pulls in about 38 MB, which the runners' Azure mirror has taken over 10 minutes to serve +# without pulseaudio, FMOD logs audio device errors on 2019 and 2020 editors; it pulls in about 38 MB, +# which the runners' Azure mirror has taken over 10 minutes to serve timeout 1200 apt-get $APT_OPTS install -y --no-install-recommends xvfb libgtk2.0-0 libglu1-mesa libgconf-2-4 libncurses5 pulseaudio -timeout 300 apt-get $APT_OPTS install -y --no-install-recommends ffmpeg || echo "Skipped ffmpeg: the install did not finish" -apt-get clean +`; + +/** ffmpeg is only for video capture and import, so it is opt-in with UNITY_CLI_LINUX_FFMPEG=true. */ +const LINUX_FFMPEG_SCRIPT = `timeout 300 apt-get $APT_OPTS install -y --no-install-recommends ffmpeg || echo "Skipped ffmpeg: the install did not finish" `; /** @@ -688,7 +689,9 @@ export class UnityInstaller { if (process.env.UNITY_CLI_LINUX_DEPENDENCIES_INSTALLED !== 'true') { try { // streamed, so a slow mirror shows where it stalls - await Exec('sudo', ['sh', '-c', LINUX_EDITOR_DEPENDENCIES_SCRIPT], { silent: false, showCommand: true }); + // sudo drops the caller's environment, so the opt-in is read here rather than in the script + const ffmpeg = process.env.UNITY_CLI_LINUX_FFMPEG === 'true' ? LINUX_FFMPEG_SCRIPT : ''; + await Exec('sudo', ['sh', '-c', `${LINUX_EDITOR_DEPENDENCIES_SCRIPT}${ffmpeg}apt-get clean\n`], { silent: false, showCommand: true }); process.env.UNITY_CLI_LINUX_DEPENDENCIES_INSTALLED = 'true'; this.logger.CI_setEnvironmentVariable('UNITY_CLI_LINUX_DEPENDENCIES_INSTALLED', 'true'); } catch (error) { diff --git a/tests/failure-markers.test.ts b/tests/failure-markers.test.ts new file mode 100644 index 00000000..2b3588ff --- /dev/null +++ b/tests/failure-markers.test.ts @@ -0,0 +1,27 @@ +import { ClassifyFailure, LICENSE_EXHAUSTED_MARKER, TRANSIENT_FAILURE_MARKER } from '../src/failure-markers'; + +describe('ClassifyFailure', () => { + it.each([ + 'License command failed with exit code 17: Seat activation or deactivation failed', + 'Machine activations limit reached for this account', + 'Code 404 while processing request (status: Found 1 entitlement groups and 0 free entitlements matching requested entitlement ids)', + ])('marks license exhaustion: %s', message => { + expect(ClassifyFailure(message)).toBe(LICENSE_EXHAUSTED_MARKER); + }); + + it.each([ + 'Error: GET https://cdn.packages.unity.com/upm-cli/releases/v9.36.0/upm-macos-arm64.zip.sha256 failed: HTTP 504', + 'Download failed: read ECONNRESET', + 'unity install failed: 503 Service Unavailable', + ])('marks transient failures: %s', message => { + expect(ClassifyFailure(message)).toBe(TRANSIENT_FAILURE_MARKER); + }); + + it.each([ + 'Unity failed with exit code 6', + 'License command failed with exit code 2: Invalid credentials', + 'GET https://example.com failed: HTTP 404', + ])('leaves other failures unmarked: %s', message => { + expect(ClassifyFailure(message)).toBeUndefined(); + }); +}); From 021c8c91d4953deef389cf260bb6d30073b2a5ef Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:58:23 -0400 Subject: [PATCH 50/60] skip returning the license on github-hosted macos and linux runners by default --- README.md | 1 + src/cli.ts | 11 ++++++++- src/license-client.ts | 22 +++++++++++++++-- tests/shared-runner-identity.test.ts | 35 ++++++++++++++++++++++++++++ 4 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 tests/shared-runner-identity.test.ts diff --git a/README.md b/README.md index fbe92a42..63968019 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,7 @@ UNITY_USERNAME= UNITY_PASSWORD= unity-cli activate-li - `-l`, `--license`: License type (personal, professional, floating) - `-t`, `--token`: Floating license token from activation. When given, the floating license is returned with the Unity Licensing Client. +- `--when `: Defaults to `auto`, which skips returning a personal or professional license on GitHub-hosted macOS and Linux runners. Those runners report one machine identity to Unity across every job and repository, so a return there revokes the license for all of them. Windows and self-hosted runners always return. - `--verbose`: Enable verbose logging. ```bash diff --git a/src/cli.ts b/src/cli.ts index c7af8d01..3557dd62 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -20,6 +20,7 @@ import { LicensingClient, PrintLicenseContext, ReturnLicense, + ReturnWhen, } from './license-client'; import { PromptForSecretInput } from './utilities'; import { UpmCli, UpmPackOptions } from './upm-cli'; @@ -119,12 +120,20 @@ program.command('return-license') .description('Return a Unity license. Tries the official Unity CLI first, then the Unity Licensing Client.') .option('-l, --license ', 'License type (personal, professional, floating)') .option('-t, --token ', 'Floating license token from activation. When given, the floating license is returned with the Unity Licensing Client.') + .option('--when ', 'auto, always or never. auto skips returning a personal or professional license on GitHub-hosted macOS and Linux runners, which share one machine identity: a return there revokes the license for every job and repo on that identity. Defaults to auto.', 'auto') .option('--verbose', 'Enable verbose logging.') .action(async (options) => { if (options.verbose) { Logger.instance.logLevel = LogLevel.DEBUG; } + const when = options.when?.toString()?.trim().toLowerCase(); + + if (!['auto', 'always', 'never'].includes(when)) { + Logger.instance.error(`Invalid --when value: ${when}. Expected auto, always or never.`); + process.exit(1); + } + const token: string | undefined = options.token?.toString()?.trim() || undefined; // Mask token in CI environments before any potential logging @@ -145,7 +154,7 @@ program.command('return-license') process.exit(1); } - await ReturnLicense(licenseType, token); + await ReturnLicense(licenseType, token, when as ReturnWhen); process.exit(0); }); diff --git a/src/license-client.ts b/src/license-client.ts index d2190214..81644e62 100644 --- a/src/license-client.ts +++ b/src/license-client.ts @@ -1211,14 +1211,32 @@ async function logLicenseBinding(): Promise { } } +/** When `return-license` returns: `auto` skips a shared runner identity, `always` and `never` do what they say. */ +export type ReturnWhen = 'auto' | 'always' | 'never'; + +/** + * Whether this is a GitHub-hosted macOS or Linux runner. Those report one machine identity to Unity across every job + * and repo, so returning a license there revokes it for all of them. Windows and self-hosted runners have their own. + */ +export function IsSharedRunnerIdentity(): boolean { + return process.env.RUNNER_ENVIRONMENT === 'github-hosted' && (process.platform === 'darwin' || process.platform === 'linux'); +} + /** * Returns a Unity license through the official Unity CLI first, then the Unity Licensing Client. * @param licenseType The type of license to return. * @param token The floating license token from activation. A floating license with a token is returned by the Unity Licensing Client. + * @param when `auto` (the default) skips returning a personal or professional license on a shared runner identity. */ -export async function ReturnLicense(licenseType: LicenseType, token: string | undefined = undefined): Promise { +export async function ReturnLicense(licenseType: LicenseType, token: string | undefined = undefined, when: ReturnWhen = 'auto'): Promise { try { - await returnLicense(licenseType, token); + if (when === 'never') { + Logger.instance.info('Skipping the license return (--when never).'); + } else if (when === 'auto' && licenseType !== LicenseType.floating && IsSharedRunnerIdentity()) { + Logger.instance.info('Skipping the license return: GitHub-hosted macOS and Linux runners share one machine identity, so a return would revoke the license for every job and repo on it. Use --when always to return anyway.'); + } else { + await returnLicense(licenseType, token); + } } finally { // returning is the last licensing step of a job, so stop what the licensing calls left running in the background await LicensingClient.StopBackgroundClients(); diff --git a/tests/shared-runner-identity.test.ts b/tests/shared-runner-identity.test.ts new file mode 100644 index 00000000..616803db --- /dev/null +++ b/tests/shared-runner-identity.test.ts @@ -0,0 +1,35 @@ +import { IsSharedRunnerIdentity } from '../src/license-client'; + +describe('IsSharedRunnerIdentity', () => { + const environment = process.env.RUNNER_ENVIRONMENT; + const platform = process.platform; + + const setPlatform = (value: NodeJS.Platform) => Object.defineProperty(process, 'platform', { value }); + + afterEach(() => { + if (environment === undefined) { + delete process.env.RUNNER_ENVIRONMENT; + } else { + process.env.RUNNER_ENVIRONMENT = environment; + } + + setPlatform(platform); + }); + + it.each([ + ['github-hosted', 'darwin', true], + ['github-hosted', 'linux', true], + ['github-hosted', 'win32', false], + ['self-hosted', 'darwin', false], + [undefined, 'linux', false], + ] as const)('%s on %s is %s', (runner, os, expected) => { + if (runner === undefined) { + delete process.env.RUNNER_ENVIRONMENT; + } else { + process.env.RUNNER_ENVIRONMENT = runner; + } + + setPlatform(os); + expect(IsSharedRunnerIdentity()).toBe(expected); + }); +}); From 90cb2fbd0bbb15371436cc1330d13ddbb998cc41 Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:46:24 -0400 Subject: [PATCH 51/60] test unity 6 and newer on prs and 4.7.2 through 2022.3 monthly --- .github/workflows/build-options-legacy.json | 77 +++++++++++++++++++++ .github/workflows/build-options.json | 8 ++- .github/workflows/integration-tests.yml | 16 ++++- docs/unity-version-support.md | 5 +- 4 files changed, 99 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/build-options-legacy.json diff --git a/.github/workflows/build-options-legacy.json b/.github/workflows/build-options-legacy.json new file mode 100644 index 00000000..2885f63e --- /dev/null +++ b/.github/workflows/build-options-legacy.json @@ -0,0 +1,77 @@ +{ + "os": [ + "ubuntu-latest", + "windows-latest", + "macos-latest" + ], + "unity-version": [ + "4.7.2", + "5.6.7f1 (e80cc3114ac1)", + "2017.4.40f1", + "2018", + "2019.x", + "2020.*", + "2021.3.x", + "2022.3.*" + ], + "include": [ + { + "os": "ubuntu-latest", + "build-target": "StandaloneLinux64" + }, + { + "os": "ubuntu-latest", + "build-target": "WebGL", + "build-args": "-colorSpace Gamma" + }, + { + "os": "ubuntu-latest", + "build-target": "Android" + }, + { + "os": "windows-latest", + "build-target": "StandaloneWindows64" + }, + { + "os": "windows-latest", + "build-target": "Android" + }, + { + "os": "macos-latest", + "build-target": "StandaloneOSX" + }, + { + "os": "macos-latest", + "build-target": "iOS" + } + ], + "exclude": [ + { + "os": "ubuntu-latest", + "unity-version": "4.7.2" + }, + { + "os": "macos-latest", + "unity-version": "4.7.2" + }, + { + "os": "windows-latest", + "build-target": "Android", + "unity-version": "4.7.2" + }, + { + "os": "ubuntu-latest", + "unity-version": "5.6.7f1 (e80cc3114ac1)" + }, + { + "os": "macos-latest", + "build-target": "iOS", + "unity-version": "5.6.7f1 (e80cc3114ac1)" + }, + { + "os": "windows-latest", + "build-target": "Android", + "unity-version": "5.6.7f1 (e80cc3114ac1)" + } + ] +} diff --git a/.github/workflows/build-options.json b/.github/workflows/build-options.json index 0c202b53..35263ec7 100644 --- a/.github/workflows/build-options.json +++ b/.github/workflows/build-options.json @@ -5,6 +5,11 @@ "macos-latest" ], "unity-version": [ + "6000.0.x", + "6000.1.*", + "6000.2", + "6000.3", + "6000.4", "6000.5", "6000.6", "6000.7", @@ -56,6 +61,5 @@ "os": "macos-latest", "build-target": "iOS" } - ], - "exclude": [] + ] } diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index f96c78cc..7be150e9 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -5,9 +5,18 @@ on: pull_request: branches: ['**'] types: [opened, reopened, synchronize, ready_for_review] + schedule: + - cron: '0 8 1 * *' # monthly: the pre-Unity 6 editors, which no longer change workflow_dispatch: # Allows you to run this workflow manually from the Actions tab + inputs: + editors: + description: Which editors to test + type: choice + options: [unity-6-and-newer, legacy] + default: unity-6-and-newer concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + # the monthly legacy run gets its own group, so a push to main does not cancel it + group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name == 'schedule' || inputs.editors == 'legacy' }} cancel-in-progress: true jobs: utp-batch-contract: @@ -40,7 +49,8 @@ jobs: - uses: RageAgainstThePixel/job-builder@v1 id: setup-jobs with: - build-options: ./.github/workflows/build-options.json + # pull requests and pushes test Unity 6 and newer; 4.7.2 through 2022.3 run monthly or on demand + build-options: ${{ (github.event_name == 'schedule' || inputs.editors == 'legacy') && './.github/workflows/build-options-legacy.json' || './.github/workflows/build-options.json' }} # macOS and Linux rows share one license seat per OS and run all at once; each Windows row takes its own seat group-by: os outputs: @@ -63,7 +73,7 @@ jobs: # the UNITY_WINDOWS_SEATS repo variable raises the Windows cap without a workflow change max-parallel: ${{ matrix.jobs.name == 'windows-latest' && fromJSON(vars.UNITY_WINDOWS_SEATS || '2') || 256 }} validate-negative-scenarios: - if: github.event.pull_request.draft == false + if: github.event.pull_request.draft == false && github.event_name != 'schedule' && inputs.editors != 'legacy' name: build negative-scenarios permissions: contents: read diff --git a/docs/unity-version-support.md b/docs/unity-version-support.md index 97445472..9823e10c 100644 --- a/docs/unity-version-support.md +++ b/docs/unity-version-support.md @@ -1,10 +1,11 @@ # Unity version support -This repo tests Unity editors on CI in two tiers: +This repo tests Unity editors on CI in three tiers: | Tier | What it means | How CI selects | |------|----------------|----------------| -| **Tested** | Integration matrix, from 4.7.2 through the newest Unity 6 beta and the Unity 7 alpha | [`.github/workflows/build-options.json`](../.github/workflows/build-options.json) via `setup-unity --channel f,b,a`: each row runs the newest build of its stream, and a final release wins once the stream has one | +| **Tested on every PR** | Unity 6000.0 through the newest Unity 6 beta and the Unity 7 alpha | [`.github/workflows/build-options.json`](../.github/workflows/build-options.json) via `setup-unity --channel f,b,a`: each row runs the newest build of its stream, and a final release wins once the stream has one | +| **Tested monthly** | 4.7.2 through 2022.3, which no longer change | [`.github/workflows/build-options-legacy.json`](../.github/workflows/build-options-legacy.json), on the monthly schedule or a manual run of integration-tests with `editors: legacy` | | **Untested** | Not in CI; still installable | Fully-qualified `-u 6000.x.yfN -c ` or partial + `--channel b\|a` | ## Resolving versions From 77424ca3f68e5795365c2524cf184712f6acf0bc Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:17:21 -0400 Subject: [PATCH 52/60] give android rows a 60 minute job timeout --- .github/workflows/unity-build.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/unity-build.yml b/.github/workflows/unity-build.yml index 89271408..e86d7500 100644 --- a/.github/workflows/unity-build.yml +++ b/.github/workflows/unity-build.yml @@ -30,7 +30,8 @@ jobs: # rows are grouped by OS, so the name carries the editor and target name: ${{ matrix.unity-version }} ${{ matrix.build-target }} ${{ matrix.build-args }} runs-on: ${{ matrix.os }} - timeout-minutes: 45 # a hung editor or licensing call must not hold the matrix + # a hung editor or licensing call must not hold the matrix; Android rows build a player through Gradle and get more + timeout-minutes: ${{ matrix.build-target == 'Android' && 60 || 45 }} strategy: matrix: ${{ fromJSON(inputs.matrix) }} fail-fast: false From 39452121de8b5fc0ed57797e034146a76b8ddf74 Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:22:39 -0400 Subject: [PATCH 53/60] disable webgl compression in test projects so builds skip brotli --- .github/workflows/unity-build.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/unity-build.yml b/.github/workflows/unity-build.yml index e86d7500..137a1f6f 100644 --- a/.github/workflows/unity-build.yml +++ b/.github/workflows/unity-build.yml @@ -302,6 +302,22 @@ jobs: sed -i 's/AndroidTargetSdkVersion: [0-9]*/AndroidTargetSdkVersion: 32/' "${UNITY_PROJECT_PATH}/ProjectSettings/ProjectSettings.asset" # ensure android dependencies are installed before UTP/build batches unity-cli setup-unity -p "${UNITY_PROJECT_PATH}" -m android + - name: Disable WebGL Compression + # Brotli on the .wasm takes minutes per build and the tests never serve the player, so it only costs job time + if: ${{ matrix.build-target == 'WebGL' && matrix.unity-version != 'none' }} + shell: pwsh + run: | + $path = Join-Path $env:UNITY_PROJECT_PATH 'ProjectSettings/ProjectSettings.asset' + $text = Get-Content -Path $path -Raw + # 0 is Brotli, 1 is Gzip and 2 is Disabled + if ($text -match '(?m)^([ \t]*)webGLCompressionFormat:.*$') { + $text = $text -replace '(?m)^([ \t]*)webGLCompressionFormat:.*$', '${1}webGLCompressionFormat: 2' + } else { + # the asset holds only the PlayerSettings document, whose keys sit two spaces in + $text = $text.TrimEnd() + "`n webGLCompressionFormat: 2`n" + } + Set-Content -Path $path -Value $text -NoNewline + Select-String -Path $path -Pattern 'webGLCompressionFormat' - name: Compute safe artifact name id: artifact-name env: From 2f31b3f91be42177f01cbca0bae0ee164adb1647 Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Fri, 30 Oct 2026 00:11:49 -0400 Subject: [PATCH 54/60] mark a download cut off mid-stream as a transient failure --- src/failure-markers.ts | 5 +++-- tests/failure-markers.test.ts | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/failure-markers.ts b/src/failure-markers.ts index 6e9aab2f..e00ad088 100644 --- a/src/failure-markers.ts +++ b/src/failure-markers.ts @@ -9,7 +9,7 @@ export const TRANSIENT_FAILURE_MARKER = 'unity-transient-failure'; // literal alternations only, so matching stays linear on any message // the licensing client's exit codes 17 and 19 cover a seat it could not take const LICENSE_EXHAUSTED_PATTERN = /activations? limit|maximum number of activations|max activations|no free seats?|no available seats?|seat limit|0 free entitlements|Seat activation or deactivation failed|Acquiring license failed/i; -const TRANSIENT_PATTERN = /HTTP 5\d\d|Bad Gateway|Service Unavailable|Gateway Time-?out|ECONNRESET|ETIMEDOUT|EAI_AGAIN|ECONNREFUSED|socket hang up/i; +const TRANSIENT_PATTERN = /HTTP 5\d\d|Bad Gateway|Service Unavailable|Gateway Time-?out|ECONNRESET|ECONNABORTED|ETIMEDOUT|EAI_AGAIN|ECONNREFUSED|socket hang up/i; /** * The marker for a failure a rerun after other jobs finish can clear, if the message names one. @@ -21,7 +21,8 @@ export function ClassifyFailure(message: string): string | undefined { return LICENSE_EXHAUSTED_MARKER; } - if (TRANSIENT_PATTERN.test(message)) { + // Node reports a download cut off mid-stream with a bare "aborted" + if (TRANSIENT_PATTERN.test(message) || /^(Error: )?aborted$/.test(message.trim())) { return TRANSIENT_FAILURE_MARKER; } diff --git a/tests/failure-markers.test.ts b/tests/failure-markers.test.ts index 2b3588ff..8bd1de0a 100644 --- a/tests/failure-markers.test.ts +++ b/tests/failure-markers.test.ts @@ -13,6 +13,8 @@ describe('ClassifyFailure', () => { 'Error: GET https://cdn.packages.unity.com/upm-cli/releases/v9.36.0/upm-macos-arm64.zip.sha256 failed: HTTP 504', 'Download failed: read ECONNRESET', 'unity install failed: 503 Service Unavailable', + 'aborted', + 'Error: aborted', ])('marks transient failures: %s', message => { expect(ClassifyFailure(message)).toBe(TRANSIENT_FAILURE_MARKER); }); From 6d93d65063924b3ce201d77036577c40dbb0d4a9 Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Fri, 30 Oct 2026 00:58:47 -0400 Subject: [PATCH 55/60] group unity rows by editor version again, with every group running at once --- .github/workflows/integration-tests.yml | 7 +++---- .github/workflows/unity-build.yml | 10 ++-------- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 7be150e9..cd276349 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -51,8 +51,9 @@ jobs: with: # pull requests and pushes test Unity 6 and newer; 4.7.2 through 2022.3 run monthly or on demand build-options: ${{ (github.event_name == 'schedule' || inputs.editors == 'legacy') && './.github/workflows/build-options-legacy.json' || './.github/workflows/build-options.json' }} - # macOS and Linux rows share one license seat per OS and run all at once; each Windows row takes its own seat - group-by: os + # one group per editor version; every group runs at once, since macOS and Linux rows share one seat per OS + # and Windows rows return theirs + group-by: unity-version outputs: jobs: ${{ steps.setup-jobs.outputs.jobs }} validate: @@ -70,8 +71,6 @@ jobs: with: matrix: ${{ toJSON(matrix.jobs.matrix) }} utp-test-profile: normal - # the UNITY_WINDOWS_SEATS repo variable raises the Windows cap without a workflow change - max-parallel: ${{ matrix.jobs.name == 'windows-latest' && fromJSON(vars.UNITY_WINDOWS_SEATS || '2') || 256 }} validate-negative-scenarios: if: github.event.pull_request.draft == false && github.event_name != 'schedule' && inputs.editors != 'legacy' name: build negative-scenarios diff --git a/.github/workflows/unity-build.yml b/.github/workflows/unity-build.yml index 137a1f6f..9cf9ca9a 100644 --- a/.github/workflows/unity-build.yml +++ b/.github/workflows/unity-build.yml @@ -9,11 +9,6 @@ on: required: false type: string default: normal - max-parallel: - description: How many rows run at once. Only Windows rows need a cap, since each one takes its own license seat. - required: false - type: number - default: 256 secrets: UNITY_USERNAME: required: true @@ -27,15 +22,14 @@ on: required: false jobs: build: - # rows are grouped by OS, so the name carries the editor and target - name: ${{ matrix.unity-version }} ${{ matrix.build-target }} ${{ matrix.build-args }} + # rows are grouped by editor version, so the name carries the OS and target + name: ${{ matrix.os }} ${{ matrix.build-target }} ${{ matrix.build-args }} runs-on: ${{ matrix.os }} # a hung editor or licensing call must not hold the matrix; Android rows build a player through Gradle and get more timeout-minutes: ${{ matrix.build-target == 'Android' && 60 || 45 }} strategy: matrix: ${{ fromJSON(inputs.matrix) }} fail-fast: false - max-parallel: ${{ inputs.max-parallel }} permissions: contents: read checks: write # to publish unit test results via checks github api From 9042e1f2c14603b7c1ba078ad1878365241c4a57 Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Fri, 30 Oct 2026 02:42:23 -0400 Subject: [PATCH 56/60] stop only the processes this job started, never another job's on a shared runner --- src/license-client.ts | 57 ++++++--- src/process-tracker.ts | 224 ++++++++++++++++++++++++++++++++++ src/unity-cli.ts | 89 +++++++++++--- src/unity-editor.ts | 57 +++++++-- src/utilities.ts | 194 +++++++---------------------- tests/process-tracker.test.ts | 77 ++++++++++++ 6 files changed, 502 insertions(+), 196 deletions(-) create mode 100644 src/process-tracker.ts create mode 100644 tests/process-tracker.test.ts diff --git a/src/license-client.ts b/src/license-client.ts index 81644e62..d6ebf8a2 100644 --- a/src/license-client.ts +++ b/src/license-client.ts @@ -8,8 +8,6 @@ import { ResolveGlobToPath, DownloadFile, Exec, - FindProcessesByName, - FindProcessesUnder, KillProcess, SanitizeLogText, Sha256FileHex, @@ -18,6 +16,13 @@ import { redactSensitiveLiterals, tryParseJson } from './utilities'; +import { + FindOwnedProcesses, + IsExecutableNamed, + IsPathUnder, + OwnedSpawnOptions, + TrackOwnedProcess, +} from './process-tracker'; export enum LicenseType { personal = 'personal', @@ -644,6 +649,7 @@ export class LicensingClient { let output: string = ''; let exitCode: number = 0; let timedOut = false; + let killedBy: NodeJS.Signals | null = null; // values that must never reach a log, even outside CI where ::add-mask:: does nothing const sensitiveFlags = ['--username', '--password', '--serial', '--return-floating']; @@ -657,9 +663,13 @@ export class LicensingClient { try { exitCode = await new Promise((resolve, reject) => { fs.accessSync(this.licenseClientPath!, fs.constants.R_OK | fs.constants.X_OK); + const startedAt = Date.now(); const child = spawn(this.licenseClientPath!, args, { - stdio: ['ignore', 'pipe', 'pipe'] + stdio: ['ignore', 'pipe', 'pipe'], + ...OwnedSpawnOptions(), }); + // the background instance the client can start stays reachable through this record + TrackOwnedProcess(child, startedAt); const sigintHandler = () => child.kill('SIGINT'); const sigtermHandler = () => child.kill('SIGTERM'); @@ -694,18 +704,20 @@ export class LicensingClient { // the client can start a background instance that inherits its stdout and stderr, // so 'close' may never fire; settle on 'exit' and stop reading the pipes shortly after let settled = false; - const settle = (code: number | null) => { + const settle = (code: number | null, signal: NodeJS.Signals | null) => { if (settled) { return; } settled = true; removeListeners(); - resolve(timedOut ? -1 : code === null ? 0 : code); + killedBy = signal; + // a client killed by a signal did not finish its work, so it is a failure, not exit code 0 + resolve(timedOut ? -1 : code === null ? (signal ? -2 : 0) : code); }; child.on('close', settle); - child.on('exit', (code) => { + child.on('exit', (code, signal) => { setTimeout(() => { child.stdout.destroy(); child.stderr.destroy(); - settle(code); + settle(code, signal); }, 1000); }); }); @@ -716,10 +728,8 @@ export class LicensingClient { } if (!silent || exitCode !== 0) { - // --debug output can carry tokens, so it gets the same sanitizing as the client log - const maskedOutput = args.includes('--debug') - ? this.maskSerialInOutput(output).split(/\r?\n/).map(line => LicensingClient.sanitizeLogLine(line, secrets)).join('\n') - : redact(this.maskSerialInOutput(output)); + // any client output can carry emails and tokens, so it gets the same sanitizing as the client log + const maskedOutput = this.maskSerialInOutput(output).split(/\r?\n/).map(line => LicensingClient.sanitizeLogLine(line, secrets)).join('\n'); const splitLines = maskedOutput.split(/\r?\n/); for (const line of splitLines) { @@ -731,7 +741,7 @@ export class LicensingClient { this.logger.endGroup(); if (exitCode !== 0) { - const message = timedOut ? 'Timed out' : this.getExitCodeMessage(exitCode); + const message = timedOut ? 'Timed out' : killedBy ? `Killed by ${killedBy}` : this.getExitCodeMessage(exitCode); throw new Error(`License command failed with exit code ${exitCode}: ${message}`); } } @@ -768,8 +778,9 @@ export class LicensingClient { } /** - * Stops Unity.Licensing.Client instances left running in the background, such as the one the official Unity CLI starts. - * A command line activation can wait on that instance indefinitely. Only done in CI, so a developer's own session is left alone. + * Stops Unity.Licensing.Client instances that this job's unity-cli commands started, directly or through the + * official Unity CLI or an editor run. A command line activation can wait on such an instance indefinitely. + * Clients other jobs started are left alone, even on a shared self-hosted runner. Only done in CI. */ public static async StopBackgroundClients(): Promise { if (process.env.CI !== 'true') { @@ -777,7 +788,7 @@ export class LicensingClient { } // matched on the executable's own name, so a process that only mentions it on its command line is left alone - const clients = await FindProcessesByName('Unity.Licensing.Client'); + const clients = (await FindOwnedProcesses()).filter(proc => IsExecutableNamed(proc, 'Unity.Licensing.Client')); await Promise.all(clients.map(client => KillProcess(client).catch(() => undefined))); } @@ -1191,7 +1202,9 @@ export async function ActivateLicense(options: ActivateOptions): Promise { Logger.instance.info(`The license file is bound to this machine's identity: ${bindings.some(binding => content.includes(binding)) ? 'yes' : 'no'}`); } } catch (error) { - Logger.instance.debug(`Could not compare the license file with this machine's identity: ${error}`); + Logger.instance.info(`Could not compare the license file with this machine's identity: ${error instanceof Error ? error.message : error}`); } } @@ -1245,7 +1258,8 @@ export async function ReturnLicense(licenseType: LicenseType, token: string | un } /** - * Stops background processes the official Unity CLI left running from its own folder. Only done in CI. + * Stops background processes the official Unity CLI left running from its own folder, when this job's unity-cli + * commands started them. Only done in CI. */ async function stopUnityCliHelpers(): Promise { const cliPath = process.env.UNITY_CLI_PATH; @@ -1254,7 +1268,9 @@ async function stopUnityCliHelpers(): Promise { return; } - const helpers = await FindProcessesUnder(path.dirname(cliPath)); + const cliDirectory = path.dirname(cliPath); + const helpers = (await FindOwnedProcesses()) + .filter(proc => IsPathUnder(proc.executablePath ?? proc.commandLine.split(' ')[0] ?? '', cliDirectory)); await Promise.all(helpers.map(helper => KillProcess(helper).catch(() => undefined))); } @@ -1333,6 +1349,9 @@ async function activateFromLicenseFile(licenseFile: string): Promise { content = await fs.promises.readFile(trimmed, 'utf-8'); } else if (trimmed.startsWith('<')) { content = trimmed; + } else if (/\.ulf$/i.test(trimmed) || /^([A-Za-z]:[\\/]|[\\/~.])/.test(trimmed)) { + // base64 of a license starts with "PD" or "PE", so this is a path to a file that is not there + throw new Error(`The license file was not found: ${trimmed}`); } else { content = Buffer.from(trimmed, 'base64').toString('utf-8'); } diff --git a/src/process-tracker.ts b/src/process-tracker.ts new file mode 100644 index 00000000..656d0c03 --- /dev/null +++ b/src/process-tracker.ts @@ -0,0 +1,224 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { ChildProcess } from 'child_process'; +import { Logger } from './logging'; +import { Exec, GetTempDir, ProcInfo } from './utilities'; + +/** + * Tracks the processes unity-cli starts, so cleanup stops only those and their helpers, never another job's. + * + * On macOS and Linux each tracked process leads its own process group, and every helper it starts (the editor's + * shader compiler and bee_backend, a licensing client's background instance) stays in that group even after it is + * re-parented to pid 1. On Windows a process keeps its parent's id after the parent exits, so helpers are found by + * walking parent ids from the tracked process, limited to processes created after it started. + * + * Tracked processes are recorded in a file under the runner's temp folder, so a later command in the same job, such as + * return-license, can stop what an earlier one left running. Each runner instance has its own temp folder, so jobs that + * share a self-hosted machine never see each other's records. + */ + +/** A process unity-cli started. */ +export interface OwnedRoot { + pid: number; + /** Milliseconds since the epoch, taken just before the process was spawned. */ + startedAt: number; +} + +/** A running process with the details ownership is decided by. */ +export interface ProcessDetails extends ProcInfo { + /** The process group id; macOS and Linux only. */ + pgid?: number; + /** Milliseconds since the epoch; Windows only. */ + createdAt?: number; + /** The executable path when the platform reports it. */ + executablePath?: string | undefined; + /** The full command line. */ + commandLine: string; +} + +const logger = Logger.instance; + +/** Options that make a spawned process lead its own process group on macOS and Linux. */ +export function OwnedSpawnOptions(): { detached: boolean } { + return { detached: process.platform !== 'win32' }; +} + +function stateFilePath(): string { + return path.join(GetTempDir(), 'unity-cli-owned-processes.json'); +} + +/** The processes recorded by this job's earlier and current commands. */ +export function ReadOwnedRoots(): OwnedRoot[] { + try { + const roots = JSON.parse(fs.readFileSync(stateFilePath(), 'utf-8')); + return Array.isArray(roots) + ? roots.filter((root): root is OwnedRoot => Number.isInteger(root?.pid) && Number.isFinite(root?.startedAt)) + : []; + } catch { + return []; + } +} + +/** + * Records a process this command started. Only done in CI, where cleanup runs. + * @param child The spawned process, started with {@link OwnedSpawnOptions}. + * @param startedAt When it was spawned, in milliseconds since the epoch. + */ +export function TrackOwnedProcess(child: ChildProcess, startedAt: number): void { + if (process.env.CI !== 'true' || !child.pid) { + return; + } + + try { + const roots = ReadOwnedRoots().filter(root => root.pid !== child.pid); + roots.push({ pid: child.pid, startedAt }); + fs.writeFileSync(stateFilePath(), JSON.stringify(roots), { encoding: 'utf-8', mode: 0o600 }); + } catch (error) { + logger.debug(`Failed to record process ${child.pid}: ${error}`); + } +} + +/** Lists running processes with their group, creation time and command line. */ +export async function ListProcesses(): Promise { + const results: ProcessDetails[] = []; + + try { + if (process.platform === 'win32') { + const output = await Exec('powershell', [ + '-NoProfile', + '-Command', + 'Get-CimInstance Win32_Process | ForEach-Object { $c = if ($_.CreationDate) { ([DateTimeOffset]$_.CreationDate).ToUnixTimeMilliseconds() } else { 0 }; "$($_.ProcessId)`t$($_.ParentProcessId)`t$c`t$($_.Name)`t$($_.ExecutablePath)`t$($_.CommandLine)" }' + ], { silent: true, showCommand: false }); + + for (const line of output.split(/\r?\n/)) { + const [pid, ppid, created, name, executable, ...commandLine] = line.split('\t'); + + if (pid && ppid && /^\d+$/.test(pid)) { + results.push({ + pid: parseInt(pid, 10), + ppid: parseInt(ppid, 10), + createdAt: parseInt(created ?? '0', 10) || 0, + name: name ?? '', + executablePath: executable || undefined, + commandLine: commandLine.join('\t'), + }); + } + } + } else { + // etime is the elapsed time as [[dd-]hh:]mm:ss, which both macOS and Linux ps report + const output = await Exec('ps', ['-axo', 'pid=,ppid=,pgid=,etime=,args='], { silent: true, showCommand: false }); + const now = Date.now(); + + for (const line of output.split('\n')) { + const match = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+([\d:-]+)\s+(.*)$/.exec(line); + + if (match) { + results.push({ + pid: parseInt(match[1]!, 10), + ppid: parseInt(match[2]!, 10), + pgid: parseInt(match[3]!, 10), + createdAt: now - ParseElapsedSeconds(match[4]!) * 1000, + name: match[5]!, + commandLine: match[5]!, + }); + } + } + } + } catch (error) { + logger.debug(`Failed to list processes: ${error}`); + } + + return results.filter(proc => proc.pid !== process.pid); +} + +/** + * The running processes that belong to the given roots: on macOS and Linux every member of their process groups, on + * Windows the roots and their descendants created after the root started. + * @param roots The processes unity-cli started. + * @param processes A process list from {@link ListProcesses}. + */ +export function SelectOwnedProcesses(roots: OwnedRoot[], processes: ProcessDetails[]): ProcessDetails[] { + if (roots.length === 0) { + return []; + } + + // a dead process's id can be reused by another job, so a member must be no older than the root it belongs to; + // ps reports whole seconds, hence the slack + const earliest = Math.min(...roots.map(root => root.startedAt)) - 2000; + + if (process.platform !== 'win32') { + const groups = new Map(roots.map(root => [root.pid, root.startedAt - 2000] as const)); + return processes.filter(proc => proc.pgid !== undefined && groups.has(proc.pgid) && + (proc.createdAt === undefined || proc.createdAt >= groups.get(proc.pgid)!)); + } + const owned = new Map(); + let frontier = new Set(roots.map(root => root.pid)); + + for (const proc of processes) { + if (frontier.has(proc.pid) && (proc.createdAt ?? 0) >= earliest) { + owned.set(proc.pid, proc); + } + } + + while (frontier.size > 0) { + const next = new Set(); + + for (const proc of processes) { + if (!owned.has(proc.pid) && frontier.has(proc.ppid) && (proc.createdAt ?? 0) >= earliest) { + owned.set(proc.pid, proc); + next.add(proc.pid); + } + } + + frontier = next; + } + + return [...owned.values()]; +} + +/** + * Converts a ps elapsed time, [[dd-]hh:]mm:ss, to seconds. + * @param elapsed The etime value. + */ +export function ParseElapsedSeconds(elapsed: string): number { + const [days, clock] = elapsed.includes('-') ? elapsed.split('-', 2) as [string, string] : ['0', elapsed]; + const parts = clock.split(':').map(part => parseInt(part, 10) || 0); + + while (parts.length < 3) { + parts.unshift(0); + } + + return (parseInt(days, 10) || 0) * 86400 + parts[0]! * 3600 + parts[1]! * 60 + parts[2]!; +} + +/** The running processes this job's unity-cli commands started, and their helpers. */ +export async function FindOwnedProcesses(): Promise { + const roots = ReadOwnedRoots(); + return roots.length === 0 ? [] : SelectOwnedProcesses(roots, await ListProcesses()); +} + +/** + * Whether a process's executable is the named one, e.g. Unity.Licensing.Client. + * @param proc The process. + * @param name The executable file name, without .exe. + */ +export function IsExecutableNamed(proc: ProcessDetails, name: string): boolean { + const target = name.toLowerCase(); + const executable = (proc.executablePath ?? proc.commandLine.split(' ')[0] ?? proc.name).toLowerCase(); + return path.basename(executable).replace(/\.exe$/, '') === target || proc.name.toLowerCase().replace(/\.exe$/, '') === target; +} + +/** + * Whether a path lies inside a directory, compared on whole path segments. + * @param candidate The path to check. + * @param directory The directory. + */ +export function IsPathUnder(candidate: string, directory: string): boolean { + const normalize = (value: string) => { + const resolved = path.resolve(value); + return process.platform === 'win32' || process.platform === 'darwin' ? resolved.toLowerCase() : resolved; + }; + const root = normalize(directory); + const target = normalize(candidate); + return target === root || target.startsWith(root.endsWith(path.sep) ? root : root + path.sep); +} diff --git a/src/unity-cli.ts b/src/unity-cli.ts index 2246bb3b..628b2c07 100644 --- a/src/unity-cli.ts +++ b/src/unity-cli.ts @@ -11,6 +11,8 @@ import { Sha256FileHex, redactSensitiveLiterals, } from './utilities'; +import { OwnedSpawnOptions, TrackOwnedProcess } from './process-tracker'; +import { compare as compareSemver, valid } from 'semver'; const CDN_BASE = 'https://public-cdn.cloud.unity3d.com/hub/prod/cli/'; const TOOL_NAME = 'unity-cli'; @@ -87,7 +89,8 @@ export class UnityCli { } /** - * Finds an installed official Unity CLI from UNITY_CLI_PATH, the PATH, the tool cache, or the install script's default location. + * Finds an installed official Unity CLI from UNITY_CLI_PATH, the tool cache, the install script's default location, or the PATH. + * The PATH comes last, since other programs named unity live there, such as the Unity7 shell on Ubuntu desktops. */ public static async Find(): Promise { const candidates: string[] = []; @@ -96,15 +99,15 @@ export class UnityCli { candidates.push(process.env.UNITY_CLI_PATH); } + candidates.push(...findCachedExecutables()); + candidates.push(...defaultInstallLocations()); + for (const dir of (process.env.PATH ?? '').split(path.delimiter)) { if (dir && dir.length > 0) { candidates.push(path.join(dir, EXE_NAME)); } } - candidates.push(...findCachedExecutables()); - candidates.push(...defaultInstallLocations()); - for (const candidate of candidates) { // only ever run a binary named unity(.exe), even when UNITY_CLI_PATH points elsewhere if (path.basename(candidate).toLowerCase() !== EXE_NAME || !isExecutableFile(candidate)) { @@ -129,7 +132,22 @@ export class UnityCli { public static async Install(version: string = 'latest'): Promise { configureEnvironment(); const requested = normalizeVersion(version); - const manifest = await fetchManifest(requested); + let manifest: UnityCliManifest; + + try { + manifest = await fetchManifest(requested); + } catch (error) { + // with no network, the newest CLI already in the tool cache still does for latest + const cachedCli = requested === 'latest' ? await findNewestCachedCli() : undefined; + + if (!cachedCli) { + throw error; + } + + Logger.instance.warn(`Could not resolve the latest Unity CLI, using the cached ${cachedCli.version}.\n${error}`); + return exportCli(cachedCli); + } + const platformKey = `${process.platform}-${process.arch}`; const binary = manifest.binaries[platformKey]; @@ -208,11 +226,15 @@ export class UnityCli { let stdout = ''; let stderr = ''; let timedOut = false; + const startedAt = Date.now(); const child = spawn(this.executable, args, { shell: false, stdio: ['ignore', 'pipe', 'pipe'], env: process.env, + ...OwnedSpawnOptions(), }); + // helpers the CLI leaves running, such as its licensing client, stay reachable through this record + TrackOwnedProcess(child, startedAt); const timer = options.timeoutMs ? setTimeout(() => { timedOut = true; @@ -222,24 +244,35 @@ export class UnityCli { const onSignal = () => child.kill('SIGTERM'); process.once('SIGINT', onSignal); process.once('SIGTERM', onSignal); - const writeLines = (chunk: string) => { - if (!options.stream) { return; } - for (const line of chunk.split(/\r?\n/)) { - // the CLI prints a plugin hint to stderr on every run - if (line.length > 0 && !line.includes(' { + // the CLI prints a plugin hint to stderr on every run + if (line.length > 0 && !line.includes(' { + if (!options.stream) { return; } + const lines = (pending[stream] + chunk).split(/\r?\n/); + pending[stream] = lines.pop() ?? ''; + lines.forEach(writeLine); + }; + const flushLines = () => { + if (!options.stream) { return; } + writeLine(pending.stdout); + writeLine(pending.stderr); + pending.stdout = pending.stderr = ''; + }; child.stdout.on('data', (data: Buffer) => { const chunk = data.toString(); stdout += chunk; - writeLines(chunk); + writeLines('stdout', chunk); }); child.stderr.on('data', (data: Buffer) => { const chunk = data.toString(); stderr += chunk; - writeLines(chunk); + writeLines('stderr', chunk); }); const cleanup = () => { if (timer) { clearTimeout(timer); } @@ -252,6 +285,7 @@ export class UnityCli { }); child.on('close', (code) => { cleanup(); + flushLines(); const exitCode = timedOut ? 14 : (code ?? 1); this.logger.debug(`unity exited with code ${exitCode}`); resolve({ exitCode, stdout: redact(stdout), stderr: redact(stderr) }); @@ -482,7 +516,9 @@ async function getCliVersion(cliPath: string): Promise { throw new Error(`${cliPath} --version failed with exit code ${exitCode}: ${stripHint(stderr).trim()}`); } - const match = stdout.match(/v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/); + // the official CLI prints only its version; another program named unity prints more, such as "unity 7.7.0" + const lastLine = stdout.split(/\r?\n/).map(line => line.trim()).filter(line => line.length > 0).pop() ?? ''; + const match = /^v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/.exec(lastLine); if (!match || !match[1]) { throw new Error(`Unable to parse Unity CLI version from: ${stdout.trim()}`); @@ -552,13 +588,30 @@ function findCachedExecutables(): string[] { return []; } + // newest first by semver, so 1.0.0-beta.14 sorts ahead of 1.0.0-beta.9 return fs.readdirSync(toolDir) - .filter(version => fs.existsSync(path.join(toolDir, version, `${os.arch()}.complete`))) - .sort() - .reverse() + .filter(version => valid(version) !== null && fs.existsSync(path.join(toolDir, version, `${os.arch()}.complete`))) + .sort((a, b) => compareSemver(b, a)) .map(version => path.join(toolDir, version, os.arch(), EXE_NAME)); } +/** The newest complete CLI in the tool cache that still runs, if any. */ +async function findNewestCachedCli(): Promise { + for (const candidate of findCachedExecutables()) { + if (!isExecutableFile(candidate)) { + continue; + } + + try { + return new UnityCli(candidate, await getCliVersion(candidate)); + } catch (error) { + Logger.instance.debug(`Skipping ${candidate}: ${error}`); + } + } + + return undefined; +} + function defaultInstallLocations(): string[] { if (process.platform === 'win32') { return [path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'Unity', 'bin', EXE_NAME)]; diff --git a/src/unity-editor.ts b/src/unity-editor.ts index 1c92c923..c2978d84 100644 --- a/src/unity-editor.ts +++ b/src/unity-editor.ts @@ -11,12 +11,19 @@ import { GetArgumentValueAsString, ProcInfo, KillProcess, - KillChildProcesses, Exec, - FindProcessesUnder, redactSensitiveLiterals, } from './utilities'; import { UnityCli } from './unity-cli'; +import { + IsExecutableNamed, + IsPathUnder, + ListProcesses, + OwnedRoot, + OwnedSpawnOptions, + SelectOwnedProcesses, + TrackOwnedProcess, +} from './process-tracker'; import { TailLogFile, LogTailResult, @@ -172,22 +179,38 @@ export class UnityEditor { let editorLogPath: string | undefined = undefined; const editorRootPath = this.editorRootPath; - // helpers the editor starts from its own folder (licensing client, shader compiler, bee_backend) outlive it as - // orphans, out of reach of a parent-pid search; anything new under the editor folder after the run is ours - const preexistingEditorProcesses = new Set((await FindProcessesUnder(editorRootPath)).map(proc => proc.pid)); + let runStartedAt = 0; + let runPaths: string[] = []; + // the helpers the editor starts (shader compiler, bee_backend, licensing client) outlive it as orphans, so the + // sweep looks for this run's own process group (macOS, Linux) or descendants (Windows), never the whole machine: + // on a shared self-hosted runner another job's editor may run from the same install async function tryKillEditorProcesses(): Promise { try { - if (procInfo) { - await KillProcess(procInfo); - await KillChildProcesses(procInfo); + if (!procInfo) { + return; + } + + const root: OwnedRoot = { pid: procInfo.pid, startedAt: runStartedAt }; + const processes = await ListProcesses(); + const owned = new Map(SelectOwnedProcesses([root], processes).map(proc => [proc.pid, proc] as const)); + + if (process.platform === 'win32') { + // a helper whose parent already exited can no longer be traced to the run, so on Windows an + // editor-folder process started during this run that names this run's project or log counts too + for (const proc of processes) { + if ((proc.createdAt ?? 0) >= runStartedAt - 2000 && + proc.executablePath && IsPathUnder(proc.executablePath, editorRootPath) && + runPaths.some(runPath => proc.commandLine.toLowerCase().includes(runPath.toLowerCase()))) { + owned.set(proc.pid, proc); + } + } } // the licensing client is left alone: it may still be saving the license it just updated, and killing it // then loses the license for the next run; return-license stops it at the end of the job instead - const leftovers = (await FindProcessesUnder(editorRootPath)) - .filter(proc => !preexistingEditorProcesses.has(proc.pid) && !/licensing/i.test(proc.name)); - await Promise.all(leftovers.map(proc => KillProcess(proc))); + const leftovers = [...owned.values()].filter(proc => !IsExecutableNamed(proc, 'Unity.Licensing.Client')); + await Promise.all(leftovers.map(proc => KillProcess(proc).catch(() => undefined))); } catch (error) { Logger.instance.error(`Failed to kill Unity process: ${error}`); } @@ -267,6 +290,8 @@ export class UnityEditor { }; const unityCli = allowUnityRun ? await this.getUnityCliForRun(command) : undefined; + runPaths = [logPath, ...(command.projectPath ? [command.projectPath] : [])]; + runStartedAt = Date.now(); if (unityCli) { const runArgs = UnityEditor.GetUnityRunArgs(this.editorPath, command.projectPath!, logPath, command.args); @@ -276,6 +301,7 @@ export class UnityEditor { unityCli.executable, runArgs, { stdio: ['ignore', 'pipe', 'pipe'], + ...OwnedSpawnOptions(), env: baseEditorEnv }); const collect = (data: Buffer) => { unityRunOutput += data.toString(); }; @@ -289,6 +315,7 @@ export class UnityEditor { 'xvfb-run', [this.editorPath, ...command.args], { stdio: ['ignore', 'ignore', 'ignore'], + ...OwnedSpawnOptions(), env: { ...baseEditorEnv, DISPLAY: baseEditorEnv.DISPLAY || ':99' @@ -302,6 +329,7 @@ export class UnityEditor { 'arch', ['-x86_64', this.editorPath, ...command.args], { stdio: ['ignore', 'ignore', 'ignore'], + ...OwnedSpawnOptions(), env: baseEditorEnv }); } else { @@ -309,6 +337,7 @@ export class UnityEditor { this.editorPath, command.args, { stdio: ['ignore', 'ignore', 'ignore'], + ...OwnedSpawnOptions(), env: baseEditorEnv }); } @@ -320,6 +349,9 @@ export class UnityEditor { process.once('SIGINT', onCancel); process.once('SIGTERM', onCancel); procInfo = { pid: unityProcess.pid, ppid: process.pid, name: this.editorPath }; + // the licensing client the editor starts is left running after the run; recording the editor lets + // return-license find and stop it at the end of the job + TrackOwnedProcess(unityProcess, runStartedAt); this.logger.debug(`Unity process started with pid: ${procInfo.pid}`); exitCode = await new Promise((resolve, reject) => { // the editor can start a licensing client that outlives it and inherits unity run's piped @@ -385,6 +417,9 @@ export class UnityEditor { } else if (exitCode !== 0) { throw Error(`Unity failed with exit code ${exitCode}`); } + } else { + // a cancelled run did not finish, so it must not end the command with exit code 0 + throw Error('The Unity run was cancelled'); } } } diff --git a/src/utilities.ts b/src/utilities.ts index c6461b95..f9b7e8dc 100644 --- a/src/utilities.ts +++ b/src/utilities.ts @@ -441,31 +441,54 @@ export async function Sha256FileHex(filePath: string): Promise { export async function DownloadFile(url: string, downloadPath: string, options: DownloadOptions = {}): Promise { logger.ci(`Downloading from ${url} to ${downloadPath}...`); await fs.promises.mkdir(path.dirname(downloadPath), { recursive: true }); + // installers move between hosts, so HTTPS redirects are followed; every download is checked against a pinned + // SHA-256 or Releases API integrity value afterwards, so where it was served from does not change what is accepted + let currentUrl = url; + let redirects = 0; await new Promise((resolve, reject) => { - const request = https.get(url, { timeout: DOWNLOAD_IDLE_TIMEOUT_MS }, (response) => { - if (response.statusCode !== 200) { - response.resume(); - reject(new Error(`GET ${url} failed: HTTP ${response.statusCode}`)); - return; - } - const file = fs.createWriteStream(downloadPath, { mode: options.executable ? 0o755 : 0o644 }); - const fail = (err: Error) => { - file.destroy(); + const get = () => { + const request = https.get(currentUrl, { timeout: DOWNLOAD_IDLE_TIMEOUT_MS }, (response) => { + const location = response.headers.location; + + if (response.statusCode && [301, 302, 303, 307, 308].includes(response.statusCode) && location) { + response.resume(); + const next = new URL(location, currentUrl); + + if (next.protocol !== 'https:' || ++redirects > 5) { + reject(new Error(`GET ${url} failed: ${next.protocol !== 'https:' ? `redirected to ${next.protocol}` : 'too many redirects'}`)); + return; + } + + currentUrl = next.href; + get(); + return; + } + + if (response.statusCode !== 200) { + response.resume(); + reject(new Error(`GET ${url} failed: HTTP ${response.statusCode}`)); + return; + } + const file = fs.createWriteStream(downloadPath, { mode: options.executable ? 0o755 : 0o644 }); + const fail = (err: Error) => { + file.destroy(); + void fs.promises.unlink(downloadPath).catch(() => undefined); + reject(err); + }; + response.once('error', fail); + file.once('error', fail); + response.pipe(file); + file.on('finish', () => { + file.close(() => resolve()); + }); + }); + request.on('timeout', () => request.destroy(new Error(`GET ${url} timed out`))); + request.on('error', (error) => { void fs.promises.unlink(downloadPath).catch(() => undefined); - reject(err); - }; - response.once('error', fail); - file.once('error', fail); - response.pipe(file); - file.on('finish', () => { - file.close(() => resolve()); + reject(error); }); - }); - request.on('timeout', () => request.destroy(new Error(`GET ${url} timed out`))); - request.on('error', (error) => { - void fs.promises.unlink(downloadPath).catch(() => undefined); - reject(error); - }); + }; + get(); }); await new Promise((r) => setTimeout(r, 100)); @@ -535,48 +558,6 @@ export function SanitizeLogText(text: string, secrets: readonly string[] = []): .join(''); } -/** - * Lists running processes whose executable file name is `name` (e.g. Unity.Licensing.Client), wherever it lives. - * @param name The executable file name, without .exe. - */ -export async function FindProcessesByName(name: string): Promise { - const results: ProcInfo[] = []; - const target = name.toLowerCase(); - - try { - if (process.platform === 'win32') { - const output = await Exec('powershell', [ - '-NoProfile', - '-Command', - 'Get-CimInstance Win32_Process | ForEach-Object { "$($_.ProcessId)`t$($_.ParentProcessId)`t$($_.Name)" }' - ], { silent: true, showCommand: false }); - - for (const line of output.split(/\r?\n/)) { - const [pid, ppid, executable] = line.split('\t'); - - if (pid && ppid && executable && executable.toLowerCase().replace(/\.exe$/, '') === target) { - results.push({ pid: parseInt(pid, 10), ppid: parseInt(ppid, 10), name: executable }); - } - } - } else { - const output = await Exec('ps', ['-axo', 'pid=,ppid=,comm='], { silent: true, showCommand: false }); - - for (const line of output.split('\n')) { - const match = line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/); - - // comm is the executable path on macOS and a name cut to 15 characters on Linux - if (match && (path.basename(match[3]!).toLowerCase() === target || target.startsWith(match[3]!.toLowerCase()) && match[3]!.length >= 15)) { - results.push({ pid: parseInt(match[1]!, 10), ppid: parseInt(match[2]!, 10), name: match[3]! }); - } - } - } - } catch (error) { - logger.debug(`Failed to list processes named ${name}: ${error}`); - } - - return results.filter(proc => proc.pid !== process.pid); -} - /** * Deletes a directory and its contents if it exists. * @param targetPath The path of the directory to delete. @@ -806,89 +787,6 @@ export async function KillProcess(procInfo: ProcInfo, signal: NodeJS.Signals = ' } } -/** - * Kills all child processes of the given process. - * @param procInfo The process information of the parent process. - */ -export async function KillChildProcesses(procInfo: ProcInfo): Promise { - logger.debug(`Killing child processes of [${procInfo.pid}] ${procInfo.name}...`); - try { - if (process.platform === 'win32') { - const command = `Get-CimInstance Win32_Process -Filter "ParentProcessId=${procInfo.pid}" | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }`; - await Exec('powershell', [ - '-NoProfile', - '-Command', - command - ], { silent: true, showCommand: false }); - } else { // linux and macos - const psOutput = await Exec('ps', ['-eo', 'pid,ppid,comm'], { silent: true, showCommand: false }); - const lines = psOutput.split('\n').slice(1); // Skip header line - const killPromises: Promise[] = []; - - for (const line of lines) { - const match = line.trim().match(/^(\d+)\s+(\d+)\s+(.*)$/); - - if (match) { - const pid: number = parseInt(match[1]!, 10); - const ppid: number = parseInt(match[2]!, 10); - const name: string = match[3]!.trim(); - - if (ppid === procInfo.pid) { - killPromises.push(KillProcess({ pid, ppid, name })); - } - } - } - - await Promise.all(killPromises); - } - } catch (error) { - logger.error(`Failed to kill child processes of pid ${procInfo.pid}:\n${JSON.stringify(error)}`); - } -} - -/** - * Lists running processes whose executable lives under a directory, such as an editor's own helpers - * (Unity.Licensing.Client, UnityShaderCompiler, bee_backend and the like). - * @param directory The directory the executables live under. - * @returns The matching processes. - */ -export async function FindProcessesUnder(directory: string): Promise { - const root = path.resolve(directory).toLowerCase(); - const results: ProcInfo[] = []; - - try { - if (process.platform === 'win32') { - const output = await Exec('powershell', [ - '-NoProfile', - '-Command', - 'Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath } | ForEach-Object { "$($_.ProcessId)`t$($_.ParentProcessId)`t$($_.ExecutablePath)" }' - ], { silent: true, showCommand: false }); - - for (const line of output.split(/\r?\n/)) { - const [pid, ppid, executable] = line.split('\t'); - - if (pid && ppid && executable && path.resolve(executable).toLowerCase().startsWith(root)) { - results.push({ pid: parseInt(pid, 10), ppid: parseInt(ppid, 10), name: executable }); - } - } - } else { - const output = await Exec('ps', ['-axo', 'pid=,ppid=,args='], { silent: true, showCommand: false }); - - for (const line of output.split('\n')) { - const match = line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/); - - if (match && match[3]!.toLowerCase().startsWith(root)) { - results.push({ pid: parseInt(match[1]!, 10), ppid: parseInt(match[2]!, 10), name: match[3]! }); - } - } - } - } catch (error) { - logger.debug(`Failed to list processes under ${directory}: ${error}`); - } - - return results.filter(proc => proc.pid !== process.pid); -} - /** * Checks if the current process is running with elevated (administrator) privileges. * @returns True if the process is elevated, false otherwise. @@ -913,4 +811,4 @@ export function tryParseJson(content: string | undefined): string | undefined { } catch { return undefined; } -} \ No newline at end of file +} diff --git a/tests/process-tracker.test.ts b/tests/process-tracker.test.ts new file mode 100644 index 00000000..dbaabad2 --- /dev/null +++ b/tests/process-tracker.test.ts @@ -0,0 +1,77 @@ +import * as path from 'path'; +import { IsExecutableNamed, IsPathUnder, ParseElapsedSeconds, ProcessDetails, SelectOwnedProcesses } from '../src/process-tracker'; + +describe('process tracker', () => { + const platform = process.platform; + const setPlatform = (value: NodeJS.Platform) => Object.defineProperty(process, 'platform', { value }); + + afterEach(() => setPlatform(platform)); + + const proc = (fields: Partial & { pid: number }): ProcessDetails => ({ + ppid: 1, + name: 'p', + commandLine: 'p', + ...fields, + }); + + it('selects only the members of owned process groups on macOS and Linux', () => { + setPlatform('linux'); + const processes = [ + proc({ pid: 100, pgid: 100, name: '/opt/unity/Editor/Unity' }), + proc({ pid: 101, ppid: 1, pgid: 100, name: '/opt/unity/Editor/Data/Tools/bee_backend' }), + proc({ pid: 200, pgid: 200, name: '/opt/unity/Editor/Unity' }), + proc({ pid: 201, ppid: 200, pgid: 200, name: '/opt/unity/Editor/Data/Tools/bee_backend' }), + ]; + + expect(SelectOwnedProcesses([{ pid: 100, startedAt: 0 }], processes).map(p => p.pid)).toEqual([100, 101]); + }); + + it('selects descendants created after the root on Windows, skipping a reused parent id', () => { + setPlatform('win32'); + const processes = [ + proc({ pid: 100, ppid: 4, createdAt: 10_000 }), + proc({ pid: 101, ppid: 100, createdAt: 11_000 }), + proc({ pid: 102, ppid: 101, createdAt: 12_000 }), + // created long before the run, so its parent id 100 belonged to an earlier process + proc({ pid: 300, ppid: 100, createdAt: 1_000 }), + proc({ pid: 200, ppid: 4, createdAt: 11_000 }), + ]; + + expect(SelectOwnedProcesses([{ pid: 100, startedAt: 10_000 }], processes).map(p => p.pid).sort()).toEqual([100, 101, 102]); + }); + + it('skips a reused group id on macOS and Linux', () => { + setPlatform('darwin'); + const processes = [ + proc({ pid: 101, pgid: 100, createdAt: 11_000 }), + // a group led by a later process that reused the root's pid, started before the root was recorded + proc({ pid: 102, pgid: 100, createdAt: 1_000 }), + ]; + + expect(SelectOwnedProcesses([{ pid: 100, startedAt: 10_000 }], processes).map(p => p.pid)).toEqual([101]); + }); + + it('parses ps elapsed times', () => { + expect(ParseElapsedSeconds('05')).toBe(5); + expect(ParseElapsedSeconds('01:05')).toBe(65); + expect(ParseElapsedSeconds('02:01:05')).toBe(7265); + expect(ParseElapsedSeconds('1-02:01:05')).toBe(93665); + }); + + it('selects nothing without roots', () => { + expect(SelectOwnedProcesses([], [proc({ pid: 1, pgid: 1 })])).toEqual([]); + }); + + it('compares paths on whole segments', () => { + const root = path.resolve('editors', '6000.0.1f1'); + expect(IsPathUnder(path.join(root, 'Editor', 'Unity'), root)).toBe(true); + expect(IsPathUnder(path.resolve('editors', '6000.0.1f1-arm64', 'Editor', 'Unity'), root)).toBe(false); + }); + + it('matches an executable by its file name', () => { + const client = proc({ pid: 1, name: 'Unity.Licensing.Client.exe', executablePath: 'C:\\Unity\\Unity.Licensing.Client.exe', commandLine: '"C:\\Unity\\Unity.Licensing.Client.exe" --namedPipe x' }); + const mention = proc({ pid: 2, name: 'pwsh.exe', executablePath: 'C:\\pwsh.exe', commandLine: 'pwsh -c Unity.Licensing.Client' }); + expect(IsExecutableNamed(client, 'Unity.Licensing.Client')).toBe(true); + expect(IsExecutableNamed(mention, 'Unity.Licensing.Client')).toBe(false); + }); +}); From e7151e077342b410c2647d1f4f012fa3a5f0471b Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Fri, 30 Oct 2026 02:42:23 -0400 Subject: [PATCH 57/60] address the remaining review findings --- .github/workflows/integration-tests.yml | 10 +++++- src/failure-markers.ts | 21 ++++++----- src/unity-installer.ts | 46 +++++++++++++++++++------ src/unity-project.ts | 14 +++++--- tests/failure-markers.test.ts | 4 ++- 5 files changed, 70 insertions(+), 25 deletions(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index cd276349..2ec470f5 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -85,7 +85,8 @@ jobs: # one check that stands for the whole run, so rows can be regrouped or renamed without touching required checks ci-result: needs: [utp-batch-contract, setup, validate, validate-negative-scenarios] - if: always() + # skipped with the rest on a draft, rather than green with nothing tested + if: ${{ always() && github.event.pull_request.draft != true }} runs-on: ubuntu-latest timeout-minutes: 5 permissions: {} @@ -93,6 +94,8 @@ jobs: - name: Check results env: RESULTS: ${{ join(needs.*.result, ' ') }} + SETUP_RESULT: ${{ needs.setup.result }} + VALIDATE_RESULT: ${{ needs.validate.result }} run: | echo "Job results: ${RESULTS}" for result in ${RESULTS}; do @@ -101,6 +104,11 @@ jobs: exit 1 fi done + # the matrix must actually have run: an empty or skipped matrix would otherwise pass with no tests + if [ "${SETUP_RESULT}" != "success" ] || [ "${VALIDATE_RESULT}" != "success" ]; then + echo "::error::No Unity rows ran (setup: ${SETUP_RESULT}, validate: ${VALIDATE_RESULT})" + exit 1 + fi timeline: needs: [setup, validate, validate-negative-scenarios] if: always() diff --git a/src/failure-markers.ts b/src/failure-markers.ts index e00ad088..9d1d6573 100644 --- a/src/failure-markers.ts +++ b/src/failure-markers.ts @@ -6,10 +6,12 @@ export const LICENSE_EXHAUSTED_MARKER = 'unity-license-exhausted'; /** Annotation title for a network or CDN failure a later rerun can clear. */ export const TRANSIENT_FAILURE_MARKER = 'unity-transient-failure'; -// literal alternations only, so matching stays linear on any message -// the licensing client's exit codes 17 and 19 cover a seat it could not take -const LICENSE_EXHAUSTED_PATTERN = /activations? limit|maximum number of activations|max activations|no free seats?|no available seats?|seat limit|0 free entitlements|Seat activation or deactivation failed|Acquiring license failed/i; -const TRANSIENT_PATTERN = /HTTP 5\d\d|Bad Gateway|Service Unavailable|Gateway Time-?out|ECONNRESET|ECONNABORTED|ETIMEDOUT|EAI_AGAIN|ECONNREFUSED|socket hang up/i; +// only phrases that name a seat or activation limit: the licensing client's generic "seat activation failed" and +// "acquiring license failed" also cover a wrong password or a revoked license, which a rerun would only repeat. +// Literal alternations only, so matching stays linear on any message. +const LICENSE_EXHAUSTED_PATTERN = /activations? limit|maximum number of activations|max activations|no free seats?|no available seats?|seat limit|0 free entitlements/i; +// a refused connection usually means a wrong host or port, which a rerun repeats, so it is not listed +const TRANSIENT_PATTERN = /HTTP 5\d\d|Bad Gateway|Service Unavailable|Gateway Time-?out|ECONNRESET|ECONNABORTED|ETIMEDOUT|EAI_AGAIN|socket hang up/i; /** * The marker for a failure a rerun after other jobs finish can clear, if the message names one. @@ -30,8 +32,9 @@ export function ClassifyFailure(message: string): string | undefined { } /** - * Logs a command failure. Failures a rerun can clear become an error annotation titled with their marker, - * which the rerun workflow looks for; every other failure is logged as before. + * Logs a command failure. Failures a rerun can clear get a notice annotation titled with their marker, which the + * rerun workflow looks for; every failure is then logged as an error as before. The marker is a notice because GitHub + * keeps only 10 error annotations per step, and a step that already reported editor errors would drop it. * @param error The error that ended the command. */ export function ReportFailure(error: unknown): void { @@ -39,8 +42,8 @@ export function ReportFailure(error: unknown): void { const marker = ClassifyFailure(message); if (marker) { - Logger.instance.annotate(LogLevel.ERROR, message, undefined, undefined, undefined, undefined, undefined, marker); - } else { - Logger.instance.error(message); + Logger.instance.annotate(LogLevel.INFO, `Retryable failure: ${message}`, undefined, undefined, undefined, undefined, undefined, marker); } + + Logger.instance.error(message); } diff --git a/src/unity-installer.ts b/src/unity-installer.ts index 192586cc..46bcce2e 100644 --- a/src/unity-installer.ts +++ b/src/unity-installer.ts @@ -28,11 +28,14 @@ const RETRYABLE_EXIT_CODES = [7, 9, 14]; */ const LINUX_EDITOR_DEPENDENCIES_SCRIPT = `#!/bin/sh set -e -if [ ! -f /etc/apt/sources.list.d/jammy.list ]; then - echo "deb https://archive.ubuntu.com/ubuntu jammy main universe" > /etc/apt/sources.list.d/jammy.list +# an Ubuntu jammy source only works on Ubuntu; a Debian or other apt host would break its own apt-get update +if grep -qs '^ID=ubuntu$' /etc/os-release; then + if [ ! -f /etc/apt/sources.list.d/jammy.list ]; then + echo "deb https://archive.ubuntu.com/ubuntu jammy main universe" > /etc/apt/sources.list.d/jammy.list + fi + # jammy only supplies packages the host release lacks; it never replaces the host's own packages + printf 'Package: *\\nPin: release n=jammy\\nPin-Priority: 100\\n' > /etc/apt/preferences.d/unity-cli-jammy fi -# jammy only supplies packages the host release lacks; it never replaces the host's own packages -printf 'Package: *\\nPin: release n=jammy\\nPin-Priority: 100\\n' > /etc/apt/preferences.d/unity-cli-jammy export DEBIAN_FRONTEND=noninteractive # a stalled mirror must not hold the job: apt retries slow requests, and each step has a hard limit APT_OPTS="-o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 -o Acquire::Retries=3 -o DPkg::Lock::Timeout=120" @@ -58,6 +61,14 @@ const PINNED_DOWNLOAD_SHA256: { [url: string]: string } = { }; /** Unity 5.x target support installer names, keyed by module id. */ +/** The PlaybackEngines folder each archive module installs into, where it differs from `Support`. */ +const ARCHIVE_PLAYBACK_ENGINE_FOLDERS: { [moduleName: string]: string } = { + 'Android': 'AndroidPlayer', + 'Linux': 'LinuxStandaloneSupport', + 'Mac': 'MacStandaloneSupport', + 'Windows': 'WindowsStandaloneSupport', +}; + const ARCHIVE_MODULE_NAMES: { [module: string]: string } = { 'android': 'Android', 'ios': 'iOS', @@ -390,7 +401,10 @@ export class UnityInstaller { } for (const moduleName of moduleNames) { - if (fs.existsSync(path.join(installDir, 'Editor', 'Data', 'PlaybackEngines', `${moduleName.toLowerCase()}support`))) { + const engineFolder = ARCHIVE_PLAYBACK_ENGINE_FOLDERS[moduleName] ?? `${moduleName}Support`; + + // Windows paths are case-insensitive, so the folder name's case does not matter here + if (fs.existsSync(path.join(installDir, 'Editor', 'Data', 'PlaybackEngines', engineFolder))) { continue; } @@ -741,16 +755,24 @@ export class UnityInstaller { } catch (error) { const code = (error as NodeJS.ErrnoException).code; - if (code === 'EEXIST' || code === 'ENOENT') { + if (code === 'ENOENT') { return; } - throw error; + if (code !== 'EEXIST') { + throw error; + } + + // patched before; only a run that stopped before the wrapper landed leaves .bee_backend without bee_backend + try { + await fs.promises.access(beeBackend); + return; + } catch { + this.logger.ci('Restoring the Bee Backend wrapper an earlier run did not finish...'); + } } this.logger.ci(`Patching Unity Linux Editor for Bee Backend...`); - await fs.promises.unlink(beeBackend); - // 'wx' fails instead of overwriting if something recreated the file since the unlink const wrapperSource: string = `#!/bin/bash # https://discussions.unity.com/t/linux-editor-stuck-on-loading-because-of-bee-backend-w-workaround/854480 set -e @@ -764,7 +786,11 @@ for ((i = 0; i < "\${#args[@]}"; ++i)); do esac done "$(dirname "$0")/.$(basename "$0")" "\${args[@]}"` - await fs.promises.writeFile(beeBackend, wrapperSource, { encoding: 'utf-8', mode: 0o755, flag: 'wx' }); + // written under a temporary name and renamed over bee_backend, so a stop at any point leaves either the + // original (still linked as .bee_backend) or the whole wrapper, never neither + const tempWrapper = path.join(dataPath, `.bee_backend.${randomUUID()}.tmp`); + await fs.promises.writeFile(tempWrapper, wrapperSource, { encoding: 'utf-8', mode: 0o755, flag: 'wx' }); + await fs.promises.rename(tempWrapper, beeBackend); } } diff --git a/src/unity-project.ts b/src/unity-project.ts index 96f35e90..111abc2e 100644 --- a/src/unity-project.ts +++ b/src/unity-project.ts @@ -106,7 +106,7 @@ export class UnityProject { continue; } - const target = UnityProject.readBuildProfileTarget(UnityProject.readSmallFile(entryPath)); + const target = UnityProject.readBuildProfileTarget(UnityProject.readBuildProfileAsset(entryPath)); if (target) { targets.add(target); @@ -117,14 +117,20 @@ export class UnityProject { return [...targets]; } - /** Reads a file of up to 1 MB through one open handle, so the size check and the read see the same file. Larger or unreadable files read as empty. */ - private static readSmallFile(filePath: string): string { + /** + * Reads an asset that may be a build profile through one open handle, so the checks and the read see the same file. + * Only the first kilobyte is read unless it names the BuildProfile script, so a large project's other assets are + * not read in full. Other, larger than 1 MB or unreadable files read as empty. + */ + private static readBuildProfileAsset(filePath: string): string { let fd: number | undefined; try { fd = fs.openSync(filePath, 'r'); + const head = Buffer.alloc(1024); + const headLength = fs.readSync(fd, head, 0, head.length, 0); - if (fs.fstatSync(fd).size > 1024 * 1024) { + if (!head.subarray(0, headLength).toString('utf-8').includes('fileID: 15003,') || fs.fstatSync(fd).size > 1024 * 1024) { return ''; } diff --git a/tests/failure-markers.test.ts b/tests/failure-markers.test.ts index 8bd1de0a..67834ba6 100644 --- a/tests/failure-markers.test.ts +++ b/tests/failure-markers.test.ts @@ -2,7 +2,6 @@ import { ClassifyFailure, LICENSE_EXHAUSTED_MARKER, TRANSIENT_FAILURE_MARKER } f describe('ClassifyFailure', () => { it.each([ - 'License command failed with exit code 17: Seat activation or deactivation failed', 'Machine activations limit reached for this account', 'Code 404 while processing request (status: Found 1 entitlement groups and 0 free entitlements matching requested entitlement ids)', ])('marks license exhaustion: %s', message => { @@ -23,6 +22,9 @@ describe('ClassifyFailure', () => { 'Unity failed with exit code 6', 'License command failed with exit code 2: Invalid credentials', 'GET https://example.com failed: HTTP 404', + 'License command failed with exit code 17: Seat activation or deactivation failed', + 'License command failed with exit code 19: Acquiring license failed', + 'connect ECONNREFUSED 127.0.0.1:443', ])('leaves other failures unmarked: %s', message => { expect(ClassifyFailure(message)).toBeUndefined(); }); From 7a8c3f9a83df86e821d7f3e67c832f996288cf8c Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Fri, 30 Oct 2026 03:45:10 -0400 Subject: [PATCH 58/60] mark unity id sign-in throttling as retryable and add a license-refresh run --- .../build-options-license-refresh.json | 19 +++++++++++++++++++ .github/workflows/integration-tests.yml | 9 +++++---- src/failure-markers.ts | 2 +- src/license-client.ts | 9 ++++++++- tests/failure-markers.test.ts | 1 + 5 files changed, 34 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/build-options-license-refresh.json diff --git a/.github/workflows/build-options-license-refresh.json b/.github/workflows/build-options-license-refresh.json new file mode 100644 index 00000000..5c8f73ee --- /dev/null +++ b/.github/workflows/build-options-license-refresh.json @@ -0,0 +1,19 @@ +{ + "os": [ + "ubuntu-latest", + "macos-latest" + ], + "unity-version": [ + "6000.5" + ], + "include": [ + { + "os": "ubuntu-latest", + "build-target": "StandaloneLinux64" + }, + { + "os": "macos-latest", + "build-target": "StandaloneOSX" + } + ] +} diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 2ec470f5..01e085dc 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -12,11 +12,12 @@ on: editors: description: Which editors to test type: choice - options: [unity-6-and-newer, legacy] + # license-refresh runs one macOS and one Linux row, which store fresh UNITY_LICENSE_* secrets with few sign-ins + options: [unity-6-and-newer, legacy, license-refresh] default: unity-6-and-newer concurrency: # the monthly legacy run gets its own group, so a push to main does not cancel it - group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name == 'schedule' || inputs.editors == 'legacy' }} + group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name == 'schedule' && 'legacy' || inputs.editors || 'unity-6-and-newer' }} cancel-in-progress: true jobs: utp-batch-contract: @@ -50,7 +51,7 @@ jobs: id: setup-jobs with: # pull requests and pushes test Unity 6 and newer; 4.7.2 through 2022.3 run monthly or on demand - build-options: ${{ (github.event_name == 'schedule' || inputs.editors == 'legacy') && './.github/workflows/build-options-legacy.json' || './.github/workflows/build-options.json' }} + build-options: ${{ (github.event_name == 'schedule' || inputs.editors == 'legacy') && './.github/workflows/build-options-legacy.json' || inputs.editors == 'license-refresh' && './.github/workflows/build-options-license-refresh.json' || './.github/workflows/build-options.json' }} # one group per editor version; every group runs at once, since macOS and Linux rows share one seat per OS # and Windows rows return theirs group-by: unity-version @@ -72,7 +73,7 @@ jobs: matrix: ${{ toJSON(matrix.jobs.matrix) }} utp-test-profile: normal validate-negative-scenarios: - if: github.event.pull_request.draft == false && github.event_name != 'schedule' && inputs.editors != 'legacy' + if: github.event.pull_request.draft == false && github.event_name != 'schedule' && inputs.editors != 'legacy' && inputs.editors != 'license-refresh' name: build negative-scenarios permissions: contents: read diff --git a/src/failure-markers.ts b/src/failure-markers.ts index 9d1d6573..35090c52 100644 --- a/src/failure-markers.ts +++ b/src/failure-markers.ts @@ -9,7 +9,7 @@ export const TRANSIENT_FAILURE_MARKER = 'unity-transient-failure'; // only phrases that name a seat or activation limit: the licensing client's generic "seat activation failed" and // "acquiring license failed" also cover a wrong password or a revoked license, which a rerun would only repeat. // Literal alternations only, so matching stays linear on any message. -const LICENSE_EXHAUSTED_PATTERN = /activations? limit|maximum number of activations|max activations|no free seats?|no available seats?|seat limit|0 free entitlements/i; +const LICENSE_EXHAUSTED_PATTERN = /activations? limit|maximum number of activations|max activations|no free seats?|no available seats?|seat limit|0 free entitlements|sign-in rate limit/i; // a refused connection usually means a wrong host or port, which a rerun repeats, so it is not listed const TRANSIENT_PATTERN = /HTTP 5\d\d|Bad Gateway|Service Unavailable|Gateway Time-?out|ECONNRESET|ECONNABORTED|ETIMEDOUT|EAI_AGAIN|socket hang up/i; diff --git a/src/license-client.ts b/src/license-client.ts index d6ebf8a2..31c5e2d5 100644 --- a/src/license-client.ts +++ b/src/license-client.ts @@ -741,7 +741,14 @@ export class LicensingClient { this.logger.endGroup(); if (exitCode !== 0) { - const message = timedOut ? 'Timed out' : killedBy ? `Killed by ${killedBy}` : this.getExitCodeMessage(exitCode); + let message = timedOut ? 'Timed out' : killedBy ? `Killed by ${killedBy}` : this.getExitCodeMessage(exitCode); + + // Unity ID throttles sign-ins when many jobs activate with one account at once; naming it lets the + // failure be marked for a rerun once those jobs finish + if (/maximum same user attempt retry count/i.test(output)) { + message = 'Unity ID sign-in rate limit reached: too many sign-in attempts for this account'; + } + throw new Error(`License command failed with exit code ${exitCode}: ${message}`); } } diff --git a/tests/failure-markers.test.ts b/tests/failure-markers.test.ts index 67834ba6..22697d6b 100644 --- a/tests/failure-markers.test.ts +++ b/tests/failure-markers.test.ts @@ -3,6 +3,7 @@ import { ClassifyFailure, LICENSE_EXHAUSTED_MARKER, TRANSIENT_FAILURE_MARKER } f describe('ClassifyFailure', () => { it.each([ 'Machine activations limit reached for this account', + 'License command failed with exit code 26: Unity ID sign-in rate limit reached: too many sign-in attempts for this account', 'Code 404 while processing request (status: Found 1 entitlement groups and 0 free entitlements matching requested entitlement ids)', ])('marks license exhaustion: %s', message => { expect(ClassifyFailure(message)).toBe(LICENSE_EXHAUSTED_MARKER); From d5c059b5e659bb31ce64a9518312251c8be1338b Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Fri, 30 Oct 2026 04:00:57 -0400 Subject: [PATCH 59/60] activate one license per os per run and pass it to the build rows encrypted instead of caching it in secrets --- .../build-options-license-refresh.json | 19 ---- .github/workflows/integration-tests.yml | 89 +++++++++++++++++-- .github/workflows/unity-build.yml | 63 +++++++------ 3 files changed, 113 insertions(+), 58 deletions(-) delete mode 100644 .github/workflows/build-options-license-refresh.json diff --git a/.github/workflows/build-options-license-refresh.json b/.github/workflows/build-options-license-refresh.json deleted file mode 100644 index 5c8f73ee..00000000 --- a/.github/workflows/build-options-license-refresh.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "os": [ - "ubuntu-latest", - "macos-latest" - ], - "unity-version": [ - "6000.5" - ], - "include": [ - { - "os": "ubuntu-latest", - "build-target": "StandaloneLinux64" - }, - { - "os": "macos-latest", - "build-target": "StandaloneOSX" - } - ] -} diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 01e085dc..9861ddd0 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -12,8 +12,7 @@ on: editors: description: Which editors to test type: choice - # license-refresh runs one macOS and one Linux row, which store fresh UNITY_LICENSE_* secrets with few sign-ins - options: [unity-6-and-newer, legacy, license-refresh] + options: [unity-6-and-newer, legacy] default: unity-6-and-newer concurrency: # the monthly legacy run gets its own group, so a push to main does not cancel it @@ -51,15 +50,72 @@ jobs: id: setup-jobs with: # pull requests and pushes test Unity 6 and newer; 4.7.2 through 2022.3 run monthly or on demand - build-options: ${{ (github.event_name == 'schedule' || inputs.editors == 'legacy') && './.github/workflows/build-options-legacy.json' || inputs.editors == 'license-refresh' && './.github/workflows/build-options-license-refresh.json' || './.github/workflows/build-options.json' }} + build-options: ${{ (github.event_name == 'schedule' || inputs.editors == 'legacy') && './.github/workflows/build-options-legacy.json' || './.github/workflows/build-options.json' }} # one group per editor version; every group runs at once, since macOS and Linux rows share one seat per OS # and Windows rows return theirs group-by: unity-version outputs: jobs: ${{ steps.setup-jobs.outputs.jobs }} + # One sign-in per OS for the whole run. macOS and Linux runners share one machine identity, so the license activated + # here serves every macOS or Linux row of this run, which then needs no sign-in of its own. It is passed on encrypted, + # because artifacts of a public repository can be downloaded by anyone, and it is never returned: a return would + # revoke it for every job on that identity, other repositories' included. license-cleanup deletes it after the run. + license: + if: github.event.pull_request.draft == false + name: license ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 24.x + cache: npm + - name: Setup unity-cli + run: | + npm ci + npm run build + npm run link + - name: Activate License + id: activate + shell: pwsh + env: + UNITY_USERNAME: ${{ secrets.UNITY_USERNAME }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + run: | + if (-not $env:UNITY_USERNAME -or -not $env:UNITY_PASSWORD) { + # pull requests from forks get no secrets; their rows fall back to their own activation + echo '::warning::No Unity credentials, so this run has no shared license' + exit 0 + } + unity-cli upm-install --json + unity-cli license-context + unity-cli activate-license --license personal + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $dir = Join-Path $env:RUNNER_TEMP 'unity-license' + New-Item -ItemType Directory -Force -Path $dir | Out-Null + # the key never reaches a command line: openssl reads it from the environment + $env:LICENSE_KEY = "$($env:UNITY_PASSWORD):$($env:GITHUB_RUN_ID)" + openssl enc -aes-256-cbc -pbkdf2 -iter 600000 -salt -in (unity-cli license-file-path) -out (Join-Path $dir 'unity-license.enc') -pass env:LICENSE_KEY + if ($LASTEXITCODE -ne 0) { throw 'Could not encrypt the run license' } + "encrypted=true" >> $env:GITHUB_OUTPUT + - uses: actions/upload-artifact@v7 + if: ${{ steps.activate.outputs.encrypted == 'true' }} + with: + name: unity-license-${{ runner.os }} + path: ${{ runner.temp }}/unity-license/unity-license.enc + retention-days: 1 + if-no-files-found: error validate: - if: ${{ needs.setup.outputs.jobs }} - needs: setup + # the rows still run when the license job fails, and activate with the account instead + if: ${{ !cancelled() && needs.setup.result == 'success' && needs.setup.outputs.jobs }} + needs: [setup, license] name: build ${{ matrix.jobs.name }} permissions: contents: read @@ -73,7 +129,8 @@ jobs: matrix: ${{ toJSON(matrix.jobs.matrix) }} utp-test-profile: normal validate-negative-scenarios: - if: github.event.pull_request.draft == false && github.event_name != 'schedule' && inputs.editors != 'legacy' && inputs.editors != 'license-refresh' + if: ${{ !cancelled() && github.event.pull_request.draft == false && github.event_name != 'schedule' && inputs.editors != 'legacy' }} + needs: license name: build negative-scenarios permissions: contents: read @@ -110,6 +167,26 @@ jobs: echo "::error::No Unity rows ran (setup: ${SETUP_RESULT}, validate: ${VALIDATE_RESULT})" exit 1 fi + license-cleanup: + needs: [license, validate, validate-negative-scenarios] + if: always() + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + actions: write # to delete this run's license artifacts + steps: + - name: Delete the run license + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + $ids = gh api "repos/$($env:GITHUB_REPOSITORY)/actions/runs/$($env:GITHUB_RUN_ID)/artifacts?per_page=100" --paginate --jq '.artifacts[] | select(.name | startswith("unity-license-")) | .id' + if ($LASTEXITCODE -ne 0) { throw 'Could not list the run artifacts' } + foreach ($id in $ids) { + gh api -X DELETE "repos/$($env:GITHUB_REPOSITORY)/actions/artifacts/$id" + if ($LASTEXITCODE -ne 0) { throw "Could not delete artifact $id" } + } + echo "Deleted $(@($ids).Count) license artifacts" timeline: needs: [setup, validate, validate-negative-scenarios] if: always() diff --git a/.github/workflows/unity-build.yml b/.github/workflows/unity-build.yml index 9cf9ca9a..1ddc33d1 100644 --- a/.github/workflows/unity-build.yml +++ b/.github/workflows/unity-build.yml @@ -14,12 +14,6 @@ on: required: true UNITY_PASSWORD: required: true - CI_TOKEN: - required: false - UNITY_LICENSE_MACOS: - required: false - UNITY_LICENSE_LINUX: - required: false jobs: build: # rows are grouped by editor version, so the name carries the OS and target @@ -174,16 +168,38 @@ jobs: echo "- path: \`${UNITY_EDITOR_PATH:-n/a}\`" } >> "$GITHUB_STEP_SUMMARY" fi - # macOS and Linux runners all report the same machine identity to Unity, so one license per OS serves every job: - # those rows activate from the license kept in the UNITY_LICENSE_MACOS / UNITY_LICENSE_LINUX repo secrets and never - # return it. When that secret is missing or no longer valid, the row activates with the account and stores the new - # license there with CI_TOKEN for the next run. Windows runners are a new machine every job, so Windows rows - # activate with the account and return the license. + # macOS and Linux runners all report the same machine identity to Unity, so the license the caller's license job + # activated once for this run serves every macOS or Linux row; it arrives as an encrypted artifact and is never + # returned. Without it, or when it no longer fits this machine, the row activates with the account. Windows runners + # are a new machine every job, so Windows rows activate with the account and return the license. + - name: Download run license + id: download-run-license + if: ${{ runner.os != 'Windows' && matrix.unity-version != 'none' }} + continue-on-error: true + uses: actions/download-artifact@v8 + with: + name: unity-license-${{ runner.os }} + path: ${{ runner.temp }}/unity-license + - name: Decrypt run license + id: run-license + if: ${{ steps.download-run-license.outcome == 'success' }} + continue-on-error: true + shell: pwsh + env: + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + run: | + $dir = Join-Path $env:RUNNER_TEMP 'unity-license' + $license = Join-Path $dir 'Unity_lic.ulf' + # the key never reaches a command line: openssl reads it from the environment + $env:LICENSE_KEY = "$($env:UNITY_PASSWORD):$($env:GITHUB_RUN_ID)" + openssl enc -d -aes-256-cbc -pbkdf2 -iter 600000 -in (Join-Path $dir 'unity-license.enc') -out $license -pass env:LICENSE_KEY + if ($LASTEXITCODE -ne 0) { throw 'Could not decrypt the run license' } + "path=$license" >> $env:GITHUB_OUTPUT - name: Activate License id: activate-license if: ${{ matrix.unity-version != 'none' }} env: - UNITY_LICENSE: ${{ runner.os == 'macOS' && secrets.UNITY_LICENSE_MACOS || runner.os == 'Linux' && secrets.UNITY_LICENSE_LINUX || '' }} + UNITY_LICENSE: ${{ steps.run-license.outputs.path }} # read by activate-license from the environment, so the credentials never sit on a command line UNITY_USERNAME: ${{ secrets.UNITY_USERNAME }} UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} @@ -191,31 +207,12 @@ jobs: unity-cli license-context major="${UNITY_EDITOR_VERSION%%.*}" # 4.x and 5.x editors need their own license version, so they always activate with the account - shared_identity=false - if [ "${RUNNER_OS}" != "Windows" ] && [ "${major}" -ge 2017 ]; then - shared_identity=true - else + if [ "${RUNNER_OS}" = "Windows" ] || [ "${major}" -lt 2017 ]; then unset UNITY_LICENSE fi - echo "shared-identity=${shared_identity}" >> "$GITHUB_OUTPUT" # activate-license writes license-source=license-file|account to this step's outputs unity-cli activate-license --license personal - - name: Store license secret - # only when a shared-identity row had to activate with the account, i.e. the stored license was missing or stale, - # and never in a pull_request run, so a token that can write secrets is only exposed to pushes and manual runs - if: ${{ github.event_name != 'pull_request' && steps.activate-license.outputs.shared-identity == 'true' && steps.activate-license.outputs.license-source == 'account' }} - continue-on-error: true - env: - GH_TOKEN: ${{ secrets.CI_TOKEN }} - run: | - if [ -z "${GH_TOKEN}" ]; then - echo "::warning::CI_TOKEN is not available, so the license is not stored for the next run" - exit 0 - fi - secret_name="UNITY_LICENSE_$(echo "${RUNNER_OS}" | tr '[:lower:]' '[:upper:]')" - # gh reads the value from stdin, so the license never appears on a command line or in the log - gh secret set "${secret_name}" --repo "${GITHUB_REPOSITORY}" < "$(unity-cli license-file-path)" - echo "Stored the license in ${secret_name}" + rm -rf "${RUNNER_TEMP}/unity-license" - name: Create Unity Project if: ${{ matrix.unity-version != 'none' }} run: | From febe63145c960b61e80cfe8b4166e447a35f7b6f Mon Sep 17 00:00:00 2001 From: StephenHodgson <13334553+StephenHodgson@users.noreply.github.com> Date: Fri, 30 Oct 2026 04:22:48 -0400 Subject: [PATCH 60/60] key the run license with ci_token instead of the unity password --- .github/workflows/integration-tests.yml | 9 ++++++--- .github/workflows/unity-build.yml | 7 +++++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 9861ddd0..1d09a3bb 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -88,10 +88,13 @@ jobs: env: UNITY_USERNAME: ${{ secrets.UNITY_USERNAME }} UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + # the encryption key comes from a random token, never the account password, so the public artifact gives + # nobody a way to test password guesses offline + CI_TOKEN: ${{ secrets.CI_TOKEN }} run: | - if (-not $env:UNITY_USERNAME -or -not $env:UNITY_PASSWORD) { + if (-not $env:UNITY_USERNAME -or -not $env:UNITY_PASSWORD -or -not $env:CI_TOKEN) { # pull requests from forks get no secrets; their rows fall back to their own activation - echo '::warning::No Unity credentials, so this run has no shared license' + echo '::warning::No Unity credentials or CI_TOKEN, so this run has no shared license' exit 0 } unity-cli upm-install --json @@ -101,7 +104,7 @@ jobs: $dir = Join-Path $env:RUNNER_TEMP 'unity-license' New-Item -ItemType Directory -Force -Path $dir | Out-Null # the key never reaches a command line: openssl reads it from the environment - $env:LICENSE_KEY = "$($env:UNITY_PASSWORD):$($env:GITHUB_RUN_ID)" + $env:LICENSE_KEY = "$($env:CI_TOKEN):$($env:GITHUB_RUN_ID)" openssl enc -aes-256-cbc -pbkdf2 -iter 600000 -salt -in (unity-cli license-file-path) -out (Join-Path $dir 'unity-license.enc') -pass env:LICENSE_KEY if ($LASTEXITCODE -ne 0) { throw 'Could not encrypt the run license' } "encrypted=true" >> $env:GITHUB_OUTPUT diff --git a/.github/workflows/unity-build.yml b/.github/workflows/unity-build.yml index 1ddc33d1..4d31d22d 100644 --- a/.github/workflows/unity-build.yml +++ b/.github/workflows/unity-build.yml @@ -14,6 +14,9 @@ on: required: true UNITY_PASSWORD: required: true + CI_TOKEN: + description: Keys the encrypted run license that macOS and Linux rows activate from. + required: false jobs: build: # rows are grouped by editor version, so the name carries the OS and target @@ -186,12 +189,12 @@ jobs: continue-on-error: true shell: pwsh env: - UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + CI_TOKEN: ${{ secrets.CI_TOKEN }} run: | $dir = Join-Path $env:RUNNER_TEMP 'unity-license' $license = Join-Path $dir 'Unity_lic.ulf' # the key never reaches a command line: openssl reads it from the environment - $env:LICENSE_KEY = "$($env:UNITY_PASSWORD):$($env:GITHUB_RUN_ID)" + $env:LICENSE_KEY = "$($env:CI_TOKEN):$($env:GITHUB_RUN_ID)" openssl enc -d -aes-256-cbc -pbkdf2 -iter 600000 -in (Join-Path $dir 'unity-license.enc') -out $license -pass env:LICENSE_KEY if ($LASTEXITCODE -ne 0) { throw 'Could not decrypt the run license' } "path=$license" >> $env:GITHUB_OUTPUT