diff --git a/.github/workflows/build-options.json b/.github/workflows/build-options.json index 7065201..4086875 100644 --- a/.github/workflows/build-options.json +++ b/.github/workflows/build-options.json @@ -35,6 +35,10 @@ "os": "windows-latest", "build-target": "StandaloneWindows64" }, + { + "os": "windows-latest", + "build-target": "Android" + }, { "os": "macos-latest", "build-target": "StandaloneOSX" @@ -53,6 +57,11 @@ "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)" @@ -61,6 +70,11 @@ "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/unity-build.yml b/.github/workflows/unity-build.yml index 23fc12a..aaf9062 100644 --- a/.github/workflows/unity-build.yml +++ b/.github/workflows/unity-build.yml @@ -25,6 +25,13 @@ jobs: UNITY_PROJECT_PATH: '' # Set from create-project step 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' }} + uses: endersonmenezes/free-disk-space@v2 + with: + remove_android: true + remove_dotnet: false + remove_tool_cache: false - uses: actions/checkout@v5 - uses: actions/setup-node@v4 with: @@ -96,6 +103,14 @@ jobs: npm install -g openupm-cli cd "${UNITY_PROJECT_PATH}" openupm add com.utilities.buildpipeline + - name: Update Android Target Sdk Version + if: ${{ matrix.build-target == 'Android' }} + shell: bash + run: | + # update AndroidTargetSdkVersion to 32 in ProjectSettings/ProjectSettings.asset + sed -i 's/AndroidTargetSdkVersion: [0-9]*/AndroidTargetSdkVersion: 32/' "${UNITY_PROJECT_PATH}/ProjectSettings/ProjectSettings.asset" + # ensure android dependencies are installed + unity-cli setup-unity -p "${UNITY_PROJECT_PATH}" -m android - name: Build Project if: ${{ env.RUN_BUILD == 'true' }} timeout-minutes: 60 @@ -120,7 +135,7 @@ jobs: if: always() uses: actions/upload-artifact@v4 with: - name: ${{ github.run_id }}.${{ github.run_attempt }} ${{ matrix.unity-version }} ${{ matrix.build-target }} logs + 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/package-lock.json b/package-lock.json index 0f39c7a..0e9cf7c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@rage-against-the-pixel/unity-cli", - "version": "1.3.3", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@rage-against-the-pixel/unity-cli", - "version": "1.3.3", + "version": "1.4.0", "license": "MIT", "dependencies": { "@electron/asar": "^4.0.1", diff --git a/package.json b/package.json index a92d061..3fc8821 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rage-against-the-pixel/unity-cli", - "version": "1.3.3", + "version": "1.4.0", "description": "A command line utility for the Unity Game Engine.", "author": "RageAgainstThePixel", "license": "MIT", diff --git a/src/android-sdk.ts b/src/android-sdk.ts index 4b9f48b..ebf70d9 100644 --- a/src/android-sdk.ts +++ b/src/android-sdk.ts @@ -9,20 +9,20 @@ import { ReadFileContents, ResolveGlobToPath } from './utilities'; +import { satisfies } from 'semver'; const logger = Logger.instance; /** * Checks if the required Android SDK is installed for the given Unity Editor and Project. - * @param editorPath The path to the Unity Editor executable. + * @param editor The UnityEditor instance. * @param projectPath The path to the Unity project. * @returns A promise that resolves when the check is complete. */ -export async function CheckAndroidSdkInstalled(editorPath: string, projectPath: string): Promise { - logger.ci(`Checking Android SDK installation for:\n > Editor: ${editorPath}\n > Project: ${projectPath}`); +export async function CheckAndroidSdkInstalled(editor: UnityEditor, projectPath: string): Promise { + logger.ci(`Checking Android SDK installation for:\n > Editor: ${editor.editorRootPath}\n > Project: ${projectPath}`); let sdkPath = undefined; await createRepositoryCfg(); - const rootEditorPath = UnityEditor.GetEditorRootPath(editorPath); const projectSettingsPath = path.join(projectPath, 'ProjectSettings/ProjectSettings.asset'); const projectSettingsContent = await ReadFileContents(projectSettingsPath); const matchResult = projectSettingsContent.match(/(?<=AndroidTargetSdkVersion: )\d+/); @@ -31,7 +31,7 @@ export async function CheckAndroidSdkInstalled(editorPath: string, projectPath: if (androidTargetSdk === undefined || androidTargetSdk === 0) { return; } - sdkPath = await getAndroidSdkPath(rootEditorPath, androidTargetSdk); + sdkPath = await getAndroidSdkPath(editor, androidTargetSdk); if (sdkPath) { logger.ci(`Target Android SDK android-${androidTargetSdk} Installed in:\n > "${sdkPath}"`); @@ -39,15 +39,15 @@ export async function CheckAndroidSdkInstalled(editorPath: string, projectPath: } logger.info(`Installing Android Target SDK:\n > android-${androidTargetSdk}`); - const sdkManagerPath = await getSdkManager(rootEditorPath); - const javaSdk = await getJDKPath(rootEditorPath); + const sdkManagerPath = await getSdkManager(editor); + const javaSdk = await getJDKPath(editor); await execSdkManager(sdkManagerPath, javaSdk, ['--licenses']); await execSdkManager(sdkManagerPath, javaSdk, ['--update']); await execSdkManager(sdkManagerPath, javaSdk, ['platform-tools', `platforms;android-${androidTargetSdk}`]); - sdkPath = await getAndroidSdkPath(rootEditorPath, androidTargetSdk); + sdkPath = await getAndroidSdkPath(editor, androidTargetSdk); if (!sdkPath) { - throw new Error(`Failed to install android-${androidTargetSdk} in ${rootEditorPath}`); + throw new Error(`Failed to install android-${androidTargetSdk} in ${editor.editorRootPath}`); } logger.ci(`Target Android SDK Installed in:\n > "${sdkPath}"`); @@ -61,11 +61,23 @@ async function createRepositoryCfg(): Promise { await fileHandle.close(); } -async function getJDKPath(rootEditorPath: string): Promise { - const jdkPath = await ResolveGlobToPath([rootEditorPath, '**', 'AndroidPlayer', 'OpenJDK']); +async function getJDKPath(editor: UnityEditor): Promise { + let jdkPath: string | undefined = undefined; - if (!jdkPath) { - throw new Error(`Failed to resolve OpenJDK in ${rootEditorPath}`); + if (editor.version.isGreaterThanOrEqualTo('2019.0.0')) { + logger.debug('Using JDK bundled with Unity 2019+'); + jdkPath = await ResolveGlobToPath([editor.editorRootPath, '**', 'AndroidPlayer', 'OpenJDK/']); + + if (!jdkPath) { + throw new Error(`Failed to resolve OpenJDK in ${editor.editorRootPath}`); + } + } else { + logger.debug('Using system JDK for Unity versions prior to 2019'); + jdkPath = process.env.JAVA_HOME || process.env.JDK_HOME; + + if (!jdkPath) { + throw new Error('JDK installation not found: No system JAVA_HOME or JDK_HOME defined'); + } } await fs.promises.access(jdkPath, fs.constants.R_OK); @@ -73,19 +85,55 @@ async function getJDKPath(rootEditorPath: string): Promise { return jdkPath; } -async function getSdkManager(rootEditorPath: string): Promise { +async function getSdkManager(editor: UnityEditor): Promise { let globPath: string[] = []; - switch (process.platform) { - case 'darwin': - case 'linux': - globPath = [rootEditorPath, '**', 'AndroidPlayer', '**', 'sdkmanager']; - break; - case 'win32': - globPath = [rootEditorPath, '**', 'AndroidPlayer', '**', 'sdkmanager.bat']; - break; - default: - throw new Error(`Unsupported platform: ${process.platform}`); + 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']; + break; + case 'win32': + globPath = [editor.editorRootPath, '**', 'AndroidPlayer', '**', 'sdkmanager.bat']; + break; + default: + throw new Error(`Unsupported platform: ${process.platform}`); + } + } else if (editor.version.range('>=2021.0.0')) { + logger.debug('Using cmdline-tools sdkmanager bundled with Unity 2021+'); + switch (process.platform) { + case 'darwin': + case 'linux': + globPath = [editor.editorRootPath, '**', 'AndroidPlayer', '**', 'cmdline-tools', '**', 'sdkmanager']; + break; + case 'win32': + globPath = [editor.editorRootPath, '**', 'AndroidPlayer', '**', 'cmdline-tools', '**', 'sdkmanager.bat']; + break; + default: + throw new Error(`Unsupported platform: ${process.platform}`); + } + } else { + logger.debug('Using system sdkmanager'); + const systemSdkPath = process.env.ANDROID_SDK_ROOT || process.env.ANDROID_HOME; + + if (!systemSdkPath) { + throw new Error('Android installation not found: No system ANDROID_SDK_ROOT or ANDROID_HOME defined'); + } + + 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']; + break; + default: + throw new Error(`Unsupported platform: ${process.platform}`); + } } + const sdkmanagerPath = await ResolveGlobToPath(globPath); if (!sdkmanagerPath) { @@ -97,19 +145,36 @@ async function getSdkManager(rootEditorPath: string): Promise { return sdkmanagerPath; } -async function getAndroidSdkPath(rootEditorPath: string, androidTargetSdk: number): Promise { - logger.ci(`Attempting to locate Android SDK Path...\n > editorPath: ${rootEditorPath}\n > androidTargetSdk: ${androidTargetSdk}`); +async function getAndroidSdkPath(editor: UnityEditor, androidTargetSdk: number): Promise { + logger.ci(`Attempting to locate Android SDK Path...\n > editorRootPath: ${editor.editorRootPath}\n > androidTargetSdk: ${androidTargetSdk}`); let sdkPath: string; - try { - sdkPath = await ResolveGlobToPath([rootEditorPath, '**', 'PlaybackEngines', 'AndroidPlayer', 'SDK', 'platforms', `android-${androidTargetSdk}/`]); - await fs.promises.access(sdkPath, fs.constants.R_OK); - } catch (error) { - logger.debug(`android-${androidTargetSdk} not installed`); - return undefined; + // if 2019+ test editor path, else use system android installation + if (editor.version.isGreaterThanOrEqualTo('2019.0.0')) { + logger.debug('Using Android SDK bundled with Unity 2019+'); + try { + sdkPath = await ResolveGlobToPath([editor.editorRootPath, '**', 'PlaybackEngines', 'AndroidPlayer', 'SDK', 'platforms', `android-${androidTargetSdk}/`]); + } catch (error) { + logger.debug(`android-${androidTargetSdk} not installed`); + return undefined; + } + } else { // fall back to system android installation + logger.debug('Using system Android SDK for Unity versions prior to 2019'); + try { + const systemSdkPath = process.env.ANDROID_SDK_ROOT || process.env.ANDROID_HOME; + + if (!systemSdkPath) { + logger.debug('Android installation not found: No system ANDROID_SDK_ROOT or ANDROID_HOME defined'); + return undefined; + } + + sdkPath = await ResolveGlobToPath([systemSdkPath, 'platforms', `android-${androidTargetSdk}/`]); + } catch (error) { + logger.debug(`android-${androidTargetSdk} not installed`); + return undefined; + } } - logger.ci(`Android sdkPath:\n > "${sdkPath}"`); return sdkPath; } @@ -123,25 +188,27 @@ async function execSdkManager(sdkManagerPath: string, javaPath: string, args: st fs.accessSync(sdkManagerPath, fs.constants.R_OK | fs.constants.X_OK); } + if (process.platform === 'win32' && !await isProcessElevated()) { + throw new Error('Android SDK installation requires elevated (administrator) privileges. Please rerun as Administrator.'); + } + try { - exitCode = await new Promise((resolve, reject) => { + exitCode = await new Promise(async (resolve, reject) => { + let cmdEnv = { ...process.env }; + cmdEnv.JAVA_HOME = javaPath; + cmdEnv.JDK_HOME = javaPath; + cmdEnv.SKIP_JDK_VERSION_CHECK = 'true'; let cmd = sdkManagerPath; let cmdArgs = args; if (process.platform === 'win32') { - if (!isProcessElevated()) { - throw new Error('Android SDK installation requires elevated (administrator) privileges. Please rerun as Administrator.'); - } - cmd = 'cmd.exe'; cmdArgs = ['/c', sdkManagerPath, ...args]; } const child = spawn(cmd, cmdArgs, { stdio: ['pipe', 'pipe', 'pipe'], - env: { - JAVA_HOME: process.platform === 'win32' ? `"${javaPath}"` : javaPath - } + env: cmdEnv }); const sigintHandler = () => child.kill('SIGINT'); const sigtermHandler = () => child.kill('SIGTERM'); diff --git a/src/cli.ts b/src/cli.ts index 4d0edc1..95479e9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -225,7 +225,14 @@ program.command('setup-unity') process.exit(1); } - const unityVersion = unityProject?.version ?? new UnityVersion(options.unityVersion, options.changeset, options.arch); + let unityVersion: UnityVersion; + + if (options.unityVersion) { + unityVersion = new UnityVersion(options.unityVersion, options.changeset, options.arch); + } 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(); @@ -262,7 +269,7 @@ program.command('setup-unity') output['UNITY_PROJECT_PATH'] = unityProject.projectPath; if (modules.includes('android')) { - await CheckAndroidSdkInstalled(unityEditor.editorPath, unityProject.projectPath); + await CheckAndroidSdkInstalled(unityEditor, unityProject.projectPath); } } diff --git a/src/unity-editor.ts b/src/unity-editor.ts index 7bdbc33..408a17e 100644 --- a/src/unity-editor.ts +++ b/src/unity-editor.ts @@ -67,7 +67,33 @@ export class UnityEditor { this.version = version; } - this.autoAddNoGraphics = this.version.satisfies('>2018.0.0'); + this.autoAddNoGraphics = this.version.isGreaterThan('2018.0.0'); + + const hubMetaDataPath = path.join(this.editorRootPath, 'metadata.hub.json'); + if (!fs.existsSync(hubMetaDataPath)) { + const metadata = { + productName: `Unity ${this.version.version.toString()}`, + entitlements: [], + releaseStream: '', + isLTS: null + }; + fs.writeFileSync(hubMetaDataPath, JSON.stringify(metadata), { encoding: 'utf-8' }); + } else { + const metadataContent = fs.readFileSync(hubMetaDataPath, { encoding: 'utf-8' }); + const metadata = JSON.parse(metadataContent); + + if (!metadata.productName) { + // projectName must be the first property + const newMetadata: any = { + productName: `Unity ${this.version.version.toString()}` + }; + Object.keys(metadata).forEach(key => { + if (key === 'productName') { return; } + newMetadata[key] = metadata[key]; + }); + fs.writeFileSync(hubMetaDataPath, JSON.stringify(newMetadata), { encoding: 'utf-8' }); + } + } } /** @@ -84,12 +110,12 @@ export class UnityEditor { return undefined; } - // Build a regex to match the template name and version - // e.g., com.unity.template.3d(-cross-platform)?.*[0-9]+\.[0-9]+\.[0-9]+\.tgz - // Accepts either a full regex or a simple string + // Build a regex to match the template name and optional version suffix + // e.g., com.unity.template.3d(-cross-platform)?.* + // Supports files (.tgz / .tar.gz) and legacy folder templates without a suffix. let regex: RegExp; try { - regex = new RegExp(`^${template}.*[0-9]+\\.[0-9]+\\.[0-9]+\\.tgz$`); + regex = new RegExp(`^${template}(?:[-.].*)?(?:\.tgz|\.tar\.gz)?$`); } catch (e) { throw new Error(`Invalid template regex: ${template}`); } @@ -119,11 +145,14 @@ export class UnityEditor { * @returns An array of available template file names. */ public GetAvailableTemplates(): string[] { + if (this.version.isLessThan('2018.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); - const templates: string[] = []; - if (process.platform === 'darwin') { templateDir = path.join(path.dirname(editorRoot), 'Resources', 'PackageManager', 'ProjectTemplates'); } else { @@ -132,17 +161,20 @@ export class UnityEditor { this.logger.debug(`Looking for templates in: ${templateDir}`); - // Check if the template directory exists if (!fs.existsSync(templateDir) || !fs.statSync(templateDir).isDirectory()) { - return templates; + 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)); + } } - // Find all .tgz packages in the template directory - const packages = fs.readdirSync(templateDir) - .filter(f => f.endsWith('.tgz')) - .map(f => path.join(templateDir, f)); - templates.push(...packages); this.logger.debug(`Found ${templates.length} templates:\n${templates.map(t => ` - ${t}`).join('\n')}`); return templates; } diff --git a/src/unity-hub.ts b/src/unity-hub.ts index 479683f..987c416 100644 --- a/src/unity-hub.ts +++ b/src/unity-hub.ts @@ -27,6 +27,7 @@ import { UnityRelease, Release } from '@rage-against-the-pixel/unity-releases-api'; +import { get } from 'http'; interface ReleaseInfo { unityRelease: UnityRelease; @@ -92,17 +93,18 @@ export class UnityHub { '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', - 'Error: No modules found to install.', 'Failed to execute the command due the following, please see \'-- --headless help\' for assistance.', - '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`.', + '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 { @@ -131,8 +133,7 @@ export class UnityHub { try { const chunk = data.toString(); const fullChunk = lineBuffer + chunk; - const lines = fullChunk.split('\n') // split by newline - .map(line => line.replace(/\r$/, '')) // remove trailing carriage return + const lines = fullChunk.split(/\r?\n/) // split by newline .filter(line => line.length > 0); // filter out empty lines if (!chunk.endsWith('\n')) { @@ -141,9 +142,7 @@ export class UnityHub { lineBuffer = ''; } - const outputLines = lines.filter(line => !ignoredLines.some(ignored => line.includes(ignored))); - - if (outputLines.includes(tasksCompleteMessage)) { + if (lines.includes(tasksCompleteMessage)) { isHubTaskComplete = true; if (child?.pid) { @@ -167,10 +166,10 @@ export class UnityHub { } } - for (const line of outputLines) { + for (const line of lines) { output += `${line}\n`; - if (!options.silent) { + if (!options.silent && !ignoredLines.some(ignored => line.includes(ignored))) { process.stdout.write(`${line}\n`); } } @@ -184,8 +183,7 @@ export class UnityHub { function flushOutput(): void { try { if (lineBuffer.length > 0) { - const lines = lineBuffer.split('\n') // split by newline - .map(line => line.replace(/\r$/, '')) // remove trailing carriage return + 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))); @@ -254,6 +252,7 @@ export class UnityHub { case 'No modules found to install.': break; default: + this.logger.debug(output); throw new Error(`Failed to execute Unity Hub (exit code: ${exitCode}) ${errorMessage}`); } } @@ -606,7 +605,7 @@ chmod -R 777 "$hubPath"`]); } try { - this.logger.info(`Checking installed modules for Unity ${unityVersion.toString()}...`); + this.logger.info(`Validating installed modules for Unity ${unityVersion.toString()}...`); const [installedModules, additionalModules] = await this.checkEditorModules(editorPath, unityVersion, modules); if (installedModules && installedModules.length > 0) { @@ -616,6 +615,7 @@ chmod -R 777 "$hubPath"`]); this.logger.info(` > ${module}`); } } + if (additionalModules && additionalModules.length > 0) { this.logger.info(`Additional Modules:`); @@ -627,6 +627,8 @@ chmod -R 777 "$hubPath"`]); if (error.message.includes(`No modules found`)) { await DeleteDirectory(editorPath); await this.GetEditor(unityVersion, modules); + } else { + throw error; } } @@ -705,7 +707,7 @@ chmod -R 777 "$hubPath"`]); editorPath = exactEditor.editorPath; } else if (allowPartialMatches) { // Fallback: semver satisfies - const versionEditors = editors.filter(e => e.version && unityVersion.satisfies(e.version.version)); + const versionEditors = editors.filter(e => e.version && unityVersion.satisfies(e.version)); if (versionEditors.length === 0) { return undefined; @@ -812,6 +814,7 @@ done // - otherwise "YYYY" const fullUnityVersionPattern = /^\d{1,4}\.\d+\.\d+[abcfpx]\d+$/; let version: string; + if (fullUnityVersionPattern.test(unityVersion.version)) { version = unityVersion.version; } else { @@ -851,40 +854,58 @@ done }; this.logger.debug(`Get Unity Release: ${JSON.stringify(request, null, 2)}`); - const { data, error } = await releasesClient.api.Release.getUnityReleases(request); - if (error) { - throw new Error(`Failed to get Unity releases: ${JSON.stringify(error, null, 2)}`); - } + async function getRelease() { + const { data, error } = await releasesClient.api.Release.getUnityReleases(request); - if (!data || !data.results || data.results.length === 0) { - throw new Error(`No Unity releases found for version: ${version}`); - } + if (error) { + throw new Error(`Failed to get Unity releases: ${JSON.stringify(error, null, 2)}`); + } - // Filter to stable 'f' releases only unless the user explicitly asked for a pre-release - const isExplicitPrerelease = /[abcpx]$/.test(unityVersion.version) || /[abcpx]/.test(unityVersion.version); - const releases: ReleaseInfo[] = (data.results || []) - .filter(release => isExplicitPrerelease || release.version.includes('f')) - .map(release => ({ - unityRelease: release, - unityVersion: new UnityVersion(release.version, release.shortRevision, unityVersion.architecture) - })); - - if (releases.length === 0) { - throw new Error(`No suitable Unity releases (stable) found for version: ${version}`); + if (!data || !data.results || data.results.length === 0) { + throw new Error(`No Unity releases found for version: ${version}`); + } + + // Filter to stable 'f' releases only unless the user explicitly asked for a pre-release + const isExplicitPrerelease = /[abcpx]$/.test(unityVersion.version) || /[abcpx]/.test(unityVersion.version); + const releases: ReleaseInfo[] = (data.results || []) + .filter(release => isExplicitPrerelease || release.version.includes('f')) + .map(release => ({ + unityRelease: release, + unityVersion: new UnityVersion(release.version, release.shortRevision, unityVersion.architecture) + })); + + if (releases.length === 0) { + throw new Error(`No suitable Unity releases (stable) found for version: ${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; } - releases.sort((a, b) => UnityVersion.compare(b.unityVersion, a.unityVersion)); + try { + return await getRelease(); + } catch (error) { + if (error instanceof Error && error.message.includes('fetch failed')) { + // Transient network error, retry once + return await getRelease(); + } - this.logger.debug(`Found ${releases.length} matching Unity releases for version: ${version}`); - releases.forEach(release => { - this.logger.debug(` - ${release.unityRelease.version} (${release.unityRelease.shortRevision}) - ${release.unityRelease.recommended}`); - }); - const latest = releases[0]!.unityRelease!; - return latest; + 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; @@ -931,8 +952,7 @@ done 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']); + 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) { diff --git a/src/unity-project.ts b/src/unity-project.ts index 288096a..8d30451 100644 --- a/src/unity-project.ts +++ b/src/unity-project.ts @@ -63,21 +63,17 @@ export class UnityProject { this.projectVersionPath = path.join(this.projectPath, 'ProjectSettings', 'ProjectVersion.txt'); fs.accessSync(this.projectVersionPath, fs.constants.R_OK); const versionText = fs.readFileSync(this.projectVersionPath, 'utf-8'); - const match = versionText.match(/m_EditorVersionWithRevision: (?(?:(?\d+)\.)?(?:(?\d+)\.)?(?:(?\d+[abcfpx]\d+)\b))\s?(?:\((?\w+)\))?/); + const match = versionText.match(/(?:m_EditorVersion|m_EditorVersionWithRevision): (?(?:(?\d+)\.)?(?:(?\d+)\.)?(?:(?\d+[abcfpx]\d+)\b))\s?(?:\((?\w+)\))?/); if (!match) { - throw Error(`No version match found!`); + throw Error(`No version match found!\nProjectVersion.txt content:\n${versionText}`); } if (!match.groups?.version) { - throw Error(`No version group found!`); + throw Error(`No version group found!\nProjectVersion.txt content:\n${versionText}`); } - if (!match.groups?.changeset) { - throw Error(`No changeset group found!`); - } - - this.version = new UnityVersion(match.groups.version, match.groups.changeset, undefined); + this.version = new UnityVersion(match.groups!.version!, match.groups?.changeset, undefined); } /** diff --git a/src/unity-version.ts b/src/unity-version.ts index 9ad9081..d67766a 100644 --- a/src/unity-version.ts +++ b/src/unity-version.ts @@ -1,6 +1,8 @@ import * as os from 'os'; import { Logger } from './logging'; import { + Range, + RangeOptions, SemVer, coerce, compare, @@ -42,7 +44,7 @@ export class UnityVersion { } static compare(a: UnityVersion, b: UnityVersion): number { - const baseComparison = compare(a.semVer, b.semVer, true); + const baseComparison = UnityVersion.baseCompare(a, b); if (baseComparison !== 0) { return baseComparison; @@ -51,6 +53,10 @@ export class UnityVersion { return UnityVersion.compareBuildMetadata(a.semVer, b.semVer); } + static baseCompare(a: UnityVersion, b: UnityVersion): number { + return compare(a.semVer, b.semVer, true); + } + toString(): string { return this.changeset ? `${this.version} (${this.changeset})` : this.version; } @@ -103,14 +109,32 @@ export class UnityVersion { return this; } - satisfies(version: string): boolean { - const coercedVersion = coerce(version); + satisfies(version: UnityVersion): boolean { + return satisfies(version.semVer, `^${this.semVer.version}`); + } - if (!coercedVersion) { - throw new Error(`Invalid version to check against: ${version}`); - } + isGreaterThan(other: string | UnityVersion): boolean { + const otherVersion = other instanceof UnityVersion ? other : new UnityVersion(other); + return UnityVersion.baseCompare(this, otherVersion) > 0; + } + + isGreaterThanOrEqualTo(other: string | UnityVersion): boolean { + const otherVersion = other instanceof UnityVersion ? other : new UnityVersion(other); + return UnityVersion.baseCompare(this, otherVersion) >= 0; + } + + isLessThan(other: string | UnityVersion): boolean { + const otherVersion = other instanceof UnityVersion ? other : new UnityVersion(other); + return UnityVersion.baseCompare(this, otherVersion) < 0; + } + + isLessThanOrEqualTo(other: string | UnityVersion): boolean { + const otherVersion = other instanceof UnityVersion ? other : new UnityVersion(other); + return UnityVersion.baseCompare(this, otherVersion) <= 0; + } - return satisfies(coercedVersion, `^${this.semVer.version}`); + range(string: string | Range, options: RangeOptions | undefined = undefined): boolean { + return satisfies(this.semVer, string, options); } equals(other: UnityVersion): boolean { diff --git a/src/utilities.ts b/src/utilities.ts index 647837d..ae12fb4 100644 --- a/src/utilities.ts +++ b/src/utilities.ts @@ -4,7 +4,7 @@ import * as path from 'path'; import * as https from 'https'; import * as readline from 'readline'; import { glob } from 'glob'; -import { spawn, spawnSync } from 'child_process'; +import { spawn } from 'child_process'; import { Logger, LogLevel } from './logging'; const logger = Logger.instance; @@ -43,9 +43,8 @@ export async function PromptForSecretInput(prompt: string): Promise { // mask the previous line with asterisks in place of each character readline.moveCursor(process.stdout, 0, -1); readline.clearLine(process.stdout, 0); - process.stdout.write(prompt + '*'.repeat(input.length) + '\n'); + process.stdout.write(`${prompt + '*'.repeat(input.length)}\n`); rl.close(); - console.log(); // Don't use logger. Move to next line after input. resolve(input); }); }); @@ -578,11 +577,11 @@ export async function KillChildProcesses(procInfo: ProcInfo): Promise { * Checks if the current process is running with elevated (administrator) privileges. * @returns True if the process is elevated, false otherwise. */ -export function isProcessElevated(): boolean { +export async function isProcessElevated(): Promise { if (process.platform !== 'win32') { return true; } // We can sudo easily on non-windows platforms - const probe = spawnSync('powershell.exe', [ + const output = await Exec('powershell', [ '-NoLogo', '-NoProfile', '-Command', - "[Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent().IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)" - ], { encoding: 'utf8' }); - return probe.status === 0 && probe.stdout.trim().toLowerCase() === 'true'; + "(New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)" + ], { silent: true, showCommand: false }); + return output.trim().toLowerCase() === 'true'; } \ No newline at end of file diff --git a/tests/unity-editor.test.ts b/tests/unity-editor.test.ts index 86e016c..c07052b 100644 --- a/tests/unity-editor.test.ts +++ b/tests/unity-editor.test.ts @@ -30,6 +30,10 @@ describe('UnityEditor', () => { expect(Array.isArray(editors)).toBe(true); for (const editor of editors) { + if (editor.version.isLessThan('2018.0.0')) { + continue; // Skip versions that do not support templates + } + const template = editor.GetTemplatePath(pattern); expect(template).toBeDefined(); } diff --git a/tests/unity-hub.test.ts b/tests/unity-hub.test.ts index b46cd57..bd0c349 100644 --- a/tests/unity-hub.test.ts +++ b/tests/unity-hub.test.ts @@ -1,7 +1,6 @@ import { UnityRelease } from '@rage-against-the-pixel/unity-releases-api'; import { UnityHub } from '../src/unity-hub'; import { UnityVersion } from '../src/unity-version'; -import { Logger, LogLevel } from '../src/logging'; jest.setTimeout(30000); // UnityHub operations can be slow diff --git a/tests/unity-version.test.ts b/tests/unity-version.test.ts index e868532..baba947 100644 --- a/tests/unity-version.test.ts +++ b/tests/unity-version.test.ts @@ -1,4 +1,3 @@ -import { UnityHub } from '../src/unity-hub'; import { UnityVersion } from '../src/unity-version'; describe('UnityVersion', () => { @@ -62,4 +61,33 @@ describe('UnityVersion', () => { const older = new UnityVersion('2021.3.2a1'); expect(UnityVersion.compare(match, older)).toBeGreaterThan(0); }); + + it('evaluates caret compatibility with satisfies', () => { + const baseline = new UnityVersion('2021.3.5f1'); + const compatible = new UnityVersion('2021.4.0f1'); + const incompatible = new UnityVersion('2022.1.0f1'); + + expect(baseline.satisfies(compatible)).toBe(true); + expect(baseline.satisfies(incompatible)).toBe(false); + }); + + it('evaluates semantic version ranges using range()', () => { + const version = new UnityVersion('2021.3.5f1'); + + expect(version.range('>=2021.3.0 <2021.4.0')).toBe(true); + expect(version.range('<2021.3.0')).toBe(false); + }); + + it('compares versions with helper predicates', () => { + const older = new UnityVersion('2021.3.5f1'); + const newer = new UnityVersion('2021.3.6f1'); + + expect(newer.isGreaterThan(older)).toBe(true); + expect(newer.isGreaterThan('2021.3.5f1')).toBe(true); + expect(newer.isGreaterThanOrEqualTo('2021.3.6f1')).toBe(true); + expect(older.isGreaterThanOrEqualTo(newer)).toBe(false); + expect(older.isLessThan(newer)).toBe(true); + expect(older.isLessThanOrEqualTo('2021.3.5f1')).toBe(true); + expect(newer.isLessThanOrEqualTo(older)).toBe(false); + }); });