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/scripts/unity-release-discovery.cjs b/.github/scripts/unity-release-discovery.cjs index 6c5d87e6..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 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. + * 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']; -const ISSUE_TITLE = 'unity-release-drift: matrix / canary vs Unity Releases API'; -const ISSUE_LABEL = 'unity-release-drift'; +const minors = ['6000.0', '6000.1', '6000.2', '6000.3', '6000.4', '6000.5', '6000.6', '6000.7', '6000.8', '7000.0', '7000.1']; 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() { @@ -36,17 +45,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 +55,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,19 +77,13 @@ 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 |'); 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( @@ -101,16 +93,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 +116,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,89 +128,13 @@ 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). - 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}\``), - '', - '### 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.' - : `${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/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 9178fb3a..35263ec7 100644 --- a/.github/workflows/build-options.json +++ b/.github/workflows/build-options.json @@ -5,20 +5,15 @@ "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.5", + "6000.6", + "6000.7", + "7000.0" ], "include": [ { @@ -66,34 +61,5 @@ "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)" - } ] -} \ No newline at end of file +} diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 469d69bc..1d09a3bb 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -5,19 +5,29 @@ 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' && 'legacy' || inputs.editors || 'unity-6-and-newer' }} cancel-in-progress: true jobs: utp-batch-contract: if: github.event.pull_request.draft == false runs-on: ubuntu-latest + timeout-minutes: 10 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 @@ -28,23 +38,87 @@ 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 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: sparse-checkout: .github/ - 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' }} + # 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 }} + # 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 -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 or CI_TOKEN, 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: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 + - 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 @@ -52,14 +126,14 @@ 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 validate-negative-scenarios: - if: github.event.pull_request.draft == false + if: ${{ !cancelled() && github.event.pull_request.draft == false && github.event_name != 'schedule' && inputs.editors != 'legacy' }} + needs: license name: build negative-scenarios permissions: contents: read @@ -67,14 +141,62 @@ 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] + # 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: {} + steps: + - 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 + if [ "${result}" = "failure" ] || [ "${result}" = "cancelled" ]; then + echo "::error::At least one job ended with ${result}" + 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 + 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() runs-on: ubuntu-latest + timeout-minutes: 5 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..7ae233ea 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -9,9 +9,10 @@ permissions: jobs: publish: runs-on: ubuntu-latest + timeout-minutes: 15 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" @@ -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/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 1f5192b3..4d31d22d 100644 --- a/.github/workflows/unity-build.yml +++ b/.github/workflows/unity-build.yml @@ -14,10 +14,16 @@ 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: - name: ${{ matrix.name }} + # 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 @@ -30,34 +36,66 @@ 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: + # 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@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 + 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: 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}" @@ -65,15 +103,20 @@ jobs: 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 + 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 }}" - --build-targets "${{ matrix.build-target }}" + --unity-version "${MATRIX_UNITY_VERSION}" + --build-targets "${MATRIX_BUILD_TARGET}" --json ) - CHANNEL="${{ matrix.channel }}" - CHANGESET="${{ matrix.changeset }}" + # 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}") fi @@ -82,26 +125,43 @@ jobs: fi "${setup_cmd[@]}" fi - - name: Verify UNITY_HUB_PATH and UNITY_EDITOR_PATH variables + - name: Collect Linux editor packages + id: linux-packages-collect + if: ${{ steps.linux-packages.outcome == 'success' && steps.linux-packages-cache.outputs.cache-hit != 'true' }} run: | - echo "UNITY_HUB_PATH: ${UNITY_HUB_PATH}" + 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}" + 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 + 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 "" @@ -111,24 +171,93 @@ 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 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: + 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: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 - name: Activate License + id: activate-license if: ${{ matrix.unity-version != 'none' }} + env: + 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 }} 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 + if [ "${RUNNER_OS}" = "Windows" ] || [ "${major}" -lt 2017 ]; then + unset UNITY_LICENSE + fi + # activate-license writes license-source=license-file|account to this step's outputs + unity-cli activate-license --license personal + rm -rf "${RUNNER_TEMP}/unity-license" - 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 "${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 + 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 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 +265,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 @@ -145,7 +275,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) @@ -166,19 +296,35 @@ 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: 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) @@ -255,47 +401,15 @@ 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_PATH}" ]; then - echo "UNITY_EDITOR_PATH is not set, skipping uninstall" - exit 0 - fi - unity-cli uninstall-unity --unity-editor "${UNITY_EDITOR_PATH}" - 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 - if [ ! -f "${LICENSING_CLIENT_LOG_PATH}" ]; then echo "::error::Licensing Client log file does not exist at ${LICENSING_CLIENT_LOG_PATH}" fi @@ -304,5 +418,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/.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/.github/workflows/unity-release-discovery.yml b/.github/workflows/unity-release-discovery.yml index 9a9991f0..630689af 100644 --- a/.github/workflows/unity-release-discovery.yml +++ b/.github/workflows/unity-release-discovery.yml @@ -5,17 +5,14 @@ on: workflow_dispatch: permissions: contents: read - issues: write jobs: discover: runs-on: ubuntu-latest + timeout-minutes: 10 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 - 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 f3e70cf9..63968019 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. +unity-cli v4 still covers: -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). +- 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. +- Live Editor log and Unity Test Protocol output turned into GitHub Actions annotations, log groups and job summaries. -Stay on this package when you still need: - -- 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) + - [Official Unity CLI](#official-unity-cli) - [Auth](#auth) - [License Version](#license-version) - [Activate License](#activate-license) @@ -46,41 +46,35 @@ 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) + - [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) - - [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 drops the Hub. Commands that need the official CLI install it on first use. -- 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 | +| --- | --- | +| `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`. Only the commands above are forwarded; unknown commands are not passed to `unity`. ## Installation @@ -89,7 +83,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 +93,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 +109,20 @@ With options always using double dashes (`--option`) and arguments passed direct unity-cli --help ``` -### Install all tools +### Official 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. +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. -- `--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. +To use a specific release, pass `--unity-cli-version ` to any command or set `UNITY_CLI_VERSION`: ```bash -unity-cli install-all-tools --auto-update +unity-cli --unity-cli-version 1.0.0-beta.14 setup-unity --unity-version 6000 ``` ### 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,23 +136,28 @@ 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. -- `-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. - `-l`, `--license`: License type (personal, professional, floating) -- `-t`, `--token`: Floating license token. Required when returning a floating license. +- `-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 @@ -169,12 +166,20 @@ unity-cli return-license --license personal #### License Context -`license-context`: Print the current license context information. +`license-context`: Print the current license state from the official CLI and the Unity Licensing Client. ```bash 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. @@ -191,78 +196,11 @@ unity-cli licensing-client-logs unity-cli licensing-audit-logs ``` -### Unity Hub - -#### Hub Version - -`hub-version`: Print the Unity Hub version. - -```bash -unity-cli hub-version -``` - -#### Hub Path - -`hub-path`: Print the path to the Unity Hub executable. - -- `--json`: Prints the last line of output as JSON string. - -```bash -unity-cli hub-path -``` - -#### Hub Logs - -`hub-logs`: Prints the path to the Unity Hub log file. - -```bash -unity-cli hub-logs -``` - -#### Package Manager Logs - -`package-manager-logs`: Prints the path to the Unity Package Manager log file. - -```bash -unity-cli package-manager-logs -``` - -#### Unity Hub Install - -`hub-install [options]`: Install or update the Unity Hub. - -- `--auto-update`: Automatically updates the Unity Hub if it is already installed. Cannot be used with `--hub-version`. -- `--hub-version`: Specify to install a specific version of Unity Hub. Cannot be used with `--auto-update`. -- `--verbose`: Enable verbose logging. -- `--json`: Prints the last line of output as JSON string. - -```bash -unity-cli hub-install -``` - -#### Run Unity Hub Commands - -`hub [options] `: 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 +209,25 @@ 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. - `--verbose` Enable verbose logging. - `--json` Prints the last line of output as JSON string. -Installs the latest Unity 6 version with Android and iOS modules: +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. -```bash -unity-cli setup-unity --unity-version 6000 --modules android,ios -``` +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. -#### 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. +Installs the latest Unity 6 version with Android and iOS modules: ```bash -unity-cli uninstall-unity --unity-version 6000 +unity-cli setup-unity --unity-version 6000 --modules android,ios ``` -### 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 +245,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,6 +253,10 @@ unity-cli open-project 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 --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 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. @@ -426,27 +299,6 @@ unity-cli upm-version 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 +315,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..9823e10c 100644 --- a/docs/unity-version-support.md +++ b/docs/unity-version-support.md @@ -4,17 +4,17 @@ This repo tests Unity editors on CI in three 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 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 - 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.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, writes the report to the run summary, and warns on the run 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. 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..a477a933 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", + "commander": "^15.0.0", "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" @@ -27,10 +24,11 @@ "@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" + "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", @@ -676,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": { @@ -727,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", @@ -746,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": { @@ -808,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": { @@ -818,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": { @@ -889,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" @@ -995,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": { @@ -1008,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" @@ -1057,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": { @@ -1072,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" }, @@ -1088,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": { @@ -1104,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" @@ -1163,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": "*", @@ -1265,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, @@ -1282,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": { @@ -1405,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, @@ -1670,6 +1962,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1684,6 +1979,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1698,6 +1996,9 @@ "loong64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1712,6 +2013,9 @@ "loong64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1726,6 +2030,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1740,6 +2047,9 @@ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1754,6 +2064,9 @@ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1768,6 +2081,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1782,6 +2098,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1796,6 +2115,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2038,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" @@ -2093,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": [ @@ -2106,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": { @@ -2153,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": { @@ -2215,9 +2537,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" @@ -2355,15 +2677,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", @@ -2381,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" }, @@ -2509,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": { @@ -2632,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", @@ -2723,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", @@ -2804,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" @@ -2838,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", @@ -2852,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", @@ -2961,13 +3294,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" @@ -3055,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", @@ -3074,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", @@ -3221,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" @@ -3248,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": { @@ -3263,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" @@ -3328,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": { @@ -3394,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" }, @@ -3478,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" @@ -3527,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": { @@ -3540,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" @@ -3590,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" @@ -3697,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" }, @@ -3752,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": { @@ -3795,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" @@ -3862,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" @@ -3928,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" }, @@ -4007,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": { @@ -4018,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" }, @@ -4073,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", @@ -4124,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" @@ -4188,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": { @@ -4241,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" }, @@ -4290,9 +4617,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": { @@ -4433,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", @@ -4493,18 +4810,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", @@ -4513,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": { @@ -4542,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", @@ -4779,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" @@ -4862,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", @@ -5179,31 +5476,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", @@ -5219,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", @@ -5250,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", @@ -5657,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", @@ -5829,9 +6084,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..42591b3c 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,30 +53,29 @@ "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", + "commander": "^15.0.0", "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", "@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" + "typescript": "^6.0.3", + "yaml": "^2.9.1" }, "overrides": { "babel-plugin-istanbul": { "test-exclude": "7.0.2" }, - "glob": "$glob" + "glob": "$glob", + "brace-expansion": "^5.0.12" } } 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/cli.ts b/src/cli.ts index a5fb9fe7..3557dd62 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,20 +3,28 @@ 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 { + ActivateLicense, + GetLicenseFilePath, + LicenseType, + LicensingClient, + PrintLicenseContext, + ReturnLicense, + ReturnWhen, +} 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')); @@ -24,86 +32,15 @@ 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.') - .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.') - .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.') - .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); - } - - if (options.autoUpdate === true && options.upmVersion) { - Logger.instance.error('Cannot use --auto-update with --upm-version.'); - process.exit(1); - } - - 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`); - } + .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) + .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(); - process.exit(0); - } catch (error) { - Logger.instance.error(`${error}`); - process.exit(1); + if (version) { + // every command resolves the official CLI through UnityCli.Get(), which reads the request from here + process.env.UNITY_CLI_VERSION = version; } }); @@ -118,12 +55,13 @@ 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('-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.') .option('--json', 'Prints the last line of output as JSON string.') .action(async (options) => { @@ -131,9 +69,18 @@ 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 client = new LicensingClient(); const licenseStr: string = options.license?.toString()?.trim(); if (!licenseStr || licenseStr.length === 0) { @@ -141,38 +88,25 @@ 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); + if (licenseType === LicenseType.professional && !options.serial) { + options.serial = await PromptForSecretInput('Serial: '); Logger.instance.maskCredential(options.serial); } - const token = await client.Activate({ + const token = await ActivateLicense({ licenseType, 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) { @@ -183,18 +117,29 @@ 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('--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 + Logger.instance.maskCredential(token); Logger.instance.debugOptions(options); - const client = new LicensingClient(); const licenseStr: string = options.license?.toString()?.trim(); if (!licenseStr || licenseStr.length === 0) { @@ -209,31 +154,21 @@ program.command('return-license') process.exit(1); } - let token: string | undefined = options.token; - - 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); - } - - await client.Deactivate(licenseType, token); + await ReturnLicense(licenseType, token, when as ReturnWhen); 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); + }); + +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); }); @@ -251,110 +186,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 +197,7 @@ 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('--verbose', 'Enable verbose logging.') .option('--json', 'Prints the last line of output as JSON string.') .action(async (options) => { @@ -386,14 +221,25 @@ 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(); + + // 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) { const module = moduleBuildTargetMap[target]; @@ -416,7 +262,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 +272,17 @@ program.command('setup-unity') process.exit(1); } } - const unityEditor = await unityHub.GetEditor(unityVersion, modules, channels); + + const unityCli = await UnityCli.Get(); + 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 +317,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 +403,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,153 +429,119 @@ 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.') +program.command('editor-logs') + .description('Prints the path to the Unity Editor log files.') + .action(async () => { + const unityEditorLogsPath = UnityEditor.GetEditorLogsDirectory(); + process.stdout.write(`${unityEditorLogsPath}\n`); + 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.') - .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 version = await forwardedEditorVersion(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.'); + 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); } - let unityEditor: UnityEditor; + const args = ['uninstall', version, '--yes']; + const arch = options.arch?.toString()?.trim(); - 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); + if (arch) { + args.push('--architecture', arch); } - const templates = unityEditor.GetAvailableTemplates(); + await forwardToUnityCli('uninstall-unity', args); + }); - 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'); +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; } - process.exit(0); + 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('Create a new Unity 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 get (e.g. 2020.3.1f1, 2021.x, 2022.1.*, 6000). If unspecified, then --unity-editor must be specified.') + .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.') - .option('--json', 'Prints the last line of output as JSON string.') + .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)]; - 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); + if (version) { + args.push('--editor-version', version); } - let unityEditor: UnityEditor; + const template = options.template.toString().trim(); + // 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 (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); + if (!prefix) { + Logger.instance.error(`Invalid project template: ${template}. Use a template id such as com.unity.template.3d.`); + process.exit(1); } - 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); + if (prefix !== template) { + Logger.instance.warn(`Template patterns are no longer supported; matching templates that start with ${prefix}.`); } - 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]; + 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 (!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`); + if (!templateId) { + Logger.instance.error(`No project template matches ${prefix}. Available: ${ids.join(', ')}`); + process.exit(1); } - process.exit(0); + args.push('--template', templateId, '--no-cloud'); + await forwardToUnityCli('create-project', args, options.json === true); }); program.command('open-project') - .description('Open a Unity project in the Unity Editor.') + .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 get (e.g. 2020.3.1f1, 2021.x, 2022.1.*, 6000). If specified, it will override the version read from the project.') + .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) => { @@ -787,57 +550,60 @@ program.command('open-project') } Logger.instance.debugOptions(options); - const projectPath = options.unityProject?.toString()?.trim() || - process.env.UNITY_PROJECT_PATH || - undefined; - const unityProject = await UnityProject.GetProject(projectPath); + 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 (!unityProject) { - Logger.instance.error(`The specified path is not a valid Unity project: ${projectPath}`); - process.exit(1); + if (version) { + args.push('--editor-version', version); } - let unityVersion: UnityVersion | undefined = unityProject?.version; - - if (options.unityVersion) { - unityVersion = new UnityVersion(options.unityVersion); + if (buildTarget) { + args.push('--build-target', buildTarget); } - const buildTarget = options.buildTarget?.toString()?.trim(); - let module: string[] | undefined = undefined; + 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 (buildTarget && buildTarget.length > 0) { - const moduleBuildTargetMap = UnityHub.GetPlatformTargetModuleMap(); - module = moduleBuildTargetMap[buildTarget] ? [moduleBuildTargetMap[buildTarget]] : undefined; + if (version) { + if (!exact) { + return version.replace(/\.[x*]$/i, ''); } - const unityHub = new UnityHub(); - const unityEditor = await unityHub.GetEditor(unityVersion, module); + const unityVersion = new UnityVersion(version); - Logger.instance.info(`Opening "${unityProject.projectPath}" with Unity ${unityEditor.version}${buildTarget ? ` - ${buildTarget}` : ''}...`); + if (unityVersion.isFullyQualified()) { + return unityVersion.version; + } - const openArgs = ['-projectPath', unityProject.projectPath]; + return (await new UnityInstaller(await UnityCli.Get()).resolveVersion(unityVersion)).version; + } - if (buildTarget && buildTarget.length > 0) { - openArgs.push('-buildTarget', buildTarget); - } + const editorPath = options.unityEditor?.toString()?.trim() || process.env.UNITY_EDITOR_PATH; + return editorPath ? new UnityEditor(editorPath).version.version : undefined; +} - let child: ChildProcess | null = null; - try { - child = spawn(unityEditor.editorPath, openArgs, { detached: true }); - child.unref(); - } finally { - process.exit(child?.pid !== undefined ? 0 : 1); - } - }); +/** 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 }); -program.command('editor-logs') - .description('Prints the path to the Unity Editor log files.') - .action(async () => { - const unityEditorLogsPath = UnityEditor.GetEditorLogsDirectory(); - process.stdout.write(`${unityEditorLogsPath}\n`); - process.exit(0); - }); + if (json) { + process.stdout.write(`${result.stdout.trim()}\n`); + } + + process.exit(result.exitCode); +} program.commandsGroup("Unity Package Manager:"); @@ -898,7 +664,7 @@ program.command('upm-install') process.exit(0); } catch (error) { - Logger.instance.error(`${error}`); + ReportFailure(error); process.exit(1); } }); @@ -912,7 +678,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); } }); @@ -1010,139 +776,12 @@ program.command('upm-pack') 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.'); + ReportFailure(error); 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)); + ReportFailure(err); process.exit(1); }); diff --git a/src/failure-markers.ts b/src/failure-markers.ts new file mode 100644 index 00000000..35090c52 --- /dev/null +++ b/src/failure-markers.ts @@ -0,0 +1,49 @@ +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'; + +// 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|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; + +/** + * 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; + } + + // 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; + } + + return undefined; +} + +/** + * 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 { + const message = error instanceof Error ? error.message : String(error); + const marker = ClassifyFailure(message); + + if (marker) { + Logger.instance.annotate(LogLevel.INFO, `Retryable failure: ${message}`, undefined, undefined, undefined, undefined, undefined, marker); + } + + Logger.instance.error(message); +} diff --git a/src/github-actions-ci.ts b/src/github-actions-ci.ts index a3aa05de..9c53e6c9 100644 --- a/src/github-actions-ci.ts +++ b/src/github-actions-ci.ts @@ -69,23 +69,38 @@ 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 { 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' }); } } 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' }); } } + public addPath(directory: string): void { + const githubPath = process.env.GITHUB_PATH; + // 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' }); + } + } + 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..31c5e2d5 100644 --- a/src/license-client.ts +++ b/src/license-client.ts @@ -3,11 +3,26 @@ 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, + DownloadFile, + Exec, + KillProcess, + SanitizeLogText, + Sha256FileHex, + GetTempDir, + ResolvePathCandidates, + redactSensitiveLiterals, tryParseJson } from './utilities'; +import { + FindOwnedProcesses, + IsExecutableNamed, + IsPathUnder, + OwnedSpawnOptions, + TrackOwnedProcess, +} from './process-tracker'; export enum LicenseType { personal = 'personal', @@ -26,13 +41,29 @@ 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; } +/** + * 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. + */ +/** How long one Unity Licensing Client command may run. */ +const LICENSING_CLIENT_TIMEOUT_MS = 10 * 60 * 1000; + export class LicensingClient { - private readonly unityHub: UnityHub = new UnityHub(); private readonly logger: Logger = Logger.instance; 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; /** @@ -43,21 +74,218 @@ 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; + 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(); } - const licensingClientExecutable = process.platform === 'win32' ? 'Unity.Licensing.Client.exe' : 'Unity.Licensing.Client'; - const licenseClientPath = await ResolveGlobToPath([this.unityHub.rootDirectory, '**', licensingClientExecutable]); + 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. Set UNITY_LICENSING_CLIENT_PATH to its location.'); + } + + 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; } + /** + * 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 = 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()); + + 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}`); + // 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 { + 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. + * 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 + } + + // 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)); + 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; + } + } + } + + /** + * 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. + */ + private static clientRootDirectory(clientPath: string): string { + const macOSDirectory = path.dirname(clientPath); + + if (path.basename(macOSDirectory) === 'MacOS' && path.basename(path.dirname(macOSDirectory)) === 'Contents') { + return path.dirname(path.dirname(macOSDirectory)); + } + + return macOSDirectory; + } + + /** + * 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(); + // 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 (isClient || ['', '.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. + */ + 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 +384,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)) { @@ -293,27 +526,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') { @@ -327,38 +541,88 @@ 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 clientDirectory = path.dirname(this.licenseClientPath); - const patchedDirectory = path.join(os.tmpdir(), `UnityLicensingClient-${this.licenseVersion.replace('.', '_')}`); + const clientRoot = LicensingClient.clientRootDirectory(this.licenseClientPath); + // 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 = (await fs.promises.readFile(sourceMarker, 'utf-8').catch(() => '')).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 { - let found = false; - for (const fileName of await fs.promises.readdir(clientDirectory)) { - if (fileName === 'Unity.Licensing.EntitlementResolver.dll') { - 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)); + 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 }); + const patchedFiles: string[] = []; + + // 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 (!found) { - throw new Error('Could not find Unity.Licensing.EntitlementResolver.dll in the unityhub installation'); + 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}. Searched:\n${searched.map(file => ` > ${file}`).join('\n')}`); + } + + this.logger.debug(`Patched the license version in:\n${patchedFiles.map(file => ` > ${file}`).join('\n')}`); + await fs.promises.chmod(patchedClientPath, 0o755); + + if (process.platform === 'darwin') { + // patching invalidates the signature, and macOS kills binaries with a broken one + const isBundle = fs.existsSync(path.join(patchedDirectory, 'Contents', 'MacOS')); + const targets = isBundle ? [patchedDirectory] : patchedFiles; + + for (const target of targets) { + await new Promise((resolve, reject) => { + 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}`))); + }); + } } + + // 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 = path.join(patchedDirectory, path.basename(this.licenseClientPath)); + 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 }; await fs.promises.mkdir(unityCommonDir, { recursive: true }); try { @@ -384,26 +648,44 @@ export class LicensingClient { let output: string = ''; let exitCode: number = 0; - - this.logger.startGroup(`\x1b[34m${this.licenseClientPath} ${args.join(' ')}\x1b[0m`); + 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']; + 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`); + const logOffset = await this.getClientLogSize(); await fs.promises.access(this.licenseClientPath!, fs.constants.R_OK | fs.constants.X_OK); 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'); 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); } @@ -419,14 +701,35 @@ 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, signal: NodeJS.Signals | null) => { + if (settled) { return; } + settled = true; removeListeners(); - resolve(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, signal) => { + setTimeout(() => { + child.stdout.destroy(); + child.stderr.destroy(); + settle(code, signal); + }, 1000); }); }); } 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.printClientLogProblems(logOffset, secrets); + } + if (!silent || exitCode !== 0) { - const maskedOutput = 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) { @@ -438,7 +741,14 @@ export class LicensingClient { this.logger.endGroup(); if (exitCode !== 0) { - const message = 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}`); } } @@ -446,6 +756,49 @@ export class LicensingClient { return output; } + /** + * 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 SanitizeLogText(line, secrets); + } + + /** + * 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()); + const lines = content.subarray(offset).toString('utf-8').split(/\r?\n/) + .filter(line => line.trim().length > 0) + .slice(-maxLines) + .map(line => LicensingClient.sanitizeLogLine(line, secrets)); + + if (lines.length > 0) { + 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}`); + } + } + + /** + * 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') { + return; + } + + // matched on the executable's own name, so a process that only mentions it on its command line is left alone + const clients = (await FindOwnedProcesses()).filter(proc => IsExecutableNamed(proc, 'Unity.Licensing.Client')); + await Promise.all(clients.map(client => KillProcess(client).catch(() => undefined))); + } + private maskSerialInOutput(output: string): string { return output.replace(/([\w-]+-XXXX)/g, (_, serial) => { const maskedSerial = serial.slice(0, -4) + `XXXX`; @@ -582,12 +935,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 @@ -670,6 +1024,20 @@ export class LicensingClient { return licenses; } + /** + * 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 GetMachineBindings(): Promise { + try { + 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 machine bindings: ${error}`); + return []; + } + } + private async activateLicense(licenseType: LicenseType, username: string, password: string, serial: string | undefined = undefined): Promise { const args = [ `--activate-ulf`, @@ -688,6 +1056,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(); @@ -696,9 +1066,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) { @@ -726,4 +1122,381 @@ 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 { + // 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 (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. */ +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...`); + await LicensingClient.StopBackgroundClients(); + return await new LicensingClient(legacyLicenseVersion).Activate(options); + } + + const licenseFile = options.licenseFile ?? process.env.UNITY_LICENSE; + + if (licenseFile && licenseFile.trim().length > 0 && + (options.licenseType === LicenseType.personal || options.licenseType === LicenseType.professional)) { + try { + if (await activateFromLicenseFile(licenseFile)) { + // lets a CI step tell whether it needs to store a fresh license + logger.CI_setOutput('license-source', 'license-file'); + return undefined; + } + } catch (error) { + logger.warn(`The license file could not be used, activating with the account instead.\n${error}`); + } + } + + logger.CI_setOutput('license-source', 'account'); + let cliError: unknown; + + try { + const result = await activateWithUnityCli(options); + + if (result.activated) { + await logLicenseBinding(); + 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}`); + } + + await LicensingClient.StopBackgroundClients(); + const token = await new LicensingClient().Activate(options); + await logLicenseBinding(); + return token; +} + +/** + * Logs whether the license file now on disk names this machine's binding, which is what a cached copy is checked + * against on the next run. Only a yes or no is logged, never the binding or the file. + */ +async function logLicenseBinding(): 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.info(`Could not compare the license file with this machine's identity: ${error instanceof Error ? error.message : error}`); + } +} + +/** 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, when: ReturnWhen = 'auto'): Promise { + try { + 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(); + await stopUnityCliHelpers(); + } +} + +/** + * 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; + + if (process.env.CI !== 'true' || !cliPath) { + return; + } + + 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))); +} + +async function returnLicense(licenseType: LicenseType, token: string | undefined): Promise { + const logger = Logger.instance; + const legacyLicenseVersion = GetLegacyLicenseVersion(); + + if (legacyLicenseVersion || (licenseType === LicenseType.floating && token)) { + await LicensingClient.StopBackgroundClients(); + 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 LicensingClient.StopBackgroundClients(); + 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(); +} + +/** + * 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 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'); + } + + if (!content.includes('')) { + 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'); + + 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[] = []; + + 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 }; +} + +/** + * 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 { 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 { + 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..6c29daf3 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'; @@ -692,6 +693,8 @@ export class Logger { 'username', 'servicesConfig', 'serviceaccountkey', + // licenseFile can hold the license XML itself + 'licensefile', ]; /** @@ -748,6 +751,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/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 new file mode 100644 index 00000000..628b2c07 --- /dev/null +++ b/src/unity-cli.ts @@ -0,0 +1,664 @@ +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, + SanitizeLogText, + 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'; +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 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 = normalizeVersion(version || process.env.UNITY_CLI_VERSION || 'latest'); + + // 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 && 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); + } + + /** + * 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[] = []; + + if (process.env.UNITY_CLI_PATH) { + 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)); + } + } + + 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)) { + 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 = 'latest'): Promise { + configureEnvironment(); + const requested = normalizeVersion(version); + 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]; + + if (!binary) { + 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); + + 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 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; + child.kill('SIGTERM'); + }, options.timeoutMs) + : undefined; + const onSignal = () => child.kill('SIGTERM'); + process.once('SIGINT', onSignal); + process.once('SIGTERM', onSignal); + // a chunk can end mid-line, so each stream keeps its unfinished line until the rest arrives + const pending = { stdout: '', stderr: '' }; + const writeLine = (line: string) => { + // 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('stdout', chunk); + }); + child.stderr.on('data', (data: Buffer) => { + const chunk = data.toString(); + stderr += chunk; + writeLines('stderr', 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(); + flushLines(); + 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); + // 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 }; + } + + /** + * 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()}`); + } + + // 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()}`); + } + + 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; + + // 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}`); + } + + 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 1.0.0-beta.14, 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 []; + } + + // newest first by semver, so 1.0.0-beta.14 sorts ahead of 1.0.0-beta.9 + return fs.readdirSync(toolDir) + .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)]; + } + + 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..c2978d84 100644 --- a/src/unity-editor.ts +++ b/src/unity-editor.ts @@ -5,27 +5,42 @@ import { Logger } from './logging'; import { UnityVersion } from './unity-version'; import { spawn, - ChildProcessByStdio + ChildProcess } from 'child_process'; import { GetArgumentValueAsString, ProcInfo, KillProcess, - KillChildProcesses, Exec, - DeleteDirectory, + redactSensitiveLiterals, } from './utilities'; +import { UnityCli } from './unity-cli'; +import { + IsExecutableNamed, + IsPathUnder, + ListProcesses, + OwnedRoot, + OwnedSpawnOptions, + SelectOwnedProcesses, + TrackOwnedProcess, +} from './process-tracker'; import { TailLogFile, 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; } 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; @@ -109,89 +124,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]. @@ -199,7 +131,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++) { @@ -224,18 +156,61 @@ 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; let logTail: LogTailResult | null = null; - let unityProcess: ChildProcessByStdio; + let unityProcess: ChildProcess; + let unityRunOutput: string | undefined = undefined; + let editorLogPath: string | undefined = undefined; + + const editorRootPath = this.editorRootPath; + 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 = [...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}`); } @@ -290,7 +265,11 @@ export class UnityEditor { } const logPath: string = GetArgumentValueAsString('-logFile', command.args); - logTail = TailLogFile(logPath, command.projectPath); + editorLogPath = logPath; + // 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); @@ -306,18 +285,37 @@ export class UnityEditor { : undefined; const baseEditorEnv: NodeJS.ProcessEnv = { ...process.env, - UNITY_THISISABUILDMACHINE: '1', DISABLE_EMBEDDED_BUILD_PIPELINE_PLUGIN_LOGGING: '1', ...(linuxEnvOverrides ?? {}) }; - if (process.platform === 'linux' && + 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); + 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'], + ...OwnedSpawnOptions(), + 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( 'xvfb-run', [this.editorPath, ...command.args], { stdio: ['ignore', 'ignore', 'ignore'], + ...OwnedSpawnOptions(), env: { ...baseEditorEnv, DISPLAY: baseEditorEnv.DISPLAY || ':99' @@ -331,6 +329,7 @@ export class UnityEditor { 'arch', ['-x86_64', this.editorPath, ...command.args], { stdio: ['ignore', 'ignore', 'ignore'], + ...OwnedSpawnOptions(), env: baseEditorEnv }); } else { @@ -338,6 +337,7 @@ export class UnityEditor { this.editorPath, command.args, { stdio: ['ignore', 'ignore', 'ignore'], + ...OwnedSpawnOptions(), env: baseEditorEnv }); } @@ -349,11 +349,27 @@ 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) => { - 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}`); @@ -377,13 +393,139 @@ export class UnityEditor { if (!isCancelled) { await tryKillEditorProcesses(); + if (exitCode !== undefined && exitCode !== 0 && unityRunOutput) { + // unity run can echo arguments back, so values of sensitive flags are redacted the same way as the logged command + const sensitiveValues = command.args.filter((_, i) => 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(' 0) { + this.logger.info(`unity run output:\n${output}`); + } + } + + if (exitCode !== undefined && exitCode !== 0) { + await this.printFailureRecap(logTail, editorLogPath); + } + if (exitCode === undefined) { throw Error('Failed to start Unity!'); } 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'); + } + } + } + + /** + * 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: + * 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. + // 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')) || + (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, -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. + * @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' || arg === '-quit') { + continue; + } + + forwarded.push(arg); } + + return [ + 'run', projectPath, + // 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', + '--', + ...forwarded + ]; } /** @@ -537,36 +679,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..46bcce2e --- /dev/null +++ b/src/unity-installer.ts @@ -0,0 +1,1079 @@ +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'; +import { UnityVersion } from './unity-version'; +import { FindAbsolutePath, UnityCli } from './unity-cli'; +import { + DownloadFile, + DownloadOptions, + 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 +# 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 +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 +# 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 +`; + +/** 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" +`; + +/** + * 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. */ +/** 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', + '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; + /** 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. + */ + 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 }); + } + + // 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}`); + } + } + + /** + * 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]); + + // 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 && matchesArchitecture(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; + } + + // the architecture-specific folder first; the plain one only when its binary is the requested architecture + const candidates = [ + path.join(installPath, `${unityVersion.version}-${unityVersion.architecture.toLowerCase()}`), + path.join(installPath, unityVersion.version), + ]; + + for (const candidate of candidates) { + const editorPath = resolveEditorExecutable(candidate); + + if (editorPath && matchesArchitecture(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`); + } + + 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}`; + 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) { + 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; + } + + 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); + const modulePackages = moduleNames.map(name => `${baseUrl}/MacEditorTargetInstaller/UnitySetup-${name}-Support-for-Editor-${unityVersion.version}.pkg`); + + if (editorPath) { + 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()}...`); + await this.installMacPackages([`${baseUrl}/MacEditorInstaller/Unity-${unityVersion.version}.pkg`, ...modulePackages]); + 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}`); + } + } + + /** + * 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[] = []; + + 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; + } + + /** + * 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 = tempInstallerPath('.exe'); + await DownloadFile(url, installerPath, await this.getDownloadOptions(url)); + this.logger.info(`Running ${path.basename(url)}...`); + + try { + 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(url)} failed with exit code ${exitCode}`); + } + } finally { + await fs.promises.rm(installerPath, { force: true }); + } + } + + private async installMacPackages(urls: string[]): Promise { + for (const url of urls) { + const pkgPath = tempInstallerPath('.pkg'); + await DownloadFile(url, pkgPath, await this.getDownloadOptions(url)); + + 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. + */ + 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 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 + if (item === 'Hub') { + continue; + } + + 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 fs.promises.chmod(targetPath, 0o755); + } + + /** + * 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); + + 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 = tempInstallerPath('.dmg'); + await DownloadFile(url, installerPath, await this.getDownloadOptions(url)); + 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 { + // streamed, so a slow mirror shows where it stalls + // 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) { + 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 = tempInstallerPath('.deb'); + 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]); + } 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'); + // 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 === 'ENOENT') { + return; + } + + 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...`); + 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[@]}"` + // 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); + } + } + + /** + * 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()); +} + +/** + * 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}`); +} + +/** + * 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; +} diff --git a/src/unity-logging.ts b/src/unity-logging.ts index cbb1d253..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 { @@ -35,6 +36,46 @@ 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) || + (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 + (/^[\w.`<>[\]+]+:[\w.`<>[\]+]+ ?\(/.test(trimmed) && trimmed.endsWith(')')); +} + +/** + * 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 @@ -1135,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; @@ -1153,6 +1194,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(); @@ -1193,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 @@ -1236,6 +1293,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 +1337,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 +1507,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/src/unity-project.ts b/src/unity-project.ts index 8d304513..111abc2e 100644 --- a/src/unity-project.ts +++ b/src/unity-project.ts @@ -76,6 +76,99 @@ 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')) { + continue; + } + + const target = UnityProject.readBuildProfileTarget(UnityProject.readBuildProfileAsset(entryPath)); + + if (target) { + targets.add(target); + } + } + } + + return [...targets]; + } + + /** + * 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 (!head.subarray(0, headLength).toString('utf-8').includes('fileID: 15003,') || 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', + 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/src/upm-cli.ts b/src/upm-cli.ts index 654fb0e2..9ed65fe8 100644 --- a/src/upm-cli.ts +++ b/src/upm-cli.ts @@ -41,11 +41,11 @@ export interface UpmPackOptions { } /** - * Managed Unity Package Manager CLI (unity-cli–installed `UnityPackageManager`), modeled after {@link UnityHub}: + * 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), analogous to {@link UnityHub.rootDirectory}. */ + /** Root directory for managed installs (~/.unity-cli/upm). */ public readonly managedRoot: string; private readonly logger: Logger = Logger.instance; @@ -192,7 +192,7 @@ export class UpmCli { throw new Error(`Could not find UnityPackageManager binary under ${installDir}`); } - /** Optional executable override (mirrors `UNITY_HUB_PATH` for {@link UnityHub}). */ + /** 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; @@ -251,7 +251,7 @@ export class UpmCli { /** * 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). + * @throws If nothing usable is installed. */ public GetExecutablePath(): string { const overridePath = this.getExecutablePathOverride(); @@ -267,7 +267,7 @@ export class UpmCli { return managed; } - /** Same role as {@link UnityHub.executable}: path used to spawn the UPM CLI (may reflect `UPM_CLI_PATH` or the managed install). */ + /** Path used to spawn the UPM CLI (may reflect `UPM_CLI_PATH` or the managed install). */ public get executable(): string { return this.GetExecutablePath(); } @@ -295,7 +295,7 @@ export class UpmCli { } /** - * Installs or updates the managed UPM CLI (mirrors {@link UnityHub.Install} for the Hub itself). + * Installs or updates the managed UPM CLI. * @returns Installed release tag (e.g. v9.27.0). */ public async Install(options?: EnsureUpmInstalledOptions): Promise { @@ -390,7 +390,7 @@ export class UpmCli { * 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). + * Call before {@link GetExecutablePath} / {@link Exec} for an optional install/update (e.g. pack). */ public async PromptInstallOrUpdateWhenInteractive(): Promise { const overrideUsable = this.executableOverrideIsUsable(); @@ -462,7 +462,7 @@ export class UpmCli { } /** - * Executes the UPM CLI with the given arguments (mirrors {@link UnityHub.Exec}). + * 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(); diff --git a/src/utilities.ts b/src/utilities.ts index 531accc4..f9b7e8dc 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,38 +435,127 @@ 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 }); + // 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) => { - https.get(url, (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 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); }); - }).on('error', (error) => { - void fs.promises.unlink(downloadPath).catch(() => undefined); - reject(error); - }); + }; + get(); }); await new Promise((r) => 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(''); } /** @@ -667,46 +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)}`); - } -} - /** * Checks if the current process is running with elevated (administrator) privileges. * @returns True if the process is elevated, false otherwise. @@ -731,4 +811,4 @@ export function tryParseJson(content: string | undefined): string | undefined { } catch { return undefined; } -} \ No newline at end of file +} diff --git a/tests/failure-markers.test.ts b/tests/failure-markers.test.ts new file mode 100644 index 00000000..22697d6b --- /dev/null +++ b/tests/failure-markers.test.ts @@ -0,0 +1,32 @@ +import { ClassifyFailure, LICENSE_EXHAUSTED_MARKER, TRANSIENT_FAILURE_MARKER } from '../src/failure-markers'; + +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); + }); + + 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', + 'aborted', + 'Error: aborted', + ])('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', + '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(); + }); +}); 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); + }); +}); 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/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); + }); +}); 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); + }); +}); diff --git a/tests/unity-cli.test.ts b/tests/unity-cli.test.ts new file mode 100644 index 00000000..ad54e252 --- /dev/null +++ b/tests/unity-cli.test.ts @@ -0,0 +1,186 @@ +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); + }); +}); + +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('*****'); + }); +}); diff --git a/tests/unity-editor.test.ts b/tests/unity-editor.test.ts index 1bb2ed83..5abbc192 100644 --- a/tests/unity-editor.test.ts +++ b/tests/unity-editor.test.ts @@ -1,158 +1,118 @@ +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[] = []; + 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')); + }); - beforeAll(async () => { - const unityHub = new UnityHub(); - editors.push(...await unityHub.ListInstalledEditors()); + afterAll(() => { + fs.rmSync(tempRoot, { recursive: true, force: true }); }); - it('lists available templates for each installed Unity editor', async () => { - expect(editors).toBeDefined(); - expect(Array.isArray(editors)).toBe(true); + 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'); + }); - for (const editor of editors) { - const templates = editor.GetAvailableTemplates(); - expect(templates).toBeDefined(); - expect(Array.isArray(templates)).toBe(true); - } + 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', + '-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', + '-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/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([]); + }); +});