diff --git a/Assets/Tests/Editor/NativeCliInstallerTests.cs b/Assets/Tests/Editor/NativeCliInstallerTests.cs index b58f90e6e..dc3837bf1 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,108 @@ 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'")); + } + + [Test] + public void BuildPathWithInstallDirectory_OnWindowsPrependsMissingNativeInstallDir() + { + // 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:\\Users\\masamichi\\Programs\\uloop\\bin;C:\\npm")); + } + + [Test] + public void BuildPathWithInstallDirectory_OnWindowsMovesExistingNativeInstallDirToFront() + { + // 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:\\Users\\masamichi\\Programs\\uloop\\bin;C:\\npm")); + } + + [Test] + public void BuildPathWithInstallDirectory_OnMacPrependsMissingNativeInstallDir() + { + // 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("/Users/masamichi/.local/bin:/usr/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 ae92f759a..37f3eb5dc 100644 --- a/Packages/src/Editor/CLI/CliConstants.cs +++ b/Packages/src/Editor/CLI/CliConstants.cs @@ -8,7 +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 aad8fa32c..658c1af0a 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; @@ -29,7 +30,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 +43,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 +54,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 +62,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, @@ -74,6 +83,7 @@ public static async Task InstallAsync(RuntimePlatform platform await Task.Run(() => { + ApplyInstallerSearchPath(startInfo, platform); Process process = ProcessStartHelper.TryStart(startInfo); if (process == null) { @@ -102,10 +112,169 @@ 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 (string.IsNullOrEmpty(normalizedPath)) + { + return installDirectory; + } + + 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) + { + if (string.Equals(entry, installDirectory, comparison)) + { + continue; + } + + builder.Append(separator); + builder.Append(entry); + } + + return builder.ToString(); + } + + 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 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) + { + 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/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)) { diff --git a/Packages/src/README.md b/Packages/src/README.md index 3a8f84f42..2ac7af7f6 100644 --- a/Packages/src/README.md +++ b/Packages/src/README.md @@ -91,6 +91,19 @@ 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 +``` + +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 9feb7ec7b..a93373b31 100644 --- a/Packages/src/README_ja.md +++ b/Packages/src/README_ja.md @@ -92,6 +92,19 @@ 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 +``` + +Settings と Setup Wizard の **Install CLI** ボタンは同じ installer を使い、旧 npm package の削除も自動で有効化します。 + Window > Unity CLI Loop > Settingsを選択します。専用ウィンドウが開くので **CLI** ボタンが青くなっている事を確認します。 1 diff --git a/README.md b/README.md index 3a8f84f42..2ac7af7f6 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,19 @@ 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 +``` + +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/scripts/install.ps1 b/scripts/install.ps1 index 82c455cb5..4b22cd760 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 @@ -8,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" @@ -16,7 +18,123 @@ 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 { + $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 { + $NpmCommand = Get-NpmCommand + if (-not $NpmCommand) { + return $false + } + + & $NpmCommand list -g $LegacyNpmPackage --depth=0 > $null 2> $null + return $LASTEXITCODE -eq 0 +} + +function Remove-LegacyNpmIfEnabled { + if (-not (Test-LegacyNpmInstalled)) { + return + } + + if (Test-RemoveLegacyEnabled) { + $NpmCommand = Get-NpmCommand + Write-Host "Removing legacy npm installation: $LegacyNpmPackage" + & $NpmCommand uninstall -g $LegacyNpmPackage + if ($LASTEXITCODE -ne 0) { + $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 + } + + 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." +} + +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 try { @@ -33,7 +151,14 @@ try { Expand-Archive -Path $ArchivePath -DestinationPath $TempDir -Force 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 + Assert-UloopVersionSucceeds -UloopPath $StagedUloopPath -Quiet + Remove-LegacyNpmIfEnabled + $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") $PathEntries = @() @@ -48,8 +173,14 @@ try { Write-Host "Added $InstallDir to User PATH. Open a new terminal to use it everywhere." } - & (Join-Path $InstallDir "uloop.exe") --version + Assert-UloopVersionSucceeds -UloopPath $FinalUloopPath + Confirm-ActiveUloopAfterLegacyCleanup + Write-LegacyNpmWarningIfPresent + 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 40c2daf9d..78e6aebf2 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -4,6 +4,80 @@ 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}" +legacy_cleanup_failed=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_legacy_npm_if_enabled() { + if ! is_legacy_npm_installed; then + return + fi + + if is_remove_legacy_enabled; then + echo "Removing legacy npm installation: $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 + 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) @@ -38,7 +112,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 @@ -68,7 +143,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" -install -m 0755 "$tmp_dir/uloop" "$INSTALL_DIR/uloop" +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 +mv -f "$staged_uloop_path" "$INSTALL_DIR/uloop" +staged_uloop_path="" case ":$PATH:" in *":$INSTALL_DIR:"*) ;; @@ -80,3 +160,6 @@ case ":$PATH:" in esac "$INSTALL_DIR/uloop" --version +ensure_active_uloop_after_legacy_cleanup +report_legacy_npm_if_present +report_path_shadowing