From ef4b747ed1abcce80d87952c28390e51b31fc930 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 3 May 2026 12:47:17 +0900 Subject: [PATCH 01/11] Add legacy npm cleanup to native installer Detect existing uloop-cli npm installations during native launcher setup so users know when an older TypeScript shim may still be present. Keep removal opt-in through ULOOP_REMOVE_LEGACY to avoid deleting user-managed npm installs without consent, and document the migration command in the install guide. --- Packages/src/README.md | 11 +++++++ Packages/src/README_ja.md | 11 +++++++ README.md | 11 +++++++ scripts/install.ps1 | 64 +++++++++++++++++++++++++++++++++++++++ scripts/install.sh | 47 ++++++++++++++++++++++++++++ 5 files changed, 144 insertions(+) diff --git a/Packages/src/README.md b/Packages/src/README.md index 3a8f84f42..864414edd 100644 --- a/Packages/src/README.md +++ b/Packages/src/README.md @@ -91,6 +91,17 @@ On Windows PowerShell: irm https://raw.githubusercontent.com/hatayama/unity-cli-loop/main/scripts/install.ps1 | iex ``` +If you previously installed the TypeScript launcher through npm, the installer detects `uloop-cli` and leaves it in place by default. To remove the legacy npm package during installation: + +```bash +curl -fsSL https://raw.githubusercontent.com/hatayama/unity-cli-loop/main/scripts/install.sh | ULOOP_REMOVE_LEGACY=1 sh +``` + +```powershell +$env:ULOOP_REMOVE_LEGACY = "1" +irm https://raw.githubusercontent.com/hatayama/unity-cli-loop/main/scripts/install.ps1 | iex +``` + Select Window > Unity CLI Loop > Settings. A dedicated window will open — confirm that the **CLI** button is highlighted in blue. 1 diff --git a/Packages/src/README_ja.md b/Packages/src/README_ja.md index 9feb7ec7b..1a4b67b43 100644 --- a/Packages/src/README_ja.md +++ b/Packages/src/README_ja.md @@ -92,6 +92,17 @@ Windows PowerShell の場合: irm https://raw.githubusercontent.com/hatayama/unity-cli-loop/main/scripts/install.ps1 | iex ``` +過去に npm 経由で TypeScript 版ランチャーを入れていた場合、installer は `uloop-cli` を検出しますが、デフォルトでは削除しません。インストール時に旧 npm package も削除する場合は、次のように実行してください。 + +```bash +curl -fsSL https://raw.githubusercontent.com/hatayama/unity-cli-loop/main/scripts/install.sh | ULOOP_REMOVE_LEGACY=1 sh +``` + +```powershell +$env:ULOOP_REMOVE_LEGACY = "1" +irm https://raw.githubusercontent.com/hatayama/unity-cli-loop/main/scripts/install.ps1 | iex +``` + Window > Unity CLI Loop > Settingsを選択します。専用ウィンドウが開くので **CLI** ボタンが青くなっている事を確認します。 1 diff --git a/README.md b/README.md index 3a8f84f42..864414edd 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,17 @@ On Windows PowerShell: irm https://raw.githubusercontent.com/hatayama/unity-cli-loop/main/scripts/install.ps1 | iex ``` +If you previously installed the TypeScript launcher through npm, the installer detects `uloop-cli` and leaves it in place by default. To remove the legacy npm package during installation: + +```bash +curl -fsSL https://raw.githubusercontent.com/hatayama/unity-cli-loop/main/scripts/install.sh | ULOOP_REMOVE_LEGACY=1 sh +``` + +```powershell +$env:ULOOP_REMOVE_LEGACY = "1" +irm https://raw.githubusercontent.com/hatayama/unity-cli-loop/main/scripts/install.ps1 | iex +``` + Select Window > Unity CLI Loop > Settings. A dedicated window will open — confirm that the **CLI** button is highlighted in blue. 1 diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 82c455cb5..df13c7c22 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -1,6 +1,7 @@ $ErrorActionPreference = "Stop" $Repository = "hatayama/unity-cli-loop" +$LegacyNpmPackage = "uloop-cli" $Version = if ($env:ULOOP_VERSION) { $env:ULOOP_VERSION } else { "latest" } $InstallDir = if ($env:ULOOP_INSTALL_DIR) { $env:ULOOP_INSTALL_DIR @@ -16,6 +17,67 @@ if ($Version -eq "latest") { } $ChecksumUrl = "$DownloadUrl.sha256" +function Test-RemoveLegacyEnabled { + if (-not $env:ULOOP_REMOVE_LEGACY) { + return $false + } + + $EnabledValues = @("1", "true", "yes") + return $EnabledValues -contains $env:ULOOP_REMOVE_LEGACY.ToLowerInvariant() +} + +function Get-NpmCommand { + Get-Command npm -ErrorAction SilentlyContinue | Select-Object -First 1 +} + +function Test-LegacyNpmInstalled { + $NpmCommand = Get-NpmCommand + if (-not $NpmCommand) { + return $false + } + + & npm list -g $LegacyNpmPackage --depth=0 > $null 2> $null + return $LASTEXITCODE -eq 0 +} + +function Remove-OrReportLegacyNpm { + if (-not (Test-LegacyNpmInstalled)) { + return + } + + if (Test-RemoveLegacyEnabled) { + Write-Host "Removing legacy npm installation: $LegacyNpmPackage" + & npm uninstall -g $LegacyNpmPackage + if ($LASTEXITCODE -ne 0) { + throw "Failed to remove legacy npm installation: $LegacyNpmPackage" + } + return + } + + Write-Host "Legacy npm installation detected: $LegacyNpmPackage" + Write-Host "The native dispatcher was installed, but the npm package may still provide an older uloop command." + Write-Host "To remove it, run:" + Write-Host " npm uninstall -g $LegacyNpmPackage" + Write-Host "Or rerun this installer with:" + Write-Host " `$env:ULOOP_REMOVE_LEGACY = `"1`"" +} + +function Report-PathShadowing { + $ResolvedCommand = Get-Command uloop -ErrorAction SilentlyContinue | Select-Object -First 1 + if (-not $ResolvedCommand) { + return + } + + $ExpectedUloop = Join-Path $InstallDir "uloop.exe" + if ([string]::Equals($ResolvedCommand.Source, $ExpectedUloop, [System.StringComparison]::OrdinalIgnoreCase)) { + return + } + + Write-Host "Installed uloop to $ExpectedUloop, but PATH resolves uloop to:" + Write-Host " $($ResolvedCommand.Source)" + Write-Host "Move $InstallDir earlier in PATH, or remove the legacy installation if it owns that command." +} + $TempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("uloop-install-" + [System.Guid]::NewGuid().ToString("N")) New-Item -ItemType Directory -Path $TempDir | Out-Null @@ -49,6 +111,8 @@ try { } & (Join-Path $InstallDir "uloop.exe") --version + Remove-OrReportLegacyNpm + Report-PathShadowing } finally { Remove-Item -Path $TempDir -Recurse -Force -ErrorAction SilentlyContinue diff --git a/scripts/install.sh b/scripts/install.sh index 40c2daf9d..9f0274a1c 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -4,6 +4,51 @@ set -eu REPOSITORY="hatayama/unity-cli-loop" INSTALL_DIR="${ULOOP_INSTALL_DIR:-$HOME/.local/bin}" VERSION="${ULOOP_VERSION:-latest}" +LEGACY_NPM_PACKAGE="uloop-cli" +REMOVE_LEGACY="${ULOOP_REMOVE_LEGACY:-0}" + +is_remove_legacy_enabled() { + case "$REMOVE_LEGACY" in + 1|true|TRUE|yes|YES) return 0 ;; + *) return 1 ;; + esac +} + +is_legacy_npm_installed() { + command -v npm >/dev/null 2>&1 && npm list -g "$LEGACY_NPM_PACKAGE" --depth=0 >/dev/null 2>&1 +} + +remove_or_report_legacy_npm() { + if ! is_legacy_npm_installed; then + return + fi + + if is_remove_legacy_enabled; then + echo "Removing legacy npm installation: $LEGACY_NPM_PACKAGE" + npm uninstall -g "$LEGACY_NPM_PACKAGE" + return + fi + + echo "Legacy npm installation detected: $LEGACY_NPM_PACKAGE" + echo "The native dispatcher was installed, but the npm package may still provide an older uloop command." + echo "To remove it, run:" + echo " npm uninstall -g $LEGACY_NPM_PACKAGE" + echo "Or rerun this installer with:" + echo " ULOOP_REMOVE_LEGACY=1" +} + +report_path_shadowing() { + resolved_uloop=$(command -v uloop 2>/dev/null || true) + expected_uloop="$INSTALL_DIR/uloop" + + if [ -z "$resolved_uloop" ] || [ "$resolved_uloop" = "$expected_uloop" ]; then + return + fi + + echo "Installed uloop to $expected_uloop, but PATH resolves uloop to:" + echo " $resolved_uloop" + echo "Move $INSTALL_DIR earlier in PATH, or remove the legacy installation if it owns that command." +} detect_asset_name() { os=$(uname -s) @@ -80,3 +125,5 @@ case ":$PATH:" in esac "$INSTALL_DIR/uloop" --version +remove_or_report_legacy_npm +report_path_shadowing From 961c0fc34959bc4ee39d28ba533d357bd35b3f5f Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 3 May 2026 13:06:05 +0900 Subject: [PATCH 02/11] Enable legacy cleanup from Unity CLI install buttons Settings and Setup Wizard run the same native installer as the terminal flow, but those buttons are explicit install actions. Pass ULOOP_REMOVE_LEGACY=1 from those UI paths so an older npm-based uloop-cli shim is removed during migration instead of continuing to shadow the native dispatcher. --- .../Tests/Editor/NativeCliInstallerTests.cs | 36 +++++++++++++++++-- Packages/src/Editor/CLI/CliConstants.cs | 2 ++ Packages/src/Editor/CLI/NativeCliInstaller.cs | 34 ++++++++++++++++-- Packages/src/Editor/UI/McpEditorWindow.cs | 6 ++-- .../src/Editor/UI/Setup/SetupWizardWindow.cs | 6 ++-- Packages/src/README.md | 2 ++ Packages/src/README_ja.md | 2 ++ README.md | 2 ++ 8 files changed, 81 insertions(+), 9 deletions(-) diff --git a/Assets/Tests/Editor/NativeCliInstallerTests.cs b/Assets/Tests/Editor/NativeCliInstallerTests.cs index b58f90e6e..ea59da902 100644 --- a/Assets/Tests/Editor/NativeCliInstallerTests.cs +++ b/Assets/Tests/Editor/NativeCliInstallerTests.cs @@ -8,13 +8,16 @@ public class NativeCliInstallerTests [Test] public void GetInstallCommand_OnMacUsesDirectInstallScriptWithoutNpm() { + // Verifies that macOS installs through the native release script, not npm. NativeCliInstallCommand command = NativeCliInstaller.GetInstallCommand( RuntimePlatform.OSXEditor, - "3.0.0-beta.0"); + "3.0.0-beta.0", + false); Assert.That(command.FileName, Is.EqualTo("/bin/sh")); Assert.That(command.Arguments, Does.Contain("https://github.com/hatayama/unity-cli-loop/releases/download/v3.0.0-beta.0/install.sh")); Assert.That(command.Arguments, Does.Contain("ULOOP_VERSION='v3.0.0-beta.0'")); + Assert.That(command.Arguments, Does.Not.Contain("ULOOP_REMOVE_LEGACY")); Assert.That(command.ManualCommand, Does.Contain("curl -fsSL")); Assert.That(command.ManualCommand, Does.Not.Contain("npm")); } @@ -22,15 +25,44 @@ public void GetInstallCommand_OnMacUsesDirectInstallScriptWithoutNpm() [Test] public void GetInstallCommand_OnWindowsUsesPowerShellInstallScriptWithoutNpm() { + // Verifies that Windows installs through the native release script, not npm. NativeCliInstallCommand command = NativeCliInstaller.GetInstallCommand( RuntimePlatform.WindowsEditor, - "3.0.0-beta.0"); + "3.0.0-beta.0", + false); Assert.That(command.FileName, Is.EqualTo("powershell")); Assert.That(command.Arguments, Does.Contain("https://github.com/hatayama/unity-cli-loop/releases/download/v3.0.0-beta.0/install.ps1")); Assert.That(command.Arguments, Does.Contain("$env:ULOOP_VERSION='v3.0.0-beta.0'")); + Assert.That(command.Arguments, Does.Not.Contain("ULOOP_REMOVE_LEGACY")); Assert.That(command.ManualCommand, Does.Contain("irm")); Assert.That(command.ManualCommand, Does.Not.Contain("npm")); } + + [Test] + public void GetInstallCommand_OnMacCanOptIntoLegacyNpmRemoval() + { + // Verifies that UI-triggered macOS installs can opt into removing the legacy npm launcher. + NativeCliInstallCommand command = NativeCliInstaller.GetInstallCommand( + RuntimePlatform.OSXEditor, + "3.0.0-beta.0", + true); + + Assert.That(command.Arguments, Does.Contain("ULOOP_REMOVE_LEGACY='1'")); + Assert.That(command.ManualCommand, Does.Contain("ULOOP_REMOVE_LEGACY='1'")); + } + + [Test] + public void GetInstallCommand_OnWindowsCanOptIntoLegacyNpmRemoval() + { + // Verifies that UI-triggered Windows installs can opt into removing the legacy npm launcher. + NativeCliInstallCommand command = NativeCliInstaller.GetInstallCommand( + RuntimePlatform.WindowsEditor, + "3.0.0-beta.0", + true); + + Assert.That(command.Arguments, Does.Contain("$env:ULOOP_REMOVE_LEGACY='1'")); + Assert.That(command.ManualCommand, Does.Contain("$env:ULOOP_REMOVE_LEGACY='1'")); + } } } diff --git a/Packages/src/Editor/CLI/CliConstants.cs b/Packages/src/Editor/CLI/CliConstants.cs index ae92f759a..58329b95c 100644 --- a/Packages/src/Editor/CLI/CliConstants.cs +++ b/Packages/src/Editor/CLI/CliConstants.cs @@ -9,6 +9,8 @@ public static class CliConstants public const string POSIX_INSTALL_SCRIPT_NAME = "install.sh"; public const string WINDOWS_INSTALL_SCRIPT_NAME = "install.ps1"; public const string INSTALL_VERSION_ENVIRONMENT_VARIABLE = "ULOOP_VERSION"; + public const string REMOVE_LEGACY_ENVIRONMENT_VARIABLE = "ULOOP_REMOVE_LEGACY"; + public const string REMOVE_LEGACY_ENABLED_VALUE = "1"; public const string RELEASE_TAG_PREFIX = "v"; public const string SKILL_DIR_PREFIX = "uloop-"; public const string SKILL_DIR_GLOB = "uloop-*"; diff --git a/Packages/src/Editor/CLI/NativeCliInstaller.cs b/Packages/src/Editor/CLI/NativeCliInstaller.cs index aad8fa32c..049e67da1 100644 --- a/Packages/src/Editor/CLI/NativeCliInstaller.cs +++ b/Packages/src/Editor/CLI/NativeCliInstaller.cs @@ -29,7 +29,10 @@ public NativeCliInstallCommand(string fileName, string arguments, string manualC /// public static class NativeCliInstaller { - public static NativeCliInstallCommand GetInstallCommand(RuntimePlatform platform, string packageVersion) + public static NativeCliInstallCommand GetInstallCommand( + RuntimePlatform platform, + string packageVersion, + bool removeLegacyNpm) { UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(packageVersion), "packageVersion must not be null or empty"); @@ -39,6 +42,7 @@ public static NativeCliInstallCommand GetInstallCommand(RuntimePlatform platform string scriptUrl = BuildReleaseAssetUrl(releaseTag, CliConstants.WINDOWS_INSTALL_SCRIPT_NAME); string command = $"$env:{CliConstants.INSTALL_VERSION_ENVIRONMENT_VARIABLE}='{releaseTag}'; " + + BuildWindowsRemoveLegacyAssignment(removeLegacyNpm) + $"irm '{scriptUrl}' | iex"; return new NativeCliInstallCommand( "powershell", @@ -49,6 +53,7 @@ public static NativeCliInstallCommand GetInstallCommand(RuntimePlatform platform string posixScriptUrl = BuildReleaseAssetUrl(releaseTag, CliConstants.POSIX_INSTALL_SCRIPT_NAME); string posixCommand = $"curl -fsSL '{posixScriptUrl}' | " + + BuildPosixRemoveLegacyAssignment(removeLegacyNpm) + $"{CliConstants.INSTALL_VERSION_ENVIRONMENT_VARIABLE}='{releaseTag}' sh"; return new NativeCliInstallCommand( "/bin/sh", @@ -56,9 +61,12 @@ public static NativeCliInstallCommand GetInstallCommand(RuntimePlatform platform posixCommand); } - public static async Task InstallAsync(RuntimePlatform platform, string packageVersion) + public static async Task InstallAsync( + RuntimePlatform platform, + string packageVersion, + bool removeLegacyNpm) { - NativeCliInstallCommand command = GetInstallCommand(platform, packageVersion); + NativeCliInstallCommand command = GetInstallCommand(platform, packageVersion, removeLegacyNpm); ProcessStartInfo startInfo = new ProcessStartInfo { FileName = command.FileName, @@ -106,6 +114,26 @@ await Task.Run(() => return new CliInstallResult(success, errorOutput); } + private static string BuildWindowsRemoveLegacyAssignment(bool removeLegacyNpm) + { + if (!removeLegacyNpm) + { + return ""; + } + + return $"$env:{CliConstants.REMOVE_LEGACY_ENVIRONMENT_VARIABLE}='{CliConstants.REMOVE_LEGACY_ENABLED_VALUE}'; "; + } + + private static string BuildPosixRemoveLegacyAssignment(bool removeLegacyNpm) + { + if (!removeLegacyNpm) + { + return ""; + } + + return $"{CliConstants.REMOVE_LEGACY_ENVIRONMENT_VARIABLE}='{CliConstants.REMOVE_LEGACY_ENABLED_VALUE}' "; + } + private static string BuildReleaseTag(string packageVersion) { if (packageVersion.StartsWith(CliConstants.RELEASE_TAG_PREFIX, StringComparison.Ordinal)) diff --git a/Packages/src/Editor/UI/McpEditorWindow.cs b/Packages/src/Editor/UI/McpEditorWindow.cs index 583cdf7f7..c1a42a8f4 100644 --- a/Packages/src/Editor/UI/McpEditorWindow.cs +++ b/Packages/src/Editor/UI/McpEditorWindow.cs @@ -705,13 +705,15 @@ private async void HandleInstallCli() { CliInstallResult result = await NativeCliInstaller.InstallAsync( Application.platform, - McpConstants.PackageInfo.version); + McpConstants.PackageInfo.version, + true); if (!result.Success) { NativeCliInstallCommand command = NativeCliInstaller.GetInstallCommand( Application.platform, - McpConstants.PackageInfo.version); + McpConstants.PackageInfo.version, + true); EditorUtility.DisplayDialog( "Installation Failed", $"Failed to install uLoop CLI.\n\n{result.ErrorOutput}\n\nYou can try manually:\n{command.ManualCommand}", diff --git a/Packages/src/Editor/UI/Setup/SetupWizardWindow.cs b/Packages/src/Editor/UI/Setup/SetupWizardWindow.cs index e358e1c55..b5eef741b 100644 --- a/Packages/src/Editor/UI/Setup/SetupWizardWindow.cs +++ b/Packages/src/Editor/UI/Setup/SetupWizardWindow.cs @@ -806,13 +806,15 @@ private async void HandleInstallCli() { CliInstallResult result = await NativeCliInstaller.InstallAsync( Application.platform, - McpConstants.PackageInfo.version); + McpConstants.PackageInfo.version, + true); if (!result.Success) { NativeCliInstallCommand command = NativeCliInstaller.GetInstallCommand( Application.platform, - McpConstants.PackageInfo.version); + McpConstants.PackageInfo.version, + true); EditorUtility.DisplayDialog( "Installation Failed", $"Failed to install uloop-cli.\n\n{result.ErrorOutput}\n\n" diff --git a/Packages/src/README.md b/Packages/src/README.md index 864414edd..2ac7af7f6 100644 --- a/Packages/src/README.md +++ b/Packages/src/README.md @@ -102,6 +102,8 @@ $env:ULOOP_REMOVE_LEGACY = "1" irm https://raw.githubusercontent.com/hatayama/unity-cli-loop/main/scripts/install.ps1 | iex ``` +The **Install CLI** buttons in Settings and Setup Wizard use the same installer and opt into removing the legacy npm package automatically. + Select Window > Unity CLI Loop > Settings. A dedicated window will open — confirm that the **CLI** button is highlighted in blue. 1 diff --git a/Packages/src/README_ja.md b/Packages/src/README_ja.md index 1a4b67b43..a93373b31 100644 --- a/Packages/src/README_ja.md +++ b/Packages/src/README_ja.md @@ -103,6 +103,8 @@ $env:ULOOP_REMOVE_LEGACY = "1" irm https://raw.githubusercontent.com/hatayama/unity-cli-loop/main/scripts/install.ps1 | iex ``` +Settings と Setup Wizard の **Install CLI** ボタンは同じ installer を使い、旧 npm package の削除も自動で有効化します。 + Window > Unity CLI Loop > Settingsを選択します。専用ウィンドウが開くので **CLI** ボタンが青くなっている事を確認します。 1 diff --git a/README.md b/README.md index 864414edd..2ac7af7f6 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,8 @@ $env:ULOOP_REMOVE_LEGACY = "1" irm https://raw.githubusercontent.com/hatayama/unity-cli-loop/main/scripts/install.ps1 | iex ``` +The **Install CLI** buttons in Settings and Setup Wizard use the same installer and opt into removing the legacy npm package automatically. + Select Window > Unity CLI Loop > Settings. A dedicated window will open — confirm that the **CLI** button is highlighted in blue. 1 From 74d07014922be4d370af2699259366ba188b3213 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 3 May 2026 13:21:38 +0900 Subject: [PATCH 03/11] Preserve native launcher after legacy cleanup Remove the legacy npm launcher before writing the native dispatcher so npm uninstall cannot delete the just-installed uloop binary when both installers use the same bin directory. --- scripts/install.ps1 | 10 ++++++++-- scripts/install.sh | 10 ++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index df13c7c22..1c958ca8f 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -40,7 +40,7 @@ function Test-LegacyNpmInstalled { return $LASTEXITCODE -eq 0 } -function Remove-OrReportLegacyNpm { +function Remove-LegacyNpmIfEnabled { if (-not (Test-LegacyNpmInstalled)) { return } @@ -51,6 +51,11 @@ function Remove-OrReportLegacyNpm { if ($LASTEXITCODE -ne 0) { throw "Failed to remove legacy npm installation: $LegacyNpmPackage" } + } +} + +function Write-LegacyNpmWarningIfPresent { + if ((Test-RemoveLegacyEnabled) -or (-not (Test-LegacyNpmInstalled))) { return } @@ -94,6 +99,7 @@ try { Expand-Archive -Path $ArchivePath -DestinationPath $TempDir -Force + Remove-LegacyNpmIfEnabled New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null Copy-Item -Path (Join-Path $TempDir "uloop.exe") -Destination (Join-Path $InstallDir "uloop.exe") -Force @@ -111,7 +117,7 @@ try { } & (Join-Path $InstallDir "uloop.exe") --version - Remove-OrReportLegacyNpm + Write-LegacyNpmWarningIfPresent Report-PathShadowing } finally { diff --git a/scripts/install.sh b/scripts/install.sh index 9f0274a1c..4987efad0 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -18,7 +18,7 @@ is_legacy_npm_installed() { command -v npm >/dev/null 2>&1 && npm list -g "$LEGACY_NPM_PACKAGE" --depth=0 >/dev/null 2>&1 } -remove_or_report_legacy_npm() { +remove_legacy_npm_if_enabled() { if ! is_legacy_npm_installed; then return fi @@ -26,6 +26,11 @@ remove_or_report_legacy_npm() { if is_remove_legacy_enabled; then echo "Removing legacy npm installation: $LEGACY_NPM_PACKAGE" npm uninstall -g "$LEGACY_NPM_PACKAGE" + fi +} + +report_legacy_npm_if_present() { + if is_remove_legacy_enabled || ! is_legacy_npm_installed; then return fi @@ -113,6 +118,7 @@ curl -fsSL "$download_url" -o "$tmp_dir/$asset_name" curl -fsSL "$checksum_url" -o "$tmp_dir/$asset_name.sha256" verify_checksum tar -xzf "$tmp_dir/$asset_name" -C "$tmp_dir" +remove_legacy_npm_if_enabled install -m 0755 "$tmp_dir/uloop" "$INSTALL_DIR/uloop" case ":$PATH:" in @@ -125,5 +131,5 @@ case ":$PATH:" in esac "$INSTALL_DIR/uloop" --version -remove_or_report_legacy_npm +report_legacy_npm_if_present report_path_shadowing From 6aff156900da0447abebed06d01685c98f30c8dc Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 3 May 2026 13:26:45 +0900 Subject: [PATCH 04/11] Stage native launcher before legacy cleanup Verify the replacement launcher from a staged path before removing the legacy npm package, then move the staged binary into place. This keeps failed installs from removing the working legacy command while still preventing npm uninstall from deleting the final native launcher. --- scripts/install.ps1 | 12 ++++++++++-- scripts/install.sh | 9 +++++++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 1c958ca8f..6d33d1e40 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -84,6 +84,7 @@ function Report-PathShadowing { } $TempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("uloop-install-" + [System.Guid]::NewGuid().ToString("N")) +$StagedUloopPath = $null New-Item -ItemType Directory -Path $TempDir | Out-Null try { @@ -99,9 +100,13 @@ try { Expand-Archive -Path $ArchivePath -DestinationPath $TempDir -Force - Remove-LegacyNpmIfEnabled New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null - Copy-Item -Path (Join-Path $TempDir "uloop.exe") -Destination (Join-Path $InstallDir "uloop.exe") -Force + $StagedUloopPath = Join-Path $InstallDir ("uloop-install-" + [System.Guid]::NewGuid().ToString("N") + ".exe") + Copy-Item -Path (Join-Path $TempDir "uloop.exe") -Destination $StagedUloopPath -Force + & $StagedUloopPath --version > $null + Remove-LegacyNpmIfEnabled + Move-Item -Path $StagedUloopPath -Destination (Join-Path $InstallDir "uloop.exe") -Force + $StagedUloopPath = $null $UserPath = [Environment]::GetEnvironmentVariable("Path", "User") $PathEntries = @() @@ -121,5 +126,8 @@ try { Report-PathShadowing } finally { + if ($StagedUloopPath -and (Test-Path $StagedUloopPath)) { + Remove-Item -Path $StagedUloopPath -Force -ErrorAction SilentlyContinue + } Remove-Item -Path $TempDir -Recurse -Force -ErrorAction SilentlyContinue } diff --git a/scripts/install.sh b/scripts/install.sh index 4987efad0..eac3aa2d8 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -88,7 +88,8 @@ fi checksum_url="$download_url.sha256" tmp_dir=$(mktemp -d) -trap 'rm -rf "$tmp_dir"' EXIT +staged_uloop_path="" +trap 'rm -rf "$tmp_dir"; if [ -n "$staged_uloop_path" ]; then rm -f "$staged_uloop_path"; fi' EXIT verify_checksum() { if command -v sha256sum >/dev/null 2>&1; then @@ -118,8 +119,12 @@ curl -fsSL "$download_url" -o "$tmp_dir/$asset_name" curl -fsSL "$checksum_url" -o "$tmp_dir/$asset_name.sha256" verify_checksum tar -xzf "$tmp_dir/$asset_name" -C "$tmp_dir" +staged_uloop_path="$INSTALL_DIR/.uloop-install-$$" +install -m 0755 "$tmp_dir/uloop" "$staged_uloop_path" +"$staged_uloop_path" --version >/dev/null remove_legacy_npm_if_enabled -install -m 0755 "$tmp_dir/uloop" "$INSTALL_DIR/uloop" +mv -f "$staged_uloop_path" "$INSTALL_DIR/uloop" +staged_uloop_path="" case ":$PATH:" in *":$INSTALL_DIR:"*) ;; From b7390d786dcf8bf21ff2ee121022cb86e3b28d03 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 3 May 2026 13:30:17 +0900 Subject: [PATCH 05/11] Use npm cmd shim for Windows legacy cleanup Resolve npm.cmd or npm.exe before falling back to npm so Windows installer cleanup does not depend on PowerShell script shim execution policy when checking or removing the legacy package. --- scripts/install.ps1 | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 6d33d1e40..19633969e 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -27,7 +27,15 @@ function Test-RemoveLegacyEnabled { } function Get-NpmCommand { - Get-Command npm -ErrorAction SilentlyContinue | Select-Object -First 1 + $CommandNames = @("npm.cmd", "npm.exe", "npm") + foreach ($CommandName in $CommandNames) { + $Command = Get-Command $CommandName -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($Command) { + return $Command.Source + } + } + + return $null } function Test-LegacyNpmInstalled { @@ -36,7 +44,7 @@ function Test-LegacyNpmInstalled { return $false } - & npm list -g $LegacyNpmPackage --depth=0 > $null 2> $null + & $NpmCommand list -g $LegacyNpmPackage --depth=0 > $null 2> $null return $LASTEXITCODE -eq 0 } @@ -46,8 +54,9 @@ function Remove-LegacyNpmIfEnabled { } if (Test-RemoveLegacyEnabled) { + $NpmCommand = Get-NpmCommand Write-Host "Removing legacy npm installation: $LegacyNpmPackage" - & npm uninstall -g $LegacyNpmPackage + & $NpmCommand uninstall -g $LegacyNpmPackage if ($LASTEXITCODE -ne 0) { throw "Failed to remove legacy npm installation: $LegacyNpmPackage" } From 20f8395ab5d9a09a39834cc9f83081422772fd5a Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 3 May 2026 13:51:58 +0900 Subject: [PATCH 06/11] Keep installer success tied to active native CLI Treat legacy npm cleanup failure as recoverable only when the newly installed native dispatcher is still the command resolved by PATH. This avoids reporting success when users would continue running an older npm-provided uloop after cleanup fails. --- scripts/install.ps1 | 25 ++++++++++++++++++++++++- scripts/install.sh | 27 ++++++++++++++++++++++++++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 19633969e..81fde003d 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -9,6 +9,7 @@ $InstallDir = if ($env:ULOOP_INSTALL_DIR) { Join-Path $env:LOCALAPPDATA "Programs\uloop\bin" } $AssetName = "uloop-windows-amd64.zip" +$LegacyCleanupFailed = $false if ($Version -eq "latest") { $DownloadUrl = "https://github.com/$Repository/releases/latest/download/$AssetName" @@ -58,11 +59,32 @@ function Remove-LegacyNpmIfEnabled { Write-Host "Removing legacy npm installation: $LegacyNpmPackage" & $NpmCommand uninstall -g $LegacyNpmPackage if ($LASTEXITCODE -ne 0) { - throw "Failed to remove legacy npm installation: $LegacyNpmPackage" + $script:LegacyCleanupFailed = $true + Write-Warning "Could not remove legacy npm installation: $LegacyNpmPackage" + Write-Host "To remove it manually, run:" + Write-Host " npm uninstall -g $LegacyNpmPackage" } } } +function Confirm-ActiveUloopAfterLegacyCleanup { + if (-not $script:LegacyCleanupFailed) { + return + } + + $ResolvedCommand = Get-Command uloop -ErrorAction SilentlyContinue | Select-Object -First 1 + if (-not $ResolvedCommand) { + return + } + + $ExpectedUloop = Join-Path $InstallDir "uloop.exe" + if ([string]::Equals($ResolvedCommand.Source, $ExpectedUloop, [System.StringComparison]::OrdinalIgnoreCase)) { + return + } + + throw "Failed to remove legacy npm installation, and PATH still resolves uloop to $($ResolvedCommand.Source). The native dispatcher was installed to $ExpectedUloop, but running uloop may still use the legacy command. Remove the legacy package manually, or move $InstallDir earlier in PATH." +} + function Write-LegacyNpmWarningIfPresent { if ((Test-RemoveLegacyEnabled) -or (-not (Test-LegacyNpmInstalled))) { return @@ -131,6 +153,7 @@ try { } & (Join-Path $InstallDir "uloop.exe") --version + Confirm-ActiveUloopAfterLegacyCleanup Write-LegacyNpmWarningIfPresent Report-PathShadowing } diff --git a/scripts/install.sh b/scripts/install.sh index eac3aa2d8..78e6aebf2 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -6,6 +6,7 @@ INSTALL_DIR="${ULOOP_INSTALL_DIR:-$HOME/.local/bin}" VERSION="${ULOOP_VERSION:-latest}" LEGACY_NPM_PACKAGE="uloop-cli" REMOVE_LEGACY="${ULOOP_REMOVE_LEGACY:-0}" +legacy_cleanup_failed=0 is_remove_legacy_enabled() { case "$REMOVE_LEGACY" in @@ -25,10 +26,33 @@ remove_legacy_npm_if_enabled() { if is_remove_legacy_enabled; then echo "Removing legacy npm installation: $LEGACY_NPM_PACKAGE" - npm uninstall -g "$LEGACY_NPM_PACKAGE" + if ! npm uninstall -g "$LEGACY_NPM_PACKAGE"; then + legacy_cleanup_failed=1 + echo "Warning: Could not remove legacy npm installation: $LEGACY_NPM_PACKAGE" + echo "To remove it manually, run:" + echo " npm uninstall -g $LEGACY_NPM_PACKAGE" + fi fi } +ensure_active_uloop_after_legacy_cleanup() { + if [ "$legacy_cleanup_failed" -ne 1 ]; then + return + fi + + resolved_uloop=$(command -v uloop 2>/dev/null || true) + expected_uloop="$INSTALL_DIR/uloop" + if [ -z "$resolved_uloop" ] || [ "$resolved_uloop" = "$expected_uloop" ]; then + return + fi + + echo "Failed to remove legacy npm installation, and PATH still resolves uloop to:" >&2 + echo " $resolved_uloop" >&2 + echo "The native dispatcher was installed to $expected_uloop, but running uloop may still use the legacy command." >&2 + echo "Remove the legacy package manually, or move $INSTALL_DIR earlier in PATH." >&2 + exit 1 +} + report_legacy_npm_if_present() { if is_remove_legacy_enabled || ! is_legacy_npm_installed; then return @@ -136,5 +160,6 @@ case ":$PATH:" in esac "$INSTALL_DIR/uloop" --version +ensure_active_uloop_after_legacy_cleanup report_legacy_npm_if_present report_path_shadowing From 64670c3a684218847082ff65a6fc63c6f358ba3b Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 3 May 2026 14:11:19 +0900 Subject: [PATCH 07/11] Keep Unity CLI detection aligned after native install Seed the POSIX installer with the same login-shell PATH used by CLI detection, and update the current Unity process PATH after a successful native install so cleanup does not leave Settings or Setup Wizard unable to resolve uloop immediately. --- .../Tests/Editor/NativeCliInstallerTests.cs | 64 ++++++++ Packages/src/Editor/CLI/CliConstants.cs | 11 ++ Packages/src/Editor/CLI/NativeCliInstaller.cs | 151 ++++++++++++++++++ .../Editor/Utils/NodeEnvironmentResolver.cs | 7 +- 4 files changed, 227 insertions(+), 6 deletions(-) diff --git a/Assets/Tests/Editor/NativeCliInstallerTests.cs b/Assets/Tests/Editor/NativeCliInstallerTests.cs index ea59da902..3df61f18b 100644 --- a/Assets/Tests/Editor/NativeCliInstallerTests.cs +++ b/Assets/Tests/Editor/NativeCliInstallerTests.cs @@ -64,5 +64,69 @@ public void GetInstallCommand_OnWindowsCanOptIntoLegacyNpmRemoval() Assert.That(command.Arguments, Does.Contain("$env:ULOOP_REMOVE_LEGACY='1'")); Assert.That(command.ManualCommand, Does.Contain("$env:ULOOP_REMOVE_LEGACY='1'")); } + + [Test] + public void BuildPathWithInstallDirectory_OnWindowsAppendsMissingNativeInstallDir() + { + // Verifies that Unity's current Windows PATH can be extended for immediate native CLI detection. + string result = NativeCliInstaller.BuildPathWithInstallDirectory( + "C:\\npm", + "C:\\Users\\masamichi\\Programs\\uloop\\bin", + RuntimePlatform.WindowsEditor); + + Assert.That(result, Is.EqualTo("C:\\npm;C:\\Users\\masamichi\\Programs\\uloop\\bin")); + } + + [Test] + public void BuildPathWithInstallDirectory_OnWindowsDoesNotDuplicateExistingNativeInstallDir() + { + // Verifies that Windows PATH matching is case-insensitive when the native install dir is already present. + string result = NativeCliInstaller.BuildPathWithInstallDirectory( + "C:\\npm;C:\\USERS\\MASAMICHI\\PROGRAMS\\ULOOP\\BIN", + "C:\\Users\\masamichi\\Programs\\uloop\\bin", + RuntimePlatform.WindowsEditor); + + Assert.That(result, Is.EqualTo("C:\\npm;C:\\USERS\\MASAMICHI\\PROGRAMS\\ULOOP\\BIN")); + } + + [Test] + public void BuildPathWithInstallDirectory_OnMacAppendsMissingNativeInstallDir() + { + // Verifies that POSIX PATH keeps colon separation when adding the native install dir. + string result = NativeCliInstaller.BuildPathWithInstallDirectory( + "/usr/local/bin", + "/Users/masamichi/.local/bin", + RuntimePlatform.OSXEditor); + + Assert.That(result, Is.EqualTo("/usr/local/bin:/Users/masamichi/.local/bin")); + } + + [Test] + public void GetDefaultInstallDirectoryFromRoots_OnMacMatchesInstallerDefault() + { + // Verifies that Unity mirrors the POSIX installer default install directory. + string result = NativeCliInstaller.GetDefaultInstallDirectoryFromRoots( + RuntimePlatform.OSXEditor, + "/Users/masamichi", + null); + + Assert.That(result, Is.EqualTo(System.IO.Path.Combine("/Users/masamichi", ".local", "bin"))); + } + + [Test] + public void GetDefaultInstallDirectoryFromRoots_OnWindowsMatchesInstallerDefault() + { + // Verifies that Unity mirrors the PowerShell installer default install directory. + string result = NativeCliInstaller.GetDefaultInstallDirectoryFromRoots( + RuntimePlatform.WindowsEditor, + null, + "C:\\Users\\masamichi\\AppData\\Local"); + + Assert.That(result, Is.EqualTo(System.IO.Path.Combine( + "C:\\Users\\masamichi\\AppData\\Local", + "Programs", + "uloop", + "bin"))); + } } } diff --git a/Packages/src/Editor/CLI/CliConstants.cs b/Packages/src/Editor/CLI/CliConstants.cs index 58329b95c..37f3eb5dc 100644 --- a/Packages/src/Editor/CLI/CliConstants.cs +++ b/Packages/src/Editor/CLI/CliConstants.cs @@ -8,9 +8,20 @@ public static class CliConstants public const string RELEASE_DOWNLOAD_BASE_URL = "https://github.com/hatayama/unity-cli-loop/releases/download"; public const string POSIX_INSTALL_SCRIPT_NAME = "install.sh"; public const string WINDOWS_INSTALL_SCRIPT_NAME = "install.ps1"; + public const string INSTALL_DIR_ENVIRONMENT_VARIABLE = "ULOOP_INSTALL_DIR"; public const string INSTALL_VERSION_ENVIRONMENT_VARIABLE = "ULOOP_VERSION"; public const string REMOVE_LEGACY_ENVIRONMENT_VARIABLE = "ULOOP_REMOVE_LEGACY"; public const string REMOVE_LEGACY_ENABLED_VALUE = "1"; + public const string POSIX_HOME_ENVIRONMENT_VARIABLE = "HOME"; + public const string POSIX_PATH_ENVIRONMENT_VARIABLE = "PATH"; + public const string WINDOWS_LOCAL_APPDATA_ENVIRONMENT_VARIABLE = "LOCALAPPDATA"; + public const string WINDOWS_PATH_ENVIRONMENT_VARIABLE = "Path"; + public const string POSIX_LOCAL_DIR_NAME = ".local"; + public const string WINDOWS_PROGRAMS_DIR_NAME = "Programs"; + public const string NATIVE_INSTALL_DIR_NAME = "uloop"; + public const string NATIVE_INSTALL_BIN_DIR_NAME = "bin"; + public const string POSIX_PATH_SEPARATOR = ":"; + public const string WINDOWS_PATH_SEPARATOR = ";"; public const string RELEASE_TAG_PREFIX = "v"; public const string SKILL_DIR_PREFIX = "uloop-"; public const string SKILL_DIR_GLOB = "uloop-*"; diff --git a/Packages/src/Editor/CLI/NativeCliInstaller.cs b/Packages/src/Editor/CLI/NativeCliInstaller.cs index 049e67da1..8a1d62817 100644 --- a/Packages/src/Editor/CLI/NativeCliInstaller.cs +++ b/Packages/src/Editor/CLI/NativeCliInstaller.cs @@ -1,5 +1,6 @@ using System; using System.Diagnostics; +using System.IO; using System.Text; using System.Threading.Tasks; using UnityEngine; @@ -76,6 +77,7 @@ public static async Task InstallAsync( RedirectStandardError = true, CreateNoWindow = true }; + ApplyInstallerSearchPath(startInfo, platform); bool success = false; string errorOutput = ""; @@ -110,10 +112,159 @@ await Task.Run(() => } }); + if (success) + { + ApplyInstallDirectoryToCurrentProcessPath(platform); + } + CliInstallationDetector.InvalidateCache(); return new CliInstallResult(success, errorOutput); } + internal static string BuildPathWithInstallDirectory( + string currentPath, + string installDirectory, + RuntimePlatform platform) + { + UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(installDirectory), "installDirectory must not be null or empty"); + + string normalizedPath = currentPath ?? ""; + if (ContainsPathDirectory(normalizedPath, installDirectory, platform)) + { + return normalizedPath; + } + + if (string.IsNullOrEmpty(normalizedPath)) + { + return installDirectory; + } + + return normalizedPath + GetPathSeparator(platform) + installDirectory; + } + + internal static string GetDefaultInstallDirectoryFromRoots( + RuntimePlatform platform, + string homeDirectory, + string localAppData) + { + if (platform == RuntimePlatform.WindowsEditor) + { + if (string.IsNullOrWhiteSpace(localAppData)) + { + return null; + } + + return Path.Combine( + localAppData, + CliConstants.WINDOWS_PROGRAMS_DIR_NAME, + CliConstants.NATIVE_INSTALL_DIR_NAME, + CliConstants.NATIVE_INSTALL_BIN_DIR_NAME); + } + + if (string.IsNullOrWhiteSpace(homeDirectory)) + { + return null; + } + + return Path.Combine( + homeDirectory, + CliConstants.POSIX_LOCAL_DIR_NAME, + CliConstants.NATIVE_INSTALL_BIN_DIR_NAME); + } + + private static void ApplyInstallerSearchPath(ProcessStartInfo startInfo, RuntimePlatform platform) + { + UnityEngine.Debug.Assert(startInfo != null, "startInfo must not be null"); + + if (platform == RuntimePlatform.WindowsEditor) + { + return; + } + + string loginShellPath = NodeEnvironmentResolver.GetLoginShellPathAtPlatform(platform); + if (string.IsNullOrEmpty(loginShellPath)) + { + return; + } + + startInfo.Environment[CliConstants.POSIX_PATH_ENVIRONMENT_VARIABLE] = loginShellPath; + } + + private static void ApplyInstallDirectoryToCurrentProcessPath(RuntimePlatform platform) + { + string installDirectory = GetInstallDirectoryForCurrentUser(platform); + if (string.IsNullOrEmpty(installDirectory)) + { + return; + } + + string pathVariableName = GetPathEnvironmentVariableName(platform); + string currentPath = Environment.GetEnvironmentVariable(pathVariableName); + string updatedPath = BuildPathWithInstallDirectory(currentPath, installDirectory, platform); + Environment.SetEnvironmentVariable(pathVariableName, updatedPath); + } + + private static string GetInstallDirectoryForCurrentUser(RuntimePlatform platform) + { + string configuredInstallDirectory = Environment.GetEnvironmentVariable(CliConstants.INSTALL_DIR_ENVIRONMENT_VARIABLE); + if (!string.IsNullOrWhiteSpace(configuredInstallDirectory)) + { + return configuredInstallDirectory; + } + + string homeDirectory = Environment.GetEnvironmentVariable(CliConstants.POSIX_HOME_ENVIRONMENT_VARIABLE); + if (string.IsNullOrWhiteSpace(homeDirectory)) + { + homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + } + + string localAppData = Environment.GetEnvironmentVariable(CliConstants.WINDOWS_LOCAL_APPDATA_ENVIRONMENT_VARIABLE); + return GetDefaultInstallDirectoryFromRoots(platform, homeDirectory, localAppData); + } + + private static bool ContainsPathDirectory(string currentPath, string installDirectory, RuntimePlatform platform) + { + if (string.IsNullOrEmpty(currentPath)) + { + return false; + } + + string[] entries = currentPath.Split( + new[] { GetPathSeparator(platform) }, + StringSplitOptions.RemoveEmptyEntries); + StringComparison comparison = GetPathComparison(platform); + foreach (string entry in entries) + { + if (string.Equals(entry, installDirectory, comparison)) + { + return true; + } + } + + return false; + } + + private static string GetPathEnvironmentVariableName(RuntimePlatform platform) + { + return platform == RuntimePlatform.WindowsEditor + ? CliConstants.WINDOWS_PATH_ENVIRONMENT_VARIABLE + : CliConstants.POSIX_PATH_ENVIRONMENT_VARIABLE; + } + + private static string GetPathSeparator(RuntimePlatform platform) + { + return platform == RuntimePlatform.WindowsEditor + ? CliConstants.WINDOWS_PATH_SEPARATOR + : CliConstants.POSIX_PATH_SEPARATOR; + } + + private static StringComparison GetPathComparison(RuntimePlatform platform) + { + return platform == RuntimePlatform.WindowsEditor + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + } + private static string BuildWindowsRemoveLegacyAssignment(bool removeLegacyNpm) { if (!removeLegacyNpm) diff --git a/Packages/src/Editor/Utils/NodeEnvironmentResolver.cs b/Packages/src/Editor/Utils/NodeEnvironmentResolver.cs index 6543df8fe..76423f616 100644 --- a/Packages/src/Editor/Utils/NodeEnvironmentResolver.cs +++ b/Packages/src/Editor/Utils/NodeEnvironmentResolver.cs @@ -185,12 +185,7 @@ private static string ExecuteAndGetOutput(ProcessStartInfo startInfo) private const string WHICH_END_MARKER = "__WHICH_END__"; // Uses markers to extract PATH value, ignoring any banner/echo output from shell startup files - private static string GetLoginShellPath() - { - return GetLoginShellPathAtPlatform(Application.platform); - } - - private static string GetLoginShellPathAtPlatform(RuntimePlatform platform) + internal static string GetLoginShellPathAtPlatform(RuntimePlatform platform) { if (IsWindowsEditor(platform)) { From 51f64d0070bacafac8adfc23d39a80d19287a8ad Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 3 May 2026 14:18:51 +0900 Subject: [PATCH 08/11] Move installer PATH lookup off the editor thread Resolve the login-shell PATH inside the background installer task so Settings and Setup Wizard do not block the Unity editor thread before the native CLI install starts. --- Packages/src/Editor/CLI/NativeCliInstaller.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/src/Editor/CLI/NativeCliInstaller.cs b/Packages/src/Editor/CLI/NativeCliInstaller.cs index 8a1d62817..2849e6ff5 100644 --- a/Packages/src/Editor/CLI/NativeCliInstaller.cs +++ b/Packages/src/Editor/CLI/NativeCliInstaller.cs @@ -77,13 +77,13 @@ public static async Task InstallAsync( RedirectStandardError = true, CreateNoWindow = true }; - ApplyInstallerSearchPath(startInfo, platform); bool success = false; string errorOutput = ""; await Task.Run(() => { + ApplyInstallerSearchPath(startInfo, platform); Process process = ProcessStartHelper.TryStart(startInfo); if (process == null) { From a747da16bd712d879a03b888d472d340552fad20 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 3 May 2026 14:26:08 +0900 Subject: [PATCH 09/11] Use copy semantics for Windows staged installer Keep Windows reinstall and update flows compatible with an existing uloop.exe by copying the verified staged binary over the final path instead of relying on Move-Item -Force overwrite behavior. --- scripts/install.ps1 | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 81fde003d..a88ebac4e 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -136,7 +136,9 @@ try { Copy-Item -Path (Join-Path $TempDir "uloop.exe") -Destination $StagedUloopPath -Force & $StagedUloopPath --version > $null Remove-LegacyNpmIfEnabled - Move-Item -Path $StagedUloopPath -Destination (Join-Path $InstallDir "uloop.exe") -Force + $FinalUloopPath = Join-Path $InstallDir "uloop.exe" + Copy-Item -Path $StagedUloopPath -Destination $FinalUloopPath -Force + Remove-Item -Path $StagedUloopPath -Force $StagedUloopPath = $null $UserPath = [Environment]::GetEnvironmentVariable("Path", "User") @@ -152,7 +154,7 @@ try { Write-Host "Added $InstallDir to User PATH. Open a new terminal to use it everywhere." } - & (Join-Path $InstallDir "uloop.exe") --version + & $FinalUloopPath --version Confirm-ActiveUloopAfterLegacyCleanup Write-LegacyNpmWarningIfPresent Report-PathShadowing From ff606ebbe1f52ed5b1328276a4158acd5a3b13fa Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 3 May 2026 14:51:58 +0900 Subject: [PATCH 10/11] Stop Windows install when uloop verification fails Check the native process exit code after staged and final --version probes so the installer does not remove a legacy npm launcher or report success after a bad Windows asset fails verification. --- scripts/install.ps1 | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index a88ebac4e..4b22cd760 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -114,6 +114,25 @@ function Report-PathShadowing { Write-Host "Move $InstallDir earlier in PATH, or remove the legacy installation if it owns that command." } +function Assert-UloopVersionSucceeds { + param( + [Parameter(Mandatory = $true)] + [string]$UloopPath, + [switch]$Quiet + ) + + if ($Quiet) { + & $UloopPath --version > $null + } + else { + & $UloopPath --version + } + + if ($LASTEXITCODE -ne 0) { + throw "uloop binary verification failed for $UloopPath" + } +} + $TempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("uloop-install-" + [System.Guid]::NewGuid().ToString("N")) $StagedUloopPath = $null New-Item -ItemType Directory -Path $TempDir | Out-Null @@ -134,7 +153,7 @@ try { New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null $StagedUloopPath = Join-Path $InstallDir ("uloop-install-" + [System.Guid]::NewGuid().ToString("N") + ".exe") Copy-Item -Path (Join-Path $TempDir "uloop.exe") -Destination $StagedUloopPath -Force - & $StagedUloopPath --version > $null + Assert-UloopVersionSucceeds -UloopPath $StagedUloopPath -Quiet Remove-LegacyNpmIfEnabled $FinalUloopPath = Join-Path $InstallDir "uloop.exe" Copy-Item -Path $StagedUloopPath -Destination $FinalUloopPath -Force @@ -154,7 +173,7 @@ try { Write-Host "Added $InstallDir to User PATH. Open a new terminal to use it everywhere." } - & $FinalUloopPath --version + Assert-UloopVersionSucceeds -UloopPath $FinalUloopPath Confirm-ActiveUloopAfterLegacyCleanup Write-LegacyNpmWarningIfPresent Report-PathShadowing From c9b368346b276b471ca10aafb6339514f14211db Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 3 May 2026 15:16:10 +0900 Subject: [PATCH 11/11] Prefer native install path after Unity setup Move the native install directory to the front of the Unity process PATH so installer success immediately resolves the freshly installed uloop instead of an earlier legacy npm shim. --- .../Tests/Editor/NativeCliInstallerTests.cs | 18 ++++---- Packages/src/Editor/CLI/NativeCliInstaller.cs | 44 +++++++------------ 2 files changed, 26 insertions(+), 36 deletions(-) diff --git a/Assets/Tests/Editor/NativeCliInstallerTests.cs b/Assets/Tests/Editor/NativeCliInstallerTests.cs index 3df61f18b..dc3837bf1 100644 --- a/Assets/Tests/Editor/NativeCliInstallerTests.cs +++ b/Assets/Tests/Editor/NativeCliInstallerTests.cs @@ -66,39 +66,39 @@ public void GetInstallCommand_OnWindowsCanOptIntoLegacyNpmRemoval() } [Test] - public void BuildPathWithInstallDirectory_OnWindowsAppendsMissingNativeInstallDir() + public void BuildPathWithInstallDirectory_OnWindowsPrependsMissingNativeInstallDir() { - // Verifies that Unity's current Windows PATH can be extended for immediate native CLI detection. + // Verifies that Unity's current Windows PATH prefers the freshly installed native CLI. string result = NativeCliInstaller.BuildPathWithInstallDirectory( "C:\\npm", "C:\\Users\\masamichi\\Programs\\uloop\\bin", RuntimePlatform.WindowsEditor); - Assert.That(result, Is.EqualTo("C:\\npm;C:\\Users\\masamichi\\Programs\\uloop\\bin")); + Assert.That(result, Is.EqualTo("C:\\Users\\masamichi\\Programs\\uloop\\bin;C:\\npm")); } [Test] - public void BuildPathWithInstallDirectory_OnWindowsDoesNotDuplicateExistingNativeInstallDir() + public void BuildPathWithInstallDirectory_OnWindowsMovesExistingNativeInstallDirToFront() { - // Verifies that Windows PATH matching is case-insensitive when the native install dir is already present. + // Verifies that a later Windows native install dir does not leave an earlier npm shim first. string result = NativeCliInstaller.BuildPathWithInstallDirectory( "C:\\npm;C:\\USERS\\MASAMICHI\\PROGRAMS\\ULOOP\\BIN", "C:\\Users\\masamichi\\Programs\\uloop\\bin", RuntimePlatform.WindowsEditor); - Assert.That(result, Is.EqualTo("C:\\npm;C:\\USERS\\MASAMICHI\\PROGRAMS\\ULOOP\\BIN")); + Assert.That(result, Is.EqualTo("C:\\Users\\masamichi\\Programs\\uloop\\bin;C:\\npm")); } [Test] - public void BuildPathWithInstallDirectory_OnMacAppendsMissingNativeInstallDir() + public void BuildPathWithInstallDirectory_OnMacPrependsMissingNativeInstallDir() { - // Verifies that POSIX PATH keeps colon separation when adding the native install dir. + // Verifies that POSIX PATH prefers the freshly installed native CLI. string result = NativeCliInstaller.BuildPathWithInstallDirectory( "/usr/local/bin", "/Users/masamichi/.local/bin", RuntimePlatform.OSXEditor); - Assert.That(result, Is.EqualTo("/usr/local/bin:/Users/masamichi/.local/bin")); + Assert.That(result, Is.EqualTo("/Users/masamichi/.local/bin:/usr/local/bin")); } [Test] diff --git a/Packages/src/Editor/CLI/NativeCliInstaller.cs b/Packages/src/Editor/CLI/NativeCliInstaller.cs index 2849e6ff5..658c1af0a 100644 --- a/Packages/src/Editor/CLI/NativeCliInstaller.cs +++ b/Packages/src/Editor/CLI/NativeCliInstaller.cs @@ -129,17 +129,29 @@ internal static string BuildPathWithInstallDirectory( UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(installDirectory), "installDirectory must not be null or empty"); string normalizedPath = currentPath ?? ""; - if (ContainsPathDirectory(normalizedPath, installDirectory, platform)) + if (string.IsNullOrEmpty(normalizedPath)) { - return normalizedPath; + return installDirectory; } - if (string.IsNullOrEmpty(normalizedPath)) + string separator = GetPathSeparator(platform); + string[] entries = normalizedPath.Split( + new[] { separator }, + StringSplitOptions.RemoveEmptyEntries); + StringComparison comparison = GetPathComparison(platform); + StringBuilder builder = new StringBuilder(installDirectory); + foreach (string entry in entries) { - return installDirectory; + if (string.Equals(entry, installDirectory, comparison)) + { + continue; + } + + builder.Append(separator); + builder.Append(entry); } - return normalizedPath + GetPathSeparator(platform) + installDirectory; + return builder.ToString(); } internal static string GetDefaultInstallDirectoryFromRoots( @@ -222,28 +234,6 @@ private static string GetInstallDirectoryForCurrentUser(RuntimePlatform platform return GetDefaultInstallDirectoryFromRoots(platform, homeDirectory, localAppData); } - private static bool ContainsPathDirectory(string currentPath, string installDirectory, RuntimePlatform platform) - { - if (string.IsNullOrEmpty(currentPath)) - { - return false; - } - - string[] entries = currentPath.Split( - new[] { GetPathSeparator(platform) }, - StringSplitOptions.RemoveEmptyEntries); - StringComparison comparison = GetPathComparison(platform); - foreach (string entry in entries) - { - if (string.Equals(entry, installDirectory, comparison)) - { - return true; - } - } - - return false; - } - private static string GetPathEnvironmentVariableName(RuntimePlatform platform) { return platform == RuntimePlatform.WindowsEditor