diff --git a/.github/workflows/build-options.json b/.github/workflows/build-options.json index acc4659f..b1671291 100644 --- a/.github/workflows/build-options.json +++ b/.github/workflows/build-options.json @@ -7,14 +7,14 @@ "unity-version": [ "4.7.2", "5.6.7f1 (e80cc3114ac1)", - "2017", + "2017.4.40f1", "2018", - "2019", - "2020", - "2021", - "2022", - "6000.0", - "6000.1", + "2019.x", + "2020.*", + "2021.3.x", + "2022.3.*", + "6000.0.x", + "6000.1.*", "6000.2" ], "include": [ diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e5d89cbd..924d58dc 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -10,7 +10,7 @@ jobs: contents: read steps: - uses: actions/checkout@v5 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version: 24.x registry-url: "https://registry.npmjs.org" diff --git a/.github/workflows/unity-build.yml b/.github/workflows/unity-build.yml index 7189e69a..35e7c16e 100644 --- a/.github/workflows/unity-build.yml +++ b/.github/workflows/unity-build.yml @@ -26,14 +26,14 @@ jobs: RUN_BUILD: '' # Set to true if the build pipeline package can be installed and used steps: - name: Free Disk Space - if: ${{ matrix.os == 'ubuntu-latest' && matrix.unity-version == '6000.2' }} + if: ${{ matrix.os == 'ubuntu-latest' && (matrix.unity-version != '2018' && matrix.unity-version != '2017.4.40f1') }} uses: endersonmenezes/free-disk-space@713d134e243b926eba4a5cce0cf608bfd1efb89a # v2.1.1 with: remove_android: true remove_dotnet: false remove_tool_cache: false - uses: actions/checkout@v5 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version: 24.x - name: Setup unity-cli @@ -138,11 +138,3 @@ jobs: shell: bash run: | unity-cli return-license --license personal - - name: Upload Logs - if: always() - uses: actions/upload-artifact@v4 - with: - name: ${{ github.run_id }}.${{ github.run_attempt }} ${{ matrix.os }} ${{ matrix.unity-version }} ${{ matrix.build-target }} logs - retention-days: 1 - path: | - ${{ github.workspace }}/**/*.log diff --git a/README.md b/README.md index b4d471b0..7e7b3918 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ npm install -g @rage-against-the-pixel/unity-cli In general, the command structure is: ```bash -unity-cli [command] [options] +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. diff --git a/package-lock.json b/package-lock.json index 281a4bdb..2622e7fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@rage-against-the-pixel/unity-cli", - "version": "1.5.3", + "version": "1.5.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@rage-against-the-pixel/unity-cli", - "version": "1.5.3", + "version": "1.5.4", "license": "MIT", "dependencies": { "@electron/asar": "^4.0.1", @@ -24,7 +24,7 @@ }, "devDependencies": { "@types/jest": "^30.0.0", - "@types/node": "^24.9.2", + "@types/node": "^24.10.1", "@types/semver": "^7.7.1", "@types/update-notifier": "^6.0.8", "jest": "^30.2.0", @@ -601,9 +601,9 @@ } }, "node_modules/@emnapi/core": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.6.0.tgz", - "integrity": "sha512-zq/ay+9fNIJJtJiZxdTnXS20PllcYMX3OE23ESc4HK/bdYu3cOWYVhsOhVnXALfU/uqJIxn5NBPd9z4v+SfoSg==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz", + "integrity": "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==", "dev": true, "license": "MIT", "optional": true, @@ -613,9 +613,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.6.0.tgz", - "integrity": "sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", + "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", "dev": true, "license": "MIT", "optional": true, @@ -1503,9 +1503,9 @@ } }, "node_modules/@tsconfig/node10": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", - "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", "dev": true, "license": "MIT" }, @@ -1632,9 +1632,9 @@ } }, "node_modules/@types/node": { - "version": "24.9.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.2.tgz", - "integrity": "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==", + "version": "24.10.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", + "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "dev": true, "license": "MIT", "peer": true, @@ -1668,9 +1668,9 @@ } }, "node_modules/@types/yargs": { - "version": "17.0.34", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.34.tgz", - "integrity": "sha512-KExbHVa92aJpw9WDQvzBaGVE2/Pz+pLZQloT2hjL8IqsZnV62rlPOYvNnLmf/L2dyllfVUOVBj64M0z/46eR2A==", + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", "dev": true, "license": "MIT", "dependencies": { @@ -2271,9 +2271,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.8.21", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.21.tgz", - "integrity": "sha512-JU0h5APyQNsHOlAM7HnQnPToSDQoEBZqzu/YBlqDnEeymPnZDREeXJA3KBMQee+dKteAxZ2AtvQEvVYdZf241Q==", + "version": "2.8.28", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.28.tgz", + "integrity": "sha512-gYjt7OIqdM0PcttNYP2aVrr2G0bMALkBaoehD4BuRGjAOtipg0b6wHg1yNL+s5zSnLZZrGHOw4IrND8CD+3oIQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2327,9 +2327,9 @@ } }, "node_modules/browserslist": { - "version": "4.27.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz", - "integrity": "sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw==", + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz", + "integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==", "dev": true, "funding": [ { @@ -2348,10 +2348,10 @@ "license": "MIT", "peer": true, "dependencies": { - "baseline-browser-mapping": "^2.8.19", - "caniuse-lite": "^1.0.30001751", - "electron-to-chromium": "^1.5.238", - "node-releases": "^2.0.26", + "baseline-browser-mapping": "^2.8.25", + "caniuse-lite": "^1.0.30001754", + "electron-to-chromium": "^1.5.249", + "node-releases": "^2.0.27", "update-browserslist-db": "^1.1.4" }, "bin": { @@ -2414,9 +2414,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001751", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz", - "integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==", + "version": "1.0.30001755", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001755.tgz", + "integrity": "sha512-44V+Jm6ctPj7R52Na4TLi3Zri4dWUljJd+RDm+j8LtNCc/ihLCT+X1TzoOAkRETEWqjuLnh9581Tl80FvK7jVA==", "dev": true, "funding": [ { @@ -2482,9 +2482,9 @@ } }, "node_modules/cjs-module-lexer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.1.0.tgz", - "integrity": "sha512-UX0OwmYRYQQetfrLEZeewIFFI+wSTofC+pMBLNuH3RUuu/xzG1oz84UCEDOSoQlN3fZ4+AzmV50ZYvGqkMh9yA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.1.1.tgz", + "integrity": "sha512-+CmxIZ/L2vNcEfvNtLdU0ZQ6mbq3FZnwAP2PPTiKP+1QOoKwlKlPgb8UKV0Dds7QVaMnHm+FwSft2VB0s/SLjQ==", "dev": true, "license": "MIT" }, @@ -2814,9 +2814,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.244", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.244.tgz", - "integrity": "sha512-OszpBN7xZX4vWMPJwB9illkN/znA8M36GQqQxi6MNy9axWxhOfJyZZJtSLQCpEFLHP2xK33BiWx9aIuIEXVCcw==", + "version": "1.5.254", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.254.tgz", + "integrity": "sha512-DcUsWpVhv9svsKRxnSCZ86SjD+sp32SGidNB37KpqXJncp1mfUgKbHvBomE89WJDbfVKw1mdv5+ikrvd43r+Bg==", "dev": true, "license": "ISC" }, @@ -4676,9 +4676,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, "license": "MIT", "dependencies": { @@ -4723,9 +4723,9 @@ } }, "node_modules/ky": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/ky/-/ky-1.13.0.tgz", - "integrity": "sha512-JeNNGs44hVUp2XxO3FY9WV28ymG7LgO4wju4HL/dCq1A8eKDcFgVrdCn1ssn+3Q/5OQilv5aYsL0DMt5mmAV9w==", + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/ky/-/ky-1.14.0.tgz", + "integrity": "sha512-Rczb6FMM6JT0lvrOlP5WUOCB7s9XKxzwgErzhKlKde1bEV90FXplV1o87fpt4PU/asJFiqjYJxAJyzJhcrxOsQ==", "license": "MIT", "engines": { "node": ">=18" @@ -5133,9 +5133,9 @@ } }, "node_modules/path-scurry": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz", - "integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", + "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^11.0.0", @@ -5620,9 +5620,10 @@ } }, "node_modules/stubborn-utils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/stubborn-utils/-/stubborn-utils-1.0.1.tgz", - "integrity": "sha512-bwtct4FpoH1eYdSMFc84fxnYynWwsy2u0joj94K+6caiPnjZIpwTLHT2u7CFAS0GumaBZVB5Y2GkJ46mJS76qg==" + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/stubborn-utils/-/stubborn-utils-1.0.2.tgz", + "integrity": "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==", + "license": "MIT" }, "node_modules/supports-color": { "version": "7.2.0", diff --git a/package.json b/package.json index 86dd47eb..2dbe16d5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rage-against-the-pixel/unity-cli", - "version": "1.5.3", + "version": "1.5.4", "description": "A command line utility for the Unity Game Engine.", "author": "RageAgainstThePixel", "license": "MIT", @@ -43,9 +43,9 @@ "scripts": { "build": "tsc", "dev": "tsc --watch", + "tests": "jest --roots tests", "link": "npm link", - "unlink": "npm unlink @rage-against-the-pixel/unity-cli", - "tests": "jest --roots tests" + "unlink": "npm unlink @rage-against-the-pixel/unity-cli" }, "dependencies": { "@electron/asar": "^4.0.1", @@ -60,7 +60,7 @@ }, "devDependencies": { "@types/jest": "^30.0.0", - "@types/node": "^24.9.2", + "@types/node": "^24.10.1", "@types/semver": "^7.7.1", "@types/update-notifier": "^6.0.8", "jest": "^30.2.0", diff --git a/src/android-sdk.ts b/src/android-sdk.ts index ebf70d95..a788c559 100644 --- a/src/android-sdk.ts +++ b/src/android-sdk.ts @@ -7,9 +7,9 @@ import { UnityEditor } from './unity-editor'; import { isProcessElevated, ReadFileContents, - ResolveGlobToPath + ResolveGlobToPath, + ResolvePathCandidates, } from './utilities'; -import { satisfies } from 'semver'; const logger = Logger.instance; @@ -86,16 +86,16 @@ async function getJDKPath(editor: UnityEditor): Promise { } async function getSdkManager(editor: UnityEditor): Promise { - let globPath: string[] = []; + let globCandidates: string[][] = []; if (editor.version.range('>=2019.0.0 <2021.0.0')) { logger.debug('Using sdkmanager bundled with Unity 2019 and 2020'); switch (process.platform) { case 'darwin': case 'linux': - globPath = [editor.editorRootPath, '**', 'AndroidPlayer', '**', 'sdkmanager']; + globCandidates = [[editor.editorRootPath, '**', 'AndroidPlayer', '**', 'sdkmanager']]; break; case 'win32': - globPath = [editor.editorRootPath, '**', 'AndroidPlayer', '**', 'sdkmanager.bat']; + globCandidates = [[editor.editorRootPath, '**', 'AndroidPlayer', '**', 'sdkmanager.bat']]; break; default: throw new Error(`Unsupported platform: ${process.platform}`); @@ -105,10 +105,10 @@ async function getSdkManager(editor: UnityEditor): Promise { switch (process.platform) { case 'darwin': case 'linux': - globPath = [editor.editorRootPath, '**', 'AndroidPlayer', '**', 'cmdline-tools', '**', 'sdkmanager']; + globCandidates = [[editor.editorRootPath, '**', 'AndroidPlayer', '**', 'cmdline-tools', '**', 'sdkmanager']]; break; case 'win32': - globPath = [editor.editorRootPath, '**', 'AndroidPlayer', '**', 'cmdline-tools', '**', 'sdkmanager.bat']; + globCandidates = [[editor.editorRootPath, '**', 'AndroidPlayer', '**', 'cmdline-tools', '**', 'sdkmanager.bat']]; break; default: throw new Error(`Unsupported platform: ${process.platform}`); @@ -121,23 +121,32 @@ async function getSdkManager(editor: UnityEditor): Promise { throw new Error('Android installation not found: No system ANDROID_SDK_ROOT or ANDROID_HOME defined'); } + const sdkManagerBinary = process.platform === 'win32' ? 'sdkmanager.bat' : 'sdkmanager'; switch (process.platform) { case 'darwin': case 'linux': - globPath = [systemSdkPath, 'cmdline-tools', 'latest', 'bin', 'sdkmanager']; - break; case 'win32': - globPath = [systemSdkPath, 'cmdline-tools', 'latest', 'bin', 'sdkmanager.bat']; + globCandidates = [ + [systemSdkPath, 'cmdline-tools', 'latest', 'bin', sdkManagerBinary], + [systemSdkPath, 'cmdline-tools', '**', 'bin', sdkManagerBinary], + [systemSdkPath, 'tools', 'bin', sdkManagerBinary] + ]; break; default: throw new Error(`Unsupported platform: ${process.platform}`); } } - const sdkmanagerPath = await ResolveGlobToPath(globPath); + const sdkmanagerPath = await ResolvePathCandidates(globCandidates); if (!sdkmanagerPath) { - throw new Error(`Failed to resolve sdkmanager in ${globPath}`); + const normalizedCandidates = globCandidates.map(candidate => path.join(...candidate).split(path.sep).join('/')); + if (normalizedCandidates.length > 0) { + logger.ci(`sdkmanager glob candidates:\n${normalizedCandidates.map(candidate => ` > ${candidate}`).join('\n')}`); + } else { + logger.ci('sdkmanager glob candidates:\n > '); + } + throw new Error('Failed to resolve sdkmanager in expected locations'); } await fs.promises.access(sdkmanagerPath, fs.constants.R_OK); diff --git a/src/logging.ts b/src/logging.ts index 166143fa..2599cd54 100644 --- a/src/logging.ts +++ b/src/logging.ts @@ -136,6 +136,74 @@ export class Logger { this.log(LogLevel.ERROR, message, optionalParams); } + /** + * Annotates a file and line number in CI environments that support it. + * @param logLevel The level of the log. + * @param message The message to annotate. + * @param file The file to annotate. + * @param line The line number to annotate. + * @param endLine The end line number to annotate. + * @param column The column number to annotate. + * @param endColumn The end column number to annotate. + * @param title The title of the annotation. + */ + public annotate(logLevel: LogLevel, message: string, file?: string, line?: number, endLine?: number, column?: number, endColumn?: number, title?: string): void { + let annotation = ''; + + switch (this._ci) { + case 'GITHUB_ACTIONS': { + var level: string; + switch (logLevel) { + case LogLevel.CI: + case LogLevel.INFO: + case LogLevel.DEBUG: { + level = 'notice'; + break; + } + case LogLevel.WARN: { + level = 'warning'; + break; + } + case LogLevel.ERROR: { + level = 'error'; + break; + } + } + + let parts: string[] = []; + + if (file !== undefined && file.length > 0) { + parts.push(`file=${file}`); + } + + if (line !== undefined && line > 0) { + parts.push(`line=${line}`); + } + + if (endLine !== undefined && endLine > 0) { + parts.push(`endLine=${endLine}`); + } + + if (column !== undefined && column > 0) { + parts.push(`col=${column}`); + } + + if (endColumn !== undefined && endColumn > 0) { + parts.push(`endColumn=${endColumn}`); + } + + if (title !== undefined && title.length > 0) { + parts.push(`title=${title}`); + } + + annotation = `::${level} ${parts.join(',')}::${message}`; + break; + } + } + + process.stdout.write(`${annotation}\n`); + } + private shouldLog(level: LogLevel): boolean { if (level === LogLevel.CI) { return true; } const levelOrder = [LogLevel.DEBUG, LogLevel.INFO, LogLevel.WARN, LogLevel.ERROR]; @@ -187,4 +255,21 @@ export class Logger { } } } + + public CI_appendWorkflowSummary(telemetry: any[]) { + switch (this._ci) { + case 'GITHUB_ACTIONS': { + const githubSummary = process.env.GITHUB_STEP_SUMMARY; + + if (githubSummary) { + let table = `| Key | Value |\n| --- | ----- |\n`; + telemetry.forEach(item => { + table += `| ${item.key} | ${item.value} |\n`; + }); + + fs.appendFileSync(githubSummary, table, { encoding: 'utf8' }); + } + } + } + } } \ No newline at end of file diff --git a/src/unity-editor.ts b/src/unity-editor.ts index 6aa6d127..3c4aba6d 100644 --- a/src/unity-editor.ts +++ b/src/unity-editor.ts @@ -261,7 +261,7 @@ export class UnityEditor { } const logPath: string = GetArgumentValueAsString('-logFile', command.args); - logTail = TailLogFile(logPath); + logTail = TailLogFile(logPath, command.projectPath); const commandStr = `\x1b[34m${this.editorPath} ${command.args.join(' ')}\x1b[0m`; this.logger.startGroup(commandStr); @@ -269,6 +269,15 @@ export class UnityEditor { throw new Error(`Cannot execute Unity ${this.version.toString()} on Apple Silicon Macs.`); } + const linuxEnvOverrides = process.platform === 'linux' + ? await this.prepareLinuxAudioEnvironment() + : undefined; + const baseEditorEnv: NodeJS.ProcessEnv = { + ...process.env, + UNITY_THISISABUILDMACHINE: '1', + ...(linuxEnvOverrides ?? {}) + }; + if (process.platform === 'linux' && !command.args.includes('-nographics') ) { @@ -277,9 +286,8 @@ export class UnityEditor { [this.editorPath, ...command.args], { stdio: ['ignore', 'ignore', 'ignore'], env: { - ...process.env, - DISPLAY: ':99', - UNITY_THISISABUILDMACHINE: '1' + ...baseEditorEnv, + DISPLAY: baseEditorEnv.DISPLAY || ':99' } }); } else if (process.arch === 'arm64' && @@ -300,10 +308,7 @@ export class UnityEditor { this.editorPath, command.args, { stdio: ['ignore', 'ignore', 'ignore'], - env: { - ...process.env, - UNITY_THISISABUILDMACHINE: '1' - } + env: baseEditorEnv }); } @@ -383,6 +388,43 @@ export class UnityEditor { return path.join(logsDir, `${prefix ? prefix + '-' : ''}Unity-${timestamp}.log`); } + private async prepareLinuxAudioEnvironment(): Promise { + if (process.platform !== 'linux') { + return {}; + } + + const envOverrides: NodeJS.ProcessEnv = { + SDL_AUDIODRIVER: process.env.SDL_AUDIODRIVER || 'dummy', + AUDIODRIVER: process.env.AUDIODRIVER || 'dummy', + AUDIODEV: process.env.AUDIODEV || 'null', + ALSA_CARD: process.env.ALSA_CARD || 'Loopback', + PULSE_SINK: process.env.PULSE_SINK || 'unity_dummy' + }; + + const defaultRuntimeDir = `/run/user/${typeof process.getuid === 'function' ? process.getuid() : 1000}`; + const runtimeDir = process.env.XDG_RUNTIME_DIR || defaultRuntimeDir; + envOverrides.XDG_RUNTIME_DIR = runtimeDir; + + try { + await fs.promises.mkdir(runtimeDir, { recursive: true, mode: 0o700 }); + } catch (error) { + this.logger.debug(`Failed to ensure XDG_RUNTIME_DIR (${runtimeDir}): ${error}`); + } + + await this.tryExec('bash', ['-c', 'pulseaudio --check 2>/dev/null || pulseaudio --start --exit-idle-time=-1 || true']); + await this.tryExec('bash', ['-c', 'command -v pactl >/dev/null 2>&1 && { pactl list short sinks 2>/dev/null | grep -q unity_dummy || pactl load-module module-null-sink sink_name=unity_dummy sink_properties=device.description=UnityCI >/tmp/unity-null-sink.id; } || true']); + + return envOverrides; + } + + private async tryExec(command: string, args: string[]): Promise { + try { + await Exec(command, args, { silent: true, showCommand: false }); + } catch (error) { + this.logger.debug(`Skipped helper command "${command} ${args.join(' ')}": ${error}`); + } + } + /** * Get the root path of the Unity Editor installation based on the provided editor path. * @param editorPath The path to the Unity Editor executable. diff --git a/src/unity-hub.ts b/src/unity-hub.ts index 5c8510db..c54e6f59 100644 --- a/src/unity-hub.ts +++ b/src/unity-hub.ts @@ -108,9 +108,13 @@ export class UnityHub { try { exitCode = await new Promise((resolve, reject) => { let isSettled: boolean = false; // Has the promise been settled (resolved or rejected)? - let isHubTaskComplete: boolean = false; // Has the Unity Hub tasks completed successfully? + 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 tasksCompleteMessage = 'All Tasks Completed Successfully.'; + const tasksCompleteMessages: string[] = [ + 'All Tasks Completed Successfully.', + 'Completed with errors.' + ]; const child = spawn(executable, execArgs, { stdio: ['ignore', 'pipe', 'pipe'] }); @@ -140,8 +144,9 @@ export class UnityHub { lineBuffer = ''; } - if (lines.includes(tasksCompleteMessage)) { - isHubTaskComplete = true; + if (lines.some(line => tasksCompleteMessages.includes(line))) { + isHubTaskCompleteSuccess = lines.includes('All Tasks Completed Successfully.'); + isHubTaskCompleteFailed = lines.includes('Completed with errors.'); if (child?.pid) { try { @@ -159,7 +164,13 @@ export class UnityHub { } catch { // Ignore, process may have already exited } finally { - settle(0); + if (isHubTaskCompleteSuccess) { + settle(0); + } else if (isHubTaskCompleteFailed) { + settle(1); + } else { + settle(null); + } } } } @@ -186,8 +197,9 @@ export class UnityHub { lineBuffer = ''; const outputLines = lines.filter(line => !ignoredLines.some(ignored => line.includes(ignored))); - if (outputLines.includes(tasksCompleteMessage)) { - isHubTaskComplete = true; + 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) { @@ -210,12 +222,7 @@ export class UnityHub { isSettled = true; removeListeners(); flushOutput(); - - if (isHubTaskComplete) { - resolve(0); - } else { - resolve(code === null ? 0 : code); - } + resolve(code === null ? 0 : code); } child.stdout.on('data', processOutput); @@ -439,7 +446,7 @@ wget -qO - https://hub.unity3d.com/linux/keys/public | gpg --dearmor | tee /usr/ echo "deb [signed-by=/usr/share/keyrings/Unity_Technologies_ApS.gpg] https://hub.unity3d.com/linux/repos/deb stable main" > /etc/apt/sources.list.d/unityhub.list echo "deb https://archive.ubuntu.com/ubuntu jammy main universe" | tee /etc/apt/sources.list.d/jammy.list apt-get update -apt-get install -y --no-install-recommends unityhub${version ? '=' + version : ''} ffmpeg libgtk2.0-0 libglu1-mesa libgconf-2-4 libncurses5 +apt-get install -y --no-install-recommends unityhub${version ? '=' + version : ''} ffmpeg libgtk2.0-0 libglu1-mesa libgconf-2-4 libncurses5 pulseaudio apt-get clean sed -i 's/^\\(.*DISPLAY=:.*XAUTHORITY=.*\\)\\( "\\$@" \\)2>&1$/\\1\\2/' /usr/bin/xvfb-run printf '#!/bin/bash\nxvfb-run --auto-servernum /opt/unityhub/unityhub "$@" 2>/dev/null' | tee /usr/bin/unity-hub >/dev/null diff --git a/src/utilities.ts b/src/utilities.ts index 63c1c8e3..55905370 100644 --- a/src/utilities.ts +++ b/src/utilities.ts @@ -26,6 +26,24 @@ export async function ResolveGlobToPath(globs: string[]): Promise { throw new Error(`No accessible file found for glob pattern: ${path.normalize(globPath)}`); } +/** + * Resolves a list of glob patterns to the first matching file path. + * @param globsList A list of arrays of path segments that may include glob patterns. + * @returns The first matching file path, or undefined if none found. + */ +export async function ResolvePathCandidates(globsList: string[][]): Promise { + for (const globPath of globsList) { + try { + return await ResolveGlobToPath(globPath); + } catch (error) { + const joinedPath = path.join(...globPath); + logger.debug(`Failed to resolve sdkmanager using glob: ${joinedPath}`); + } + } + + return undefined; +} + /** * Prompts the user for input, masking the input with asterisks. * @param prompt The prompt message to display. @@ -334,29 +352,27 @@ export interface LogTailResult { tailPromise: Promise; /** Function to signal that log tailing should end */ stopLogTail: () => void; + /** Collected telemetry objects parsed from lines beginning with '##utp:' */ + telemetry: any[]; } /** * Tails a log file using fs.watch and ReadStream for efficient reading. * @param logPath The path to the log file to tail. + * @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): LogTailResult { +export function TailLogFile(logPath: string, projectPath: string | undefined): LogTailResult { let logEnded = false; let lastSize = 0; const logPollingInterval = 250; + const telemetry: any[] = []; async function readNewLogContent(): Promise { try { - if (!fs.existsSync(logPath)) { - return; - } - + if (!fs.existsSync(logPath)) { return; } const stats = await fs.promises.stat(logPath); - - if (stats.size < lastSize) { - lastSize = 0; - } + if (stats.size < lastSize) { lastSize = 0; } if (stats.size > lastSize) { const bytesToRead = stats.size - lastSize; @@ -375,12 +391,44 @@ export function TailLogFile(logPath: string): LogTailResult { if (bytesToRead > 0) { const chunk = buffer.toString('utf8'); + // Parse telemetry lines in this chunk (lines starting with '##utp:') try { - process.stdout.write(chunk); + const lines = chunk.split(/\r?\n/); + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!line) { continue; } + if (line.startsWith('##utp:')) { + const jsonPart = line.substring('##utp:'.length).trim(); + try { + const utp = JSON.parse(jsonPart); + telemetry.push(utp); + + if (utp.severity && utp.severity.toLowerCase() === 'error') { + const file = utp.file ? utp.file.replace(/\\/g, '/') : undefined; + const lineNum = utp.line ? utp.line : undefined; + const message = utp.message; + const stacktrace = utp.stacktrace ? `${utp.stacktrace}` : undefined; + if (!message.startsWith(`\n::error::\u001B[31m`)) { // indicates a duplicate annotation + // only annotate if the file is within the current project + if (projectPath && file && file.startsWith(projectPath)) { + Logger.instance.annotate(LogLevel.ERROR, stacktrace == undefined ? message : `${message}\n${stacktrace}`, file, lineNum); + } else { + Logger.instance.error(stacktrace == undefined ? message : `${message}\n${stacktrace}`); + } + } + } + } catch (error) { + logger.warn(`Failed to parse telemetry JSON: ${error} -- raw: ${jsonPart}`); + } + } else { + process.stdout.write(`${line}\n`); + } + } } catch (error: any) { if (error.code !== 'EPIPE') { throw error; } + logger.warn(`Error while parsing telemetry from log chunk: ${error}`); } } } @@ -402,6 +450,7 @@ export function TailLogFile(logPath: string): LogTailResult { await readNewLogContent(); try { + // write a final newline to separate log output process.stdout.write('\n'); } catch (error: any) { if (error.code !== 'EPIPE') { @@ -420,7 +469,7 @@ export function TailLogFile(logPath: string): LogTailResult { logEnded = true; } - return { tailPromise, stopLogTail }; + return { tailPromise, stopLogTail, telemetry }; } /** diff --git a/tests/mocks/electron-asar.js b/tests/mocks/electron-asar.js index 8a83d2b3..ac4b15b7 100644 --- a/tests/mocks/electron-asar.js +++ b/tests/mocks/electron-asar.js @@ -1,9 +1,10 @@ // 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(''); + extractFile: (asarPath, file) => { + if (file === 'package.json') { + return Buffer.from(JSON.stringify({ version: '1.0.0' })); } + return Buffer.from(''); + }, + uncacheAll: () => { }, };