diff --git a/.gitignore b/.gitignore index db42b195b..d32cf9a63 100644 --- a/.gitignore +++ b/.gitignore @@ -108,22 +108,20 @@ yarn-error.log* .npm .yarn-integrity -# TypeScript Server (exclude development builds, commit only production bundle) -Packages/src/TypeScriptServer~/dist/* -!Packages/src/TypeScriptServer~/dist/server.bundle.js -!Packages/src/TypeScriptServer~/dist/server.bundle.js.map - # Native Go CLI release artifacts are included in the Unity package Packages/src/GoCli~/dist/* !Packages/src/GoCli~/dist/darwin-arm64/ !Packages/src/GoCli~/dist/darwin-amd64/ !Packages/src/GoCli~/dist/windows-amd64/ -!Packages/src/GoCli~/dist/darwin-arm64/uloop -!Packages/src/GoCli~/dist/darwin-amd64/uloop -!Packages/src/GoCli~/dist/windows-amd64/uloop.exe -!Packages/src/GoCli~/dist/darwin-arm64/uloop-launcher -!Packages/src/GoCli~/dist/darwin-amd64/uloop-launcher -!Packages/src/GoCli~/dist/windows-amd64/uloop-launcher.exe +Packages/src/GoCli~/dist/darwin-arm64/* +Packages/src/GoCli~/dist/darwin-amd64/* +Packages/src/GoCli~/dist/windows-amd64/* +!Packages/src/GoCli~/dist/darwin-arm64/uloop-core +!Packages/src/GoCli~/dist/darwin-arm64/uloop-dispatcher +!Packages/src/GoCli~/dist/darwin-amd64/uloop-core +!Packages/src/GoCli~/dist/darwin-amd64/uloop-dispatcher +!Packages/src/GoCli~/dist/windows-amd64/uloop-core.exe +!Packages/src/GoCli~/dist/windows-amd64/uloop-dispatcher.exe Packages/src/GoCli~/release/ # Environment configuration files diff --git a/Assets/Tests/Editor/NativeCliInstallerTests.cs b/Assets/Tests/Editor/NativeCliInstallerTests.cs index dc3837bf1..c339abd09 100644 --- a/Assets/Tests/Editor/NativeCliInstallerTests.cs +++ b/Assets/Tests/Editor/NativeCliInstallerTests.cs @@ -1,3 +1,5 @@ +using System.IO; +using System.Runtime.InteropServices; using NUnit.Framework; using UnityEngine; @@ -6,9 +8,9 @@ namespace io.github.hatayama.UnityCliLoop.Tests public class NativeCliInstallerTests { [Test] - public void GetInstallCommand_OnMacUsesDirectInstallScriptWithoutNpm() + public void GetInstallCommand_OnMacKeepsCliOnlyCurlInstallerAvailable() { - // Verifies that macOS installs through the native release script, not npm. + // Verifies that CLI-only macOS users still have the direct release script, not npm. NativeCliInstallCommand command = NativeCliInstaller.GetInstallCommand( RuntimePlatform.OSXEditor, "3.0.0-beta.0", @@ -23,9 +25,9 @@ public void GetInstallCommand_OnMacUsesDirectInstallScriptWithoutNpm() } [Test] - public void GetInstallCommand_OnWindowsUsesPowerShellInstallScriptWithoutNpm() + public void GetInstallCommand_OnWindowsKeepsCliOnlyPowerShellInstallerAvailable() { - // Verifies that Windows installs through the native release script, not npm. + // Verifies that CLI-only Windows users still have the direct release script, not npm. NativeCliInstallCommand command = NativeCliInstaller.GetInstallCommand( RuntimePlatform.WindowsEditor, "3.0.0-beta.0", @@ -40,9 +42,9 @@ public void GetInstallCommand_OnWindowsUsesPowerShellInstallScriptWithoutNpm() } [Test] - public void GetInstallCommand_OnMacCanOptIntoLegacyNpmRemoval() + public void GetInstallCommand_OnMacCliOnlyInstallerCanOptIntoLegacyNpmRemoval() { - // Verifies that UI-triggered macOS installs can opt into removing the legacy npm launcher. + // Verifies that CLI-only macOS installs can opt into removing the legacy npm launcher. NativeCliInstallCommand command = NativeCliInstaller.GetInstallCommand( RuntimePlatform.OSXEditor, "3.0.0-beta.0", @@ -53,9 +55,9 @@ public void GetInstallCommand_OnMacCanOptIntoLegacyNpmRemoval() } [Test] - public void GetInstallCommand_OnWindowsCanOptIntoLegacyNpmRemoval() + public void GetInstallCommand_OnWindowsCliOnlyInstallerCanOptIntoLegacyNpmRemoval() { - // Verifies that UI-triggered Windows installs can opt into removing the legacy npm launcher. + // Verifies that CLI-only Windows installs can opt into removing the legacy npm launcher. NativeCliInstallCommand command = NativeCliInstaller.GetInstallCommand( RuntimePlatform.WindowsEditor, "3.0.0-beta.0", @@ -65,6 +67,219 @@ public void GetInstallCommand_OnWindowsCanOptIntoLegacyNpmRemoval() Assert.That(command.ManualCommand, Does.Contain("$env:ULOOP_REMOVE_LEGACY='1'")); } + [Test] + public void GetGlobalCliBundlePath_OnMacArm64UsesPackagedDispatcher() + { + // Verifies that the editor installer reads the bundled macOS dispatcher from the package. + string result = NativeCliInstaller.GetGlobalCliBundlePath( + "/package", + RuntimePlatform.OSXEditor, + Architecture.Arm64); + + Assert.That(result, Is.EqualTo(Path.Combine( + "/package", + "GoCli~", + "dist", + "darwin-arm64", + "uloop-dispatcher"))); + } + + [Test] + public void GetGlobalCliBundlePath_OnWindowsUsesPackagedDispatcher() + { + // Verifies that the editor installer reads the bundled Windows dispatcher from the package. + string result = NativeCliInstaller.GetGlobalCliBundlePath( + "C:\\package", + RuntimePlatform.WindowsEditor, + Architecture.X64); + + Assert.That(result, Is.EqualTo(Path.Combine( + "C:\\package", + "GoCli~", + "dist", + "windows-amd64", + "uloop-dispatcher.exe"))); + } + + [Test] + public void InstallGlobalCliFromBundle_OnWindowsCopiesDispatcherAsUloopExe() + { + // Verifies that editor install copies the bundled dispatcher as the user-facing uloop command. + string tempRoot = Path.Combine( + Path.GetTempPath(), + "uloop-native-installer-tests", + System.Guid.NewGuid().ToString("N")); + string sourceDir = Path.Combine(tempRoot, "source"); + string sourcePath = Path.Combine(sourceDir, "uloop-dispatcher.exe"); + string installDir = Path.Combine(tempRoot, "install"); + + Directory.CreateDirectory(sourceDir); + File.WriteAllText(sourcePath, "fake-binary"); + + try + { + CliInstallResult result = NativeCliInstaller.InstallGlobalCliFromBundle( + sourcePath, + installDir, + RuntimePlatform.WindowsEditor); + + string installedPath = NativeCliInstaller.GetGlobalCliInstallPath( + installDir, + RuntimePlatform.WindowsEditor); + Assert.That(result.Success, Is.True); + Assert.That(result.ErrorOutput, Is.Empty); + Assert.That(Path.GetFileName(installedPath), Is.EqualTo("uloop.exe")); + Assert.That(File.ReadAllText(installedPath), Is.EqualTo("fake-binary")); + } + finally + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, true); + } + } + } + + [Test] + public void InstallGlobalCliFromBundle_WhenInstallPathExistsReplacesPreviousCommand() + { + // Verifies that editor install swaps the staged dispatcher into the final command path. + string tempRoot = Path.Combine( + Path.GetTempPath(), + "uloop-native-installer-tests", + System.Guid.NewGuid().ToString("N")); + string sourceDir = Path.Combine(tempRoot, "source"); + string sourcePath = Path.Combine(sourceDir, "uloop-dispatcher.exe"); + string installDir = Path.Combine(tempRoot, "install"); + string installPath = NativeCliInstaller.GetGlobalCliInstallPath( + installDir, + RuntimePlatform.WindowsEditor); + + Directory.CreateDirectory(sourceDir); + Directory.CreateDirectory(installDir); + File.WriteAllText(sourcePath, "new-binary"); + File.WriteAllText(installPath, "old-binary"); + + try + { + CliInstallResult result = NativeCliInstaller.InstallGlobalCliFromBundle( + sourcePath, + installDir, + RuntimePlatform.WindowsEditor); + + Assert.That(result.Success, Is.True); + Assert.That(File.ReadAllText(installPath), Is.EqualTo("new-binary")); + Assert.That(Directory.GetFiles(installDir, ".uloop.exe.install-*"), Is.Empty); + } + finally + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, true); + } + } + } + + [Test] + public void InstallGlobalCliFromBundle_WhenBundleIsMissingReturnsFailure() + { + // Verifies that editor install reports a missing packaged dispatcher without creating the install dir. + string tempRoot = Path.Combine( + Path.GetTempPath(), + "uloop-native-installer-tests", + System.Guid.NewGuid().ToString("N")); + string sourcePath = Path.Combine(tempRoot, "missing", "uloop-dispatcher.exe"); + string installDir = Path.Combine(tempRoot, "install"); + + try + { + CliInstallResult result = NativeCliInstaller.InstallGlobalCliFromBundle( + sourcePath, + installDir, + RuntimePlatform.WindowsEditor); + + Assert.That(result.Success, Is.False); + Assert.That(result.ErrorOutput, Does.Contain("Global CLI dispatcher binary was not found")); + Assert.That(Directory.Exists(installDir), Is.False); + } + finally + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, true); + } + } + } + + [Test] + public void InstallGlobalCliFromBundle_WhenInstallDirectoryIsFileReturnsFailure() + { + // Verifies that expected filesystem setup failures stay inside the installer result contract. + string tempRoot = Path.Combine( + Path.GetTempPath(), + "uloop-native-installer-tests", + System.Guid.NewGuid().ToString("N")); + string sourceDir = Path.Combine(tempRoot, "source"); + string sourcePath = Path.Combine(sourceDir, "uloop-dispatcher.exe"); + string installDir = Path.Combine(tempRoot, "install-as-file"); + + Directory.CreateDirectory(sourceDir); + File.WriteAllText(sourcePath, "fake-binary"); + File.WriteAllText(installDir, "not-a-directory"); + + try + { + CliInstallResult result = NativeCliInstaller.InstallGlobalCliFromBundle( + sourcePath, + installDir, + RuntimePlatform.WindowsEditor); + + Assert.That(result.Success, Is.False); + Assert.That(result.ErrorOutput, Does.Contain("Failed to install bundled CLI dispatcher")); + } + finally + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, true); + } + } + } + + [Test] + public void InstallGlobalCliFromBundle_WhenInstallDirectoryContainsNullCharacterReturnsFailure() + { + // Verifies that invalid user-provided install paths stay inside the installer result contract. + string tempRoot = Path.Combine( + Path.GetTempPath(), + "uloop-native-installer-tests", + System.Guid.NewGuid().ToString("N")); + string sourceDir = Path.Combine(tempRoot, "source"); + string sourcePath = Path.Combine(sourceDir, "uloop-dispatcher.exe"); + string installDir = tempRoot + Path.DirectorySeparatorChar + "bad\0path"; + + Directory.CreateDirectory(sourceDir); + File.WriteAllText(sourcePath, "fake-binary"); + + try + { + CliInstallResult result = NativeCliInstaller.InstallGlobalCliFromBundle( + sourcePath, + installDir, + RuntimePlatform.WindowsEditor); + + Assert.That(result.Success, Is.False); + Assert.That(result.ErrorOutput, Does.Contain("Failed to install bundled CLI dispatcher")); + } + finally + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, true); + } + } + } + [Test] public void BuildPathWithInstallDirectory_OnWindowsPrependsMissingNativeInstallDir() { @@ -101,6 +316,169 @@ public void BuildPathWithInstallDirectory_OnMacPrependsMissingNativeInstallDir() Assert.That(result, Is.EqualTo("/Users/masamichi/.local/bin:/usr/local/bin")); } + [Test] + public void PersistInstallDirectoryToUserPath_OnWindowsUpdatesUserPath() + { + // Verifies that Windows editor installs survive Unity restarts by updating User PATH. + string capturedName = null; + string capturedValue = null; + System.EnvironmentVariableTarget capturedTarget = default; + + CliInstallResult result = NativeCliInstaller.PersistInstallDirectoryToUserPath( + "C:\\Users\\masamichi\\Programs\\uloop\\bin", + RuntimePlatform.WindowsEditor, + (name, target) => "C:\\npm", + (name, value, target) => + { + capturedName = name; + capturedValue = value; + capturedTarget = target; + }); + + Assert.That(result.Success, Is.True); + Assert.That(capturedName, Is.EqualTo("Path")); + Assert.That(capturedValue, Is.EqualTo("C:\\Users\\masamichi\\Programs\\uloop\\bin;C:\\npm")); + Assert.That(capturedTarget, Is.EqualTo(System.EnvironmentVariableTarget.User)); + } + + [Test] + public void PersistInstallDirectoryToUserPath_OnMacDoesNothing() + { + // Verifies that POSIX editor installs do not attempt unsupported .NET User PATH writes. + bool wroteUserPath = false; + + CliInstallResult result = NativeCliInstaller.PersistInstallDirectoryToUserPath( + "/Users/masamichi/.local/bin", + RuntimePlatform.OSXEditor, + (name, target) => "/usr/local/bin", + (name, value, target) => { wroteUserPath = true; }); + + Assert.That(result.Success, Is.True); + Assert.That(wroteUserPath, Is.False); + } + + [Test] + public void PersistInstallDirectoryToUserPath_OnWindowsSurfacesPermissionFailure() + { + // Verifies that permission failures are reported instead of crashing the editor installer. + CliInstallResult result = NativeCliInstaller.PersistInstallDirectoryToUserPath( + "C:\\Users\\masamichi\\Programs\\uloop\\bin", + RuntimePlatform.WindowsEditor, + (name, target) => "C:\\npm", + (name, value, target) => throw new System.UnauthorizedAccessException("denied")); + + Assert.That(result.Success, Is.False); + Assert.That(result.ErrorOutput, Does.Contain("failed to persist the uLoop CLI install directory")); + Assert.That(result.ErrorOutput, Does.Contain("denied")); + } + + [Test] + public void RemoveLegacyNpmPackageIfPresent_WhenPackageMissingSkipsUninstall() + { + // Verifies that package installs do not require npm when the legacy launcher is absent. + int runCount = 0; + + CliInstallResult result = NativeCliInstaller.RemoveLegacyNpmPackageIfPresent( + RuntimePlatform.OSXEditor, + (command, platform) => + { + runCount++; + Assert.That(command.ManualCommand, Does.Contain("npm list -g uloop-cli")); + return new CliInstallResult(false, ""); + }); + + Assert.That(result.Success, Is.True); + Assert.That(runCount, Is.EqualTo(1)); + } + + [Test] + public void RemoveLegacyNpmPackageIfPresent_WhenPackageExistsRunsUninstall() + { + // Verifies that package installs preserve the previous UI cleanup of the legacy npm launcher. + int runCount = 0; + + CliInstallResult result = NativeCliInstaller.RemoveLegacyNpmPackageIfPresent( + RuntimePlatform.WindowsEditor, + (command, platform) => + { + runCount++; + string expectedCommand = runCount == 1 + ? "npm list -g uloop-cli" + : "npm uninstall -g uloop-cli"; + Assert.That(command.ManualCommand, Does.Contain(expectedCommand)); + return new CliInstallResult(true, ""); + }); + + Assert.That(result.Success, Is.True); + Assert.That(runCount, Is.EqualTo(2)); + } + + [Test] + public void RemoveLegacyNpmPackageIfPresent_WhenUninstallFailsReturnsManualCommand() + { + // Verifies that package installs fail visibly when the legacy launcher cannot be removed. + int runCount = 0; + + CliInstallResult result = NativeCliInstaller.RemoveLegacyNpmPackageIfPresent( + RuntimePlatform.WindowsEditor, + (command, platform) => + { + runCount++; + return runCount == 1 + ? new CliInstallResult(true, "") + : new CliInstallResult(false, "denied"); + }); + + Assert.That(result.Success, Is.False); + Assert.That(result.ErrorOutput, Does.Contain("Failed to remove legacy npm installation")); + Assert.That(result.ErrorOutput, Does.Contain("npm uninstall -g uloop-cli")); + Assert.That(result.ErrorOutput, Does.Contain("denied")); + } + + [Test] + public void FinishSuccessfulBundleInstall_WhenLegacyRemovalFailsStillUpdatesPaths() + { + // Verifies that a copied dispatcher still updates PATH before reporting legacy cleanup failure. + bool appliedCurrentPath = false; + bool persistedUserPath = false; + + CliInstallResult result = NativeCliInstaller.FinishSuccessfulBundleInstall( + new CliInstallResult(true, ""), + "C:\\Users\\masamichi\\Programs\\uloop\\bin", + RuntimePlatform.WindowsEditor, + platform => new CliInstallResult(false, "legacy failed"), + platform => { appliedCurrentPath = true; }, + (installDirectory, platform) => + { + persistedUserPath = true; + return new CliInstallResult(true, ""); + }); + + Assert.That(result.Success, Is.False); + Assert.That(result.ErrorOutput, Does.Contain("legacy failed")); + Assert.That(appliedCurrentPath, Is.True); + Assert.That(persistedUserPath, Is.True); + } + + [Test] + public void FinishSuccessfulBundleInstall_WhenPathPersistenceFailsReturnsPathFailure() + { + // Verifies that PATH persistence failure is reported after the current process PATH is updated. + bool appliedCurrentPath = false; + + CliInstallResult result = NativeCliInstaller.FinishSuccessfulBundleInstall( + new CliInstallResult(true, ""), + "C:\\Users\\masamichi\\Programs\\uloop\\bin", + RuntimePlatform.WindowsEditor, + platform => new CliInstallResult(false, "legacy failed"), + platform => { appliedCurrentPath = true; }, + (installDirectory, platform) => new CliInstallResult(false, "path failed")); + + Assert.That(result.Success, Is.False); + Assert.That(result.ErrorOutput, Does.Contain("path failed")); + Assert.That(appliedCurrentPath, Is.True); + } + [Test] public void GetDefaultInstallDirectoryFromRoots_OnMacMatchesInstallerDefault() { diff --git a/Packages/src/Editor/CLI/CliConstants.cs b/Packages/src/Editor/CLI/CliConstants.cs index 37f3eb5dc..75eee08b9 100644 --- a/Packages/src/Editor/CLI/CliConstants.cs +++ b/Packages/src/Editor/CLI/CliConstants.cs @@ -27,6 +27,11 @@ public static class CliConstants public const string SKILL_DIR_GLOB = "uloop-*"; public const string GO_CLI_PACKAGE_DIR_NAME = "GoCli~"; public const string DIST_DIR_NAME = "dist"; + public const string GLOBAL_UNIX_COMMAND_NAME = EXECUTABLE_NAME; + public const string GLOBAL_WINDOWS_COMMAND_NAME = EXECUTABLE_NAME + ".exe"; + public const string GLOBAL_DISPATCHER_UNIX_BUNDLE_NAME = "uloop-dispatcher"; + public const string GLOBAL_DISPATCHER_WINDOWS_BUNDLE_NAME = "uloop-dispatcher.exe"; + public const string LEGACY_NPM_PACKAGE_NAME = "uloop-cli"; public const string PROJECT_LOCAL_BIN_DIR_NAME = "bin"; public const string PROJECT_LOCAL_UNIX_COMMAND_NAME = "uloop-core"; public const string PROJECT_LOCAL_WINDOWS_COMMAND_NAME = "uloop-core.exe"; diff --git a/Packages/src/Editor/CLI/NativeCliInstaller.cs b/Packages/src/Editor/CLI/NativeCliInstaller.cs index 658c1af0a..daada971b 100644 --- a/Packages/src/Editor/CLI/NativeCliInstaller.cs +++ b/Packages/src/Editor/CLI/NativeCliInstaller.cs @@ -1,6 +1,8 @@ using System; using System.Diagnostics; using System.IO; +using System.Runtime.InteropServices; +using System.Security; using System.Text; using System.Threading.Tasks; using UnityEngine; @@ -26,10 +28,12 @@ public NativeCliInstallCommand(string fileName, string arguments, string manualC } /// - /// Centralizes native installer invocation so editor setup UI and Go CLI update use the same direct-distribution channel. + /// Installs the package-owned global dispatcher while keeping release-script commands available for CLI-only users. /// public static class NativeCliInstaller { + private const int CHMOD_TIMEOUT_MS = 5000; + public static NativeCliInstallCommand GetInstallCommand( RuntimePlatform platform, string packageVersion, @@ -64,61 +68,216 @@ public static NativeCliInstallCommand GetInstallCommand( public static async Task InstallAsync( RuntimePlatform platform, - string packageVersion, - bool removeLegacyNpm) + string packageVersion) { - NativeCliInstallCommand command = GetInstallCommand(platform, packageVersion, removeLegacyNpm); - ProcessStartInfo startInfo = new ProcessStartInfo + UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(packageVersion), "packageVersion must not be null or empty"); + + string installDirectory = GetInstallDirectoryForCurrentUser(platform); + if (string.IsNullOrWhiteSpace(installDirectory)) { - FileName = command.FileName, - Arguments = command.Arguments, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; + return new CliInstallResult( + false, + $"Could not resolve the global CLI install directory. Set {CliConstants.INSTALL_DIR_ENVIRONMENT_VARIABLE} and try again."); + } - bool success = false; - string errorOutput = ""; + string sourceBinaryPath = GetGlobalCliBundlePath( + McpConstants.PackageResolvedPath, + platform, + RuntimeInformation.ProcessArchitecture); - await Task.Run(() => + CliInstallResult result = await Task.Run(() => InstallGlobalCliFromBundle( + sourceBinaryPath, + installDirectory, + platform)); + CliInstallationDetector.InvalidateCache(); + + if (result.Success) { - ApplyInstallerSearchPath(startInfo, platform); - Process process = ProcessStartHelper.TryStart(startInfo); - if (process == null) + result = FinishSuccessfulBundleInstall( + result, + installDirectory, + platform, + currentPlatform => RemoveLegacyNpmPackageIfPresent(currentPlatform, RunInstallCommand), + ApplyInstallDirectoryToCurrentProcessPath, + (currentInstallDirectory, currentPlatform) => PersistInstallDirectoryToUserPath( + currentInstallDirectory, + currentPlatform, + Environment.GetEnvironmentVariable, + Environment.SetEnvironmentVariable)); + } + + return result; + } + + internal static CliInstallResult InstallGlobalCliFromBundle( + string sourceBinaryPath, + string installDirectory, + RuntimePlatform platform) + { + UnityEngine.Debug.Assert(!string.IsNullOrEmpty(sourceBinaryPath), "sourceBinaryPath must not be null or empty"); + UnityEngine.Debug.Assert(!string.IsNullOrEmpty(installDirectory), "installDirectory must not be null or empty"); + + if (!File.Exists(sourceBinaryPath)) + { + return new CliInstallResult( + false, + $"Global CLI dispatcher binary was not found for {platform}/{RuntimeInformation.ProcessArchitecture}: {sourceBinaryPath}"); + } + + try + { + string installPath = GetGlobalCliInstallPath(installDirectory, platform); + string stagedInstallPath = GetStagedGlobalCliInstallPath(installDirectory, platform); + Directory.CreateDirectory(installDirectory); + File.Copy(sourceBinaryPath, stagedInstallPath, overwrite: true); + + CliInstallResult executableResult = MakeGlobalCliExecutable(stagedInstallPath, platform); + if (!executableResult.Success) { - errorOutput = $"Failed to start installer process. Run manually:\n{command.ManualCommand}"; - return; + File.Delete(stagedInstallPath); + return executableResult; } - using (process) + ReplaceInstalledCliFromStaged(stagedInstallPath, installPath); + return executableResult; + } + catch (IOException ex) + { + return BuildBundledCliInstallFailure(ex); + } + catch (UnauthorizedAccessException ex) + { + return BuildBundledCliInstallFailure(ex); + } + catch (ArgumentException ex) + { + return BuildBundledCliInstallFailure(ex); + } + catch (NotSupportedException ex) + { + return BuildBundledCliInstallFailure(ex); + } + } + + internal static string GetGlobalCliBundlePath( + string packageResolvedPath, + RuntimePlatform platform, + Architecture architecture) + { + UnityEngine.Debug.Assert(!string.IsNullOrEmpty(packageResolvedPath), "packageResolvedPath must not be null or empty"); + + return Path.Combine( + packageResolvedPath, + CliConstants.GO_CLI_PACKAGE_DIR_NAME, + CliConstants.DIST_DIR_NAME, + GetNativeCliPlatformDir(platform, architecture), + GetGlobalCliBundleFileName(platform)); + } + + internal static string GetGlobalCliInstallPath(string installDirectory, RuntimePlatform platform) + { + UnityEngine.Debug.Assert(!string.IsNullOrEmpty(installDirectory), "installDirectory must not be null or empty"); + + return Path.Combine(installDirectory, GetGlobalCliInstallFileName(platform)); + } + + internal static CliInstallResult PersistInstallDirectoryToUserPath( + string installDirectory, + RuntimePlatform platform, + Func getEnvironmentVariable, + Action setEnvironmentVariable) + { + UnityEngine.Debug.Assert(!string.IsNullOrEmpty(installDirectory), "installDirectory must not be null or empty"); + UnityEngine.Debug.Assert(getEnvironmentVariable != null, "getEnvironmentVariable must not be null"); + UnityEngine.Debug.Assert(setEnvironmentVariable != null, "setEnvironmentVariable must not be null"); + + if (platform != RuntimePlatform.WindowsEditor) + { + return new CliInstallResult(true, ""); + } + + string pathVariableName = GetPathEnvironmentVariableName(platform); + try + { + string currentUserPath = getEnvironmentVariable(pathVariableName, EnvironmentVariableTarget.User); + string updatedUserPath = BuildPathWithInstallDirectory(currentUserPath, installDirectory, platform); + if (string.Equals(currentUserPath, updatedUserPath, GetPathComparison(platform))) { - StringBuilder errorBuilder = new StringBuilder(); - process.OutputDataReceived += (s, e) => { }; - process.ErrorDataReceived += (s, e) => { if (e.Data != null) errorBuilder.AppendLine(e.Data); }; - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); - - if (!process.WaitForExit(CliConstants.GLOBAL_INSTALL_TIMEOUT_MS)) - { - if (!process.HasExited) process.Kill(); - errorOutput = $"Installation timed out after {CliConstants.GLOBAL_INSTALL_TIMEOUT_MS / 1000} seconds.\nRun manually:\n{command.ManualCommand}"; - return; - } - - process.WaitForExit(); - errorOutput = errorBuilder.ToString(); - success = process.ExitCode == 0; + return new CliInstallResult(true, ""); } - }); - if (success) + setEnvironmentVariable(pathVariableName, updatedUserPath, EnvironmentVariableTarget.User); + return new CliInstallResult(true, ""); + } + catch (SecurityException ex) + { + return BuildUserPathPersistenceFailure(ex); + } + catch (UnauthorizedAccessException ex) { - ApplyInstallDirectoryToCurrentProcessPath(platform); + return BuildUserPathPersistenceFailure(ex); } + } - CliInstallationDetector.InvalidateCache(); - return new CliInstallResult(success, errorOutput); + internal static CliInstallResult RemoveLegacyNpmPackageIfPresent( + RuntimePlatform platform, + Func runCommand) + { + UnityEngine.Debug.Assert(runCommand != null, "runCommand must not be null"); + + NativeCliInstallCommand detectCommand = GetLegacyNpmListCommand(platform); + CliInstallResult detectResult = runCommand(detectCommand, platform); + if (!detectResult.Success) + { + return new CliInstallResult(true, ""); + } + + NativeCliInstallCommand uninstallCommand = GetLegacyNpmUninstallCommand(platform); + CliInstallResult uninstallResult = runCommand(uninstallCommand, platform); + if (uninstallResult.Success) + { + return new CliInstallResult(true, ""); + } + + string errorOutput = + $"Failed to remove legacy npm installation: {CliConstants.LEGACY_NPM_PACKAGE_NAME}\n" + + "The bundled dispatcher was installed, but an older npm launcher may still shadow it.\n" + + "Run manually:\n" + + $" {uninstallCommand.ManualCommand}\n" + + uninstallResult.ErrorOutput; + return new CliInstallResult(false, errorOutput); + } + + internal static CliInstallResult FinishSuccessfulBundleInstall( + CliInstallResult installResult, + string installDirectory, + RuntimePlatform platform, + Func removeLegacyNpmPackage, + Action applyInstallDirectoryToCurrentProcessPath, + Func persistInstallDirectoryToUserPath) + { + UnityEngine.Debug.Assert(installResult.Success, "installResult must be successful"); + UnityEngine.Debug.Assert(!string.IsNullOrEmpty(installDirectory), "installDirectory must not be null or empty"); + UnityEngine.Debug.Assert(removeLegacyNpmPackage != null, "removeLegacyNpmPackage must not be null"); + UnityEngine.Debug.Assert(applyInstallDirectoryToCurrentProcessPath != null, "applyInstallDirectoryToCurrentProcessPath must not be null"); + UnityEngine.Debug.Assert(persistInstallDirectoryToUserPath != null, "persistInstallDirectoryToUserPath must not be null"); + + CliInstallResult legacyResult = removeLegacyNpmPackage(platform); + CliInstallResult result = legacyResult.Success ? installResult : legacyResult; + + applyInstallDirectoryToCurrentProcessPath(platform); + CliInstallResult persistResult = persistInstallDirectoryToUserPath(installDirectory, platform); + return persistResult.Success ? result : persistResult; + } + + private static string GetStagedGlobalCliInstallPath(string installDirectory, RuntimePlatform platform) + { + UnityEngine.Debug.Assert(!string.IsNullOrEmpty(installDirectory), "installDirectory must not be null or empty"); + + string fileName = GetGlobalCliInstallFileName(platform); + return Path.Combine( + installDirectory, + $".{fileName}.install-{Guid.NewGuid():N}"); } internal static string BuildPathWithInstallDirectory( @@ -184,24 +343,6 @@ internal static string GetDefaultInstallDirectoryFromRoots( 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); @@ -255,6 +396,218 @@ private static StringComparison GetPathComparison(RuntimePlatform platform) : StringComparison.Ordinal; } + private static void ReplaceInstalledCliFromStaged(string stagedInstallPath, string installPath) + { + UnityEngine.Debug.Assert(!string.IsNullOrEmpty(stagedInstallPath), "stagedInstallPath must not be null or empty"); + UnityEngine.Debug.Assert(!string.IsNullOrEmpty(installPath), "installPath must not be null or empty"); + + if (!File.Exists(installPath)) + { + File.Move(stagedInstallPath, installPath); + return; + } + + File.Replace(stagedInstallPath, installPath, null, true); + } + + private static CliInstallResult BuildUserPathPersistenceFailure(Exception ex) + { + UnityEngine.Debug.Assert(ex != null, "ex must not be null"); + + string errorOutput = + "Installed the uLoop CLI binary, but failed to persist the uLoop CLI install directory in the Windows User PATH. " + + $"Update {CliConstants.WINDOWS_PATH_ENVIRONMENT_VARIABLE} manually or run the CLI-only installer.\n{ex.Message}"; + return new CliInstallResult(false, errorOutput); + } + + private static CliInstallResult BuildBundledCliInstallFailure(Exception ex) + { + UnityEngine.Debug.Assert(ex != null, "ex must not be null"); + + string errorOutput = $"Failed to install bundled CLI dispatcher: {ex.Message}"; + return new CliInstallResult(false, errorOutput); + } + + private static string GetGlobalCliBundleFileName(RuntimePlatform platform) + { + return platform == RuntimePlatform.WindowsEditor + ? CliConstants.GLOBAL_DISPATCHER_WINDOWS_BUNDLE_NAME + : CliConstants.GLOBAL_DISPATCHER_UNIX_BUNDLE_NAME; + } + + private static string GetGlobalCliInstallFileName(RuntimePlatform platform) + { + return platform == RuntimePlatform.WindowsEditor + ? CliConstants.GLOBAL_WINDOWS_COMMAND_NAME + : CliConstants.GLOBAL_UNIX_COMMAND_NAME; + } + + private static string GetNativeCliPlatformDir(RuntimePlatform platform, Architecture architecture) + { + if (platform == RuntimePlatform.OSXEditor) + { + return architecture == Architecture.Arm64 ? "darwin-arm64" : "darwin-amd64"; + } + + if (platform == RuntimePlatform.WindowsEditor) + { + return "windows-amd64"; + } + + return "unsupported"; + } + + private static CliInstallResult MakeGlobalCliExecutable(string installPath, RuntimePlatform platform) + { + UnityEngine.Debug.Assert(!string.IsNullOrEmpty(installPath), "installPath must not be null or empty"); + + if (platform == RuntimePlatform.WindowsEditor) + { + return new CliInstallResult(true, ""); + } + + ProcessStartInfo startInfo = new ProcessStartInfo + { + FileName = "/bin/chmod", + Arguments = $"+x {QuoteProcessArgument(installPath)}", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + Process process = ProcessStartHelper.TryStart(startInfo); + if (process == null) + { + return new CliInstallResult(false, "Failed to start chmod process"); + } + + bool exited = process.WaitForExit(CHMOD_TIMEOUT_MS); + if (!exited) + { + if (!process.HasExited) + { + process.Kill(); + } + + process.Dispose(); + return new CliInstallResult(false, "Making global CLI executable timed out"); + } + + process.WaitForExit(); + string errorOutput = process.StandardError.ReadToEnd(); + bool success = process.ExitCode == 0; + process.Dispose(); + + return new CliInstallResult(success, errorOutput); + } + + private static string QuoteProcessArgument(string value) + { + UnityEngine.Debug.Assert(value != null, "value must not be null"); + return $"\"{value.Replace("\"", "\\\"")}\""; + } + + private static NativeCliInstallCommand GetLegacyNpmListCommand(RuntimePlatform platform) + { + string command = $"npm list -g {CliConstants.LEGACY_NPM_PACKAGE_NAME} --depth=0"; + return BuildShellCommand(platform, command); + } + + private static NativeCliInstallCommand GetLegacyNpmUninstallCommand(RuntimePlatform platform) + { + string command = $"npm uninstall -g {CliConstants.LEGACY_NPM_PACKAGE_NAME}"; + return BuildShellCommand(platform, command); + } + + private static NativeCliInstallCommand BuildShellCommand(RuntimePlatform platform, string command) + { + UnityEngine.Debug.Assert(!string.IsNullOrEmpty(command), "command must not be null or empty"); + + if (platform == RuntimePlatform.WindowsEditor) + { + return new NativeCliInstallCommand( + "cmd.exe", + $"/c {command}", + command); + } + + return new NativeCliInstallCommand( + "/bin/sh", + $"-c \"{command}\"", + command); + } + + private static CliInstallResult RunInstallCommand(NativeCliInstallCommand command, RuntimePlatform platform) + { + ProcessStartInfo startInfo = new ProcessStartInfo + { + FileName = command.FileName, + Arguments = command.Arguments, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + ApplyInstallerSearchPath(startInfo, platform); + + Process process = ProcessStartHelper.TryStart(startInfo); + if (process == null) + { + return new CliInstallResult(false, $"Failed to start command: {command.ManualCommand}"); + } + + StringBuilder errorBuilder = new StringBuilder(); + process.OutputDataReceived += (sender, e) => { }; + process.ErrorDataReceived += (sender, e) => + { + if (e.Data != null) + { + errorBuilder.AppendLine(e.Data); + } + }; + + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + bool exited = process.WaitForExit(CliConstants.GLOBAL_INSTALL_TIMEOUT_MS); + if (!exited) + { + if (!process.HasExited) + { + process.Kill(); + } + + process.Dispose(); + return new CliInstallResult(false, $"Command timed out: {command.ManualCommand}"); + } + + process.WaitForExit(); + string errorOutput = errorBuilder.ToString(); + bool success = process.ExitCode == 0; + process.Dispose(); + return new CliInstallResult(success, errorOutput); + } + + 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 string BuildWindowsRemoveLegacyAssignment(bool removeLegacyNpm) { if (!removeLegacyNpm) diff --git a/Packages/src/Editor/UI/McpEditorWindow.cs b/Packages/src/Editor/UI/McpEditorWindow.cs index c1a42a8f4..5857a28f1 100644 --- a/Packages/src/Editor/UI/McpEditorWindow.cs +++ b/Packages/src/Editor/UI/McpEditorWindow.cs @@ -705,8 +705,7 @@ private async void HandleInstallCli() { CliInstallResult result = await NativeCliInstaller.InstallAsync( Application.platform, - McpConstants.PackageInfo.version, - true); + McpConstants.PackageInfo.version); if (!result.Success) { diff --git a/Packages/src/Editor/UI/Setup/SetupWizardWindow.cs b/Packages/src/Editor/UI/Setup/SetupWizardWindow.cs index b5eef741b..cd1ea53f7 100644 --- a/Packages/src/Editor/UI/Setup/SetupWizardWindow.cs +++ b/Packages/src/Editor/UI/Setup/SetupWizardWindow.cs @@ -806,8 +806,7 @@ private async void HandleInstallCli() { CliInstallResult result = await NativeCliInstaller.InstallAsync( Application.platform, - McpConstants.PackageInfo.version, - true); + McpConstants.PackageInfo.version); if (!result.Success) { diff --git a/Packages/src/README.md b/Packages/src/README.md index 2ac7af7f6..38a72e2b9 100644 --- a/Packages/src/README.md +++ b/Packages/src/README.md @@ -79,7 +79,12 @@ Scope(s): io.github.hatayama.uloopmcp ## Step 1: Install the CLI -Install the global `uloop` launcher from terminal: +Select Window > Unity CLI Loop > Settings. A dedicated window will open. If the **CLI** button is not highlighted in blue, press **Install CLI**. + +
+CLI-only terminal install + +Use this only when you want to install the standalone global CLI without opening Unity package setup. ```bash curl -fsSL https://raw.githubusercontent.com/hatayama/unity-cli-loop/main/scripts/install.sh | sh @@ -102,13 +107,11 @@ $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 -The Settings window shows whether the global `uloop` command is detected. The distributed command is backed by the `uloop-dispatcher` binary, and the Unity package refreshes this project's `.uloop/bin/uloop-core` CLI bundle automatically when it is missing, version-mismatched, or binary-mismatched. +The Settings window shows whether the global `uloop` command is detected. diff --git a/Packages/src/README_ja.md b/Packages/src/README_ja.md index a93373b31..dc3e01ebb 100644 --- a/Packages/src/README_ja.md +++ b/Packages/src/README_ja.md @@ -80,7 +80,12 @@ Scope(s): io.github.hatayama.uloopmcp ## ステップ1: CLIのインストール -ターミナルからグローバルな `uloop` ランチャーをインストールします。 +Window > Unity CLI Loop > Settingsを選択します。専用ウィンドウが開くので、**CLI** ボタンが青くなっていなければ **Install CLI** を押してください。 + +
+CLIだけをterminalからinstallする場合はこちら + +Unity Package の setup を開かず、standalone の global CLI だけを入れたい場合に使ってください。 ```bash curl -fsSL https://raw.githubusercontent.com/hatayama/unity-cli-loop/main/scripts/install.sh | sh @@ -103,13 +108,12 @@ $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 -Settings ウィンドウでは、グローバルな `uloop` コマンドが検出されているかを確認できます。配布されるコマンドの実体は `uloop-dispatcher` binary で、このプロジェクト用の `.uloop/bin/uloop-core` CLI bundle は、未作成・バージョン不一致・binary 不一致のときに Unity Package が自動更新します。 +Settings ウィンドウでは、グローバルな `uloop` コマンドが検出されているかを確認できます。 diff --git a/README.md b/README.md index 2ac7af7f6..38a72e2b9 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,12 @@ Scope(s): io.github.hatayama.uloopmcp ## Step 1: Install the CLI -Install the global `uloop` launcher from terminal: +Select Window > Unity CLI Loop > Settings. A dedicated window will open. If the **CLI** button is not highlighted in blue, press **Install CLI**. + +
+CLI-only terminal install + +Use this only when you want to install the standalone global CLI without opening Unity package setup. ```bash curl -fsSL https://raw.githubusercontent.com/hatayama/unity-cli-loop/main/scripts/install.sh | sh @@ -102,13 +107,11 @@ $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 -The Settings window shows whether the global `uloop` command is detected. The distributed command is backed by the `uloop-dispatcher` binary, and the Unity package refreshes this project's `.uloop/bin/uloop-core` CLI bundle automatically when it is missing, version-mismatched, or binary-mismatched. +The Settings window shows whether the global `uloop` command is detected. diff --git a/README_ja.md b/README_ja.md index 9feb7ec7b..dc3e01ebb 100644 --- a/README_ja.md +++ b/README_ja.md @@ -80,7 +80,12 @@ Scope(s): io.github.hatayama.uloopmcp ## ステップ1: CLIのインストール -ターミナルからグローバルな `uloop` ランチャーをインストールします。 +Window > Unity CLI Loop > Settingsを選択します。専用ウィンドウが開くので、**CLI** ボタンが青くなっていなければ **Install CLI** を押してください。 + +
+CLIだけをterminalからinstallする場合はこちら + +Unity Package の setup を開かず、standalone の global CLI だけを入れたい場合に使ってください。 ```bash curl -fsSL https://raw.githubusercontent.com/hatayama/unity-cli-loop/main/scripts/install.sh | sh @@ -92,11 +97,23 @@ Windows PowerShell の場合: irm https://raw.githubusercontent.com/hatayama/unity-cli-loop/main/scripts/install.ps1 | iex ``` -Window > Unity CLI Loop > Settingsを選択します。専用ウィンドウが開くので **CLI** ボタンが青くなっている事を確認します。 +過去に 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 +``` + +
+ 1 -Settings ウィンドウでは、グローバルな `uloop` コマンドが検出されているかを確認できます。配布されるコマンドの実体は `uloop-dispatcher` binary で、このプロジェクト用の `.uloop/bin/uloop-core` CLI bundle は、未作成・バージョン不一致・binary 不一致のときに Unity Package が自動更新します。 +Settings ウィンドウでは、グローバルな `uloop` コマンドが検出されているかを確認できます。