diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 2739b9eb6..1ce7806cd 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -21,7 +21,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v6 with: - go-version: '1.26.1' + go-version-file: Packages/src/GoCli~/.go-version cache-dependency-path: Packages/src/GoCli~/go.sum - name: Install golangci-lint diff --git a/.github/workflows/native-cli-publish.yml b/.github/workflows/native-cli-publish.yml index 2cc28aa48..88656687f 100644 --- a/.github/workflows/native-cli-publish.yml +++ b/.github/workflows/native-cli-publish.yml @@ -32,7 +32,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v6 with: - go-version: '1.26.1' + go-version-file: Packages/src/GoCli~/.go-version cache-dependency-path: Packages/src/GoCli~/go.sum - name: Install golangci-lint diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index ac02ea290..c8fa55d94 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -8,6 +8,7 @@ on: - 'Assets/**/*.cs' - 'Packages/src/GoCli~/**/*.go' - 'Packages/src/GoCli~/.golangci.yml' + - 'Packages/src/GoCli~/.go-version' - 'Packages/src/GoCli~/go.mod' - 'Packages/src/GoCli~/go.sum' pull_request: @@ -17,6 +18,7 @@ on: - 'Assets/**/*.cs' - 'Packages/src/GoCli~/**/*.go' - 'Packages/src/GoCli~/.golangci.yml' + - 'Packages/src/GoCli~/.go-version' - 'Packages/src/GoCli~/go.mod' - 'Packages/src/GoCli~/go.sum' workflow_dispatch: @@ -112,7 +114,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v6 with: - go-version: '1.26.1' + go-version-file: Packages/src/GoCli~/.go-version cache-dependency-path: Packages/src/GoCli~/go.sum - name: Install golangci-lint diff --git a/Assets/Tests/Editor/CliSetupSectionTests.cs b/Assets/Tests/Editor/CliSetupSectionTests.cs index 0a647703b..56c29e3d2 100644 --- a/Assets/Tests/Editor/CliSetupSectionTests.cs +++ b/Assets/Tests/Editor/CliSetupSectionTests.cs @@ -4,19 +4,21 @@ namespace io.github.hatayama.UnityCliLoop.Tests.Editor { public class CliSetupSectionTests { - [TestCase(false, false, false, false, false, null, "3.0.0", "Install CLI")] - [TestCase(true, false, false, false, false, "3.0.0", "3.0.0", "Uninstall CLI")] - [TestCase(true, false, false, true, false, "2.9.0", "3.0.0", "Update CLI (v2.9.0 \u2192 v3.0.0)")] - [TestCase(true, false, false, false, true, "3.1.0", "3.0.0", "Downgrade CLI (v3.1.0 \u2192 v3.0.0)")] - [TestCase(true, true, false, false, false, "3.0.0", "3.0.0", "Uninstalling...")] - [TestCase(false, true, false, false, false, null, "3.0.0", "Installing...")] - [TestCase(false, false, true, false, false, null, "3.0.0", "Checking...")] + [TestCase(false, false, false, false, false, false, null, "3.0.0", "Install CLI")] + [TestCase(true, false, false, false, false, true, "3.0.0", "3.0.0", "Uninstall CLI")] + [TestCase(true, false, false, false, false, false, "3.0.0", "3.0.0", "Install CLI")] + [TestCase(true, false, false, true, false, true, "2.9.0", "3.0.0", "Update CLI (v2.9.0 \u2192 v3.0.0)")] + [TestCase(true, false, false, false, true, true, "3.1.0", "3.0.0", "Downgrade CLI (v3.1.0 \u2192 v3.0.0)")] + [TestCase(true, true, false, false, false, true, "3.0.0", "3.0.0", "Uninstalling...")] + [TestCase(false, true, false, false, false, false, null, "3.0.0", "Installing...")] + [TestCase(false, false, true, false, false, false, null, "3.0.0", "Checking...")] public void GetInstallCliButtonText_ReturnsExpectedText( bool isCliInstalled, bool isInstallingCli, bool isChecking, bool needsUpdate, bool needsDowngrade, + bool canUninstallCli, string cliVersion, string packageVersion, string expectedText) @@ -27,6 +29,7 @@ public void GetInstallCliButtonText_ReturnsExpectedText( isChecking, needsUpdate, needsDowngrade, + canUninstallCli, cliVersion, packageVersion); @@ -48,20 +51,23 @@ public void IsInstallCliButtonEnabled_ReturnsExpectedValue( Assert.That(enabled, Is.EqualTo(expectedEnabled)); } - [TestCase(true, false, false, true)] - [TestCase(false, false, false, false)] - [TestCase(true, true, false, false)] - [TestCase(true, false, true, false)] + [TestCase(true, false, false, true, true)] + [TestCase(true, false, false, false, false)] + [TestCase(false, false, false, true, false)] + [TestCase(true, true, false, true, false)] + [TestCase(true, false, true, true, false)] public void IsUninstallCliAction_ReturnsExpectedValue( bool isCliInstalled, bool needsUpdate, bool needsDowngrade, + bool canUninstallCli, bool expected) { bool result = CliSetupSection.IsUninstallCliAction( isCliInstalled, needsUpdate, - needsDowngrade); + needsDowngrade, + canUninstallCli); Assert.That(result, Is.EqualTo(expected)); } diff --git a/Assets/Tests/Editor/LegacyNpmRemovalPromptTests.cs b/Assets/Tests/Editor/LegacyNpmRemovalPromptTests.cs deleted file mode 100644 index e74f5db4c..000000000 --- a/Assets/Tests/Editor/LegacyNpmRemovalPromptTests.cs +++ /dev/null @@ -1,58 +0,0 @@ -using NUnit.Framework; - -namespace io.github.hatayama.UnityCliLoop.Tests.Editor -{ - public class LegacyNpmRemovalPromptTests - { - [Test] - public void ConfirmInstallCanProceed_WhenLegacyInstallIsMissingSkipsDialog() - { - // Verifies that clean installs do not interrupt the setup wizard. - bool showedDialog = false; - - bool result = LegacyNpmRemovalPrompt.ConfirmInstallCanProceed( - hasLegacyNpmInstallation: false, - (title, message, ok, cancel) => - { - showedDialog = true; - return false; - }); - - Assert.That(result, Is.True); - Assert.That(showedDialog, Is.False); - } - - [Test] - public void ConfirmInstallCanProceed_WhenLegacyInstallExistsUsesUserChoice() - { - // Verifies that setup asks before removing the old Node.js/npm CLI. - bool showedDialog = false; - - bool result = LegacyNpmRemovalPrompt.ConfirmInstallCanProceed( - hasLegacyNpmInstallation: true, - (title, message, ok, cancel) => - { - showedDialog = true; - Assert.That(title, Does.Contain("Remove Old")); - Assert.That(message, Does.Contain("Node.js/npm")); - Assert.That(ok, Does.Contain("Remove")); - Assert.That(cancel, Is.EqualTo("Cancel")); - return true; - }); - - Assert.That(result, Is.True); - Assert.That(showedDialog, Is.True); - } - - [Test] - public void ConfirmInstallCanProceed_WhenUserCancelsReturnsFalse() - { - // Verifies that setup does not install when the user rejects legacy removal. - bool result = LegacyNpmRemovalPrompt.ConfirmInstallCanProceed( - hasLegacyNpmInstallation: true, - (title, message, ok, cancel) => false); - - Assert.That(result, Is.False); - } - } -} diff --git a/Assets/Tests/Editor/LegacyNpmRemovalPromptTests.cs.meta b/Assets/Tests/Editor/LegacyNpmRemovalPromptTests.cs.meta deleted file mode 100644 index 06d8a7352..000000000 --- a/Assets/Tests/Editor/LegacyNpmRemovalPromptTests.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: cbe65674e9dfad44daa21e2dd9e51472 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/Tests/Editor/McpEditorWindowCliActionTests.cs b/Assets/Tests/Editor/McpEditorWindowCliActionTests.cs index 518035ab7..7236478bd 100644 --- a/Assets/Tests/Editor/McpEditorWindowCliActionTests.cs +++ b/Assets/Tests/Editor/McpEditorWindowCliActionTests.cs @@ -4,19 +4,22 @@ namespace io.github.hatayama.UnityCliLoop.Tests.Editor { public class McpEditorWindowCliActionTests { - [TestCase(null, "3.0.0", false)] - [TestCase("2.9.0", "3.0.0", false)] - [TestCase("3.1.0", "3.0.0", false)] - [TestCase("3.0.0", "3.0.0", true)] + [TestCase(null, "3.0.0", true, false)] + [TestCase("2.9.0", "3.0.0", true, false)] + [TestCase("3.1.0", "3.0.0", true, false)] + [TestCase("3.0.0", "3.0.0", true, true)] + [TestCase("3.0.0", "3.0.0", false, false)] public void ShouldUninstallCliFromPrimaryButton_ReturnsExpectedAction( string cliVersion, string packageVersion, + bool canUninstallCli, bool expected) { - // Verifies that only same-version installs route the primary CLI button to uninstall. + // Verifies that only same-version package-owned installs route the primary CLI button to uninstall. bool result = McpEditorWindow.ShouldUninstallCliFromPrimaryButton( cliVersion, - packageVersion); + packageVersion, + canUninstallCli); Assert.That(result, Is.EqualTo(expected)); } diff --git a/Assets/Tests/Editor/NativeCliInstallerTests.cs b/Assets/Tests/Editor/NativeCliInstallerTests.cs index 3434ecdc4..aa5818652 100644 --- a/Assets/Tests/Editor/NativeCliInstallerTests.cs +++ b/Assets/Tests/Editor/NativeCliInstallerTests.cs @@ -42,22 +42,22 @@ public void GetInstallCommand_OnWindowsKeepsCliOnlyPowerShellInstallerAvailable( } [Test] - public void GetInstallCommand_OnMacCliOnlyInstallerCanOptIntoLegacyNpmRemoval() + public void GetInstallCommand_OnMacCliOnlyInstallerDoesNotAdvertiseWindowsLegacyCleanup() { - // Verifies that CLI-only macOS installs can opt into removing the legacy npm launcher. + // Verifies that macOS manual commands do not expose the Windows-only legacy cleanup flag. 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'")); + Assert.That(command.Arguments, Does.Not.Contain("ULOOP_REMOVE_LEGACY")); + Assert.That(command.ManualCommand, Does.Not.Contain("ULOOP_REMOVE_LEGACY")); } [Test] - public void GetInstallCommand_OnWindowsCliOnlyInstallerCanOptIntoLegacyNpmRemoval() + public void GetInstallCommand_OnWindowsCliOnlyInstallerCanOptIntoLegacyLauncherCleanup() { - // Verifies that CLI-only Windows installs can opt into removing the legacy npm launcher. + // Verifies that CLI-only Windows installs can opt into removing package-owned legacy launchers. NativeCliInstallCommand command = NativeCliInstaller.GetInstallCommand( RuntimePlatform.WindowsEditor, "3.0.0-beta.0", @@ -465,9 +465,9 @@ public void UninstallGlobalCli_OnWindowsDeletesCommandAndEmptyNativeInstallTree( } [Test] - public void CleanupLegacyCommandShimsInDirectory_WhenOrphanedNpmShimsExistDeletesThem() + public void CleanupLegacyCommandShimsInDirectory_WhenOrphanedTypeScriptLauncherShimsExistDeletesThem() { - // Verifies that orphaned npm launchers no longer remain earlier in PATH. + // Verifies that orphaned TypeScript launchers no longer remain earlier in PATH. string tempRoot = Path.Combine( Path.GetTempPath(), "uloop-native-installer-tests", @@ -616,9 +616,9 @@ public void CleanupLegacyCommandShimsInDirectory_WhenCommandIsNotPackageOwnedPre } [Test] - public void RemoveLegacyNpmBinDirectoryFromPathIfUnused_WhenOnlyNodeModulesRemainRemovesPath() + public void RemoveUnusedLegacyBinDirectoryFromPath_WhenOnlyNodeModulesRemainRemovesPath() { - // Verifies that the npm global bin directory is removed from PATH only after command shims are gone. + // Verifies that the legacy bin directory is removed from PATH only after command shims are gone. string tempRoot = Path.Combine( Path.GetTempPath(), "uloop-native-installer-tests", @@ -633,7 +633,7 @@ public void RemoveLegacyNpmBinDirectoryFromPathIfUnused_WhenOnlyNodeModulesRemai try { - CliInstallResult result = NativeCliInstaller.RemoveLegacyNpmBinDirectoryFromPathIfUnused( + CliInstallResult result = NativeCliInstaller.RemoveUnusedLegacyBinDirectoryFromPath( legacyBinDirectory, RuntimePlatform.WindowsEditor, (name, target) => userPath, @@ -655,9 +655,9 @@ public void RemoveLegacyNpmBinDirectoryFromPathIfUnused_WhenOnlyNodeModulesRemai } [Test] - public void RemoveLegacyNpmBinDirectoryFromPathIfUnused_WhenOtherCommandsRemainKeepsPath() + public void RemoveUnusedLegacyBinDirectoryFromPath_WhenOtherCommandsRemainKeepsPath() { - // Verifies that npm, npx, and other global command shims keep the npm bin directory in PATH. + // Verifies that other global command shims keep the legacy bin directory in PATH. string tempRoot = Path.Combine( Path.GetTempPath(), "uloop-native-installer-tests", @@ -671,7 +671,7 @@ public void RemoveLegacyNpmBinDirectoryFromPathIfUnused_WhenOtherCommandsRemainK try { - CliInstallResult result = NativeCliInstaller.RemoveLegacyNpmBinDirectoryFromPathIfUnused( + CliInstallResult result = NativeCliInstaller.RemoveUnusedLegacyBinDirectoryFromPath( legacyBinDirectory, RuntimePlatform.WindowsEditor, (name, target) => legacyBinDirectory, @@ -692,251 +692,6 @@ public void RemoveLegacyNpmBinDirectoryFromPathIfUnused_WhenOtherCommandsRemainK } } - [Test] - public void HasLegacyNpmInstallation_WhenNpmPackageExistsReturnsTrue() - { - // Verifies that the installer can prompt before removing an installed npm package. - bool result = NativeCliInstaller.HasLegacyNpmInstallation( - RuntimePlatform.WindowsEditor, - (command, platform) => new CliInstallResult(true, ""), - applicationData: null); - - Assert.That(result, Is.True); - } - - [Test] - public void HasLegacyNpmInstallation_WhenOrphanedWindowsShimExistsReturnsTrue() - { - // Verifies that stale npm launchers are still detected when npm itself cannot report the package. - string tempRoot = Path.Combine( - Path.GetTempPath(), - "uloop-native-installer-tests", - System.Guid.NewGuid().ToString("N")); - string legacyBinDirectory = Path.Combine(tempRoot, "npm"); - - Directory.CreateDirectory(legacyBinDirectory); - File.WriteAllText( - Path.Combine(legacyBinDirectory, "uloop.ps1"), - "node_modules/uloop-cli/dist/cli.bundle.cjs"); - - try - { - bool result = NativeCliInstaller.HasLegacyNpmInstallation( - RuntimePlatform.WindowsEditor, - (command, platform) => new CliInstallResult(false, ""), - tempRoot); - - Assert.That(result, Is.True); - } - finally - { - if (Directory.Exists(tempRoot)) - { - Directory.Delete(tempRoot, true); - } - } - } - - [Test] - public void HasLegacyNpmInstallation_WhenForwardingWindowsShimExistsReturnsTrue() - { - // Verifies that setup can prompt again for forwarders created by earlier native installer versions. - string tempRoot = Path.Combine( - Path.GetTempPath(), - "uloop-native-installer-tests", - System.Guid.NewGuid().ToString("N")); - string legacyBinDirectory = Path.Combine(tempRoot, "npm"); - string nativeUloopPath = Path.Combine(tempRoot, "native", "uloop.exe"); - - Directory.CreateDirectory(legacyBinDirectory); - File.WriteAllText( - Path.Combine(legacyBinDirectory, "uloop.ps1"), - $"& '{nativeUloopPath}' @args"); - - try - { - bool result = NativeCliInstaller.HasLegacyNpmInstallation( - RuntimePlatform.WindowsEditor, - (command, platform) => new CliInstallResult(false, ""), - tempRoot, - nativeUloopPath); - - Assert.That(result, Is.True); - } - finally - { - if (Directory.Exists(tempRoot)) - { - Directory.Delete(tempRoot, true); - } - } - } - - [Test] - public void HasLegacyNpmInstallation_WhenForwardingShimTargetsBundledDispatcherReturnsTrue() - { - // Verifies that package-cache dispatcher forwarders are detected as stale package-owned shims. - string tempRoot = Path.Combine( - Path.GetTempPath(), - "uloop-native-installer-tests", - System.Guid.NewGuid().ToString("N")); - string legacyBinDirectory = Path.Combine(tempRoot, "npm"); - string bundledDispatcherPath = Path.Combine( - tempRoot, - "Library", - "PackageCache", - CliConstants.GO_CLI_PACKAGE_DIR_NAME, - CliConstants.DIST_DIR_NAME, - "windows-amd64", - CliConstants.GLOBAL_DISPATCHER_WINDOWS_BUNDLE_NAME); - - Directory.CreateDirectory(legacyBinDirectory); - File.WriteAllText( - Path.Combine(legacyBinDirectory, "uloop.ps1"), - $"& '{bundledDispatcherPath}' @args"); - - try - { - bool result = NativeCliInstaller.HasLegacyNpmInstallation( - RuntimePlatform.WindowsEditor, - (command, platform) => new CliInstallResult(false, ""), - tempRoot); - - Assert.That(result, Is.True); - } - finally - { - if (Directory.Exists(tempRoot)) - { - Directory.Delete(tempRoot, true); - } - } - } - - [Test] - public void HasLegacyNpmInstallation_WhenNoPackageOrShimReturnsFalse() - { - // Verifies that clean systems can install without a legacy removal prompt. - string tempRoot = Path.Combine( - Path.GetTempPath(), - "uloop-native-installer-tests", - System.Guid.NewGuid().ToString("N")); - - Directory.CreateDirectory(Path.Combine(tempRoot, "npm")); - - try - { - bool result = NativeCliInstaller.HasLegacyNpmInstallation( - RuntimePlatform.WindowsEditor, - (command, platform) => new CliInstallResult(false, ""), - tempRoot); - - Assert.That(result, Is.False); - } - finally - { - if (Directory.Exists(tempRoot)) - { - Directory.Delete(tempRoot, true); - } - } - } - - [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 install still updates PATH and removes stale shims before reporting npm cleanup failure. - bool appliedCurrentPath = false; - bool cleanedLegacyShims = 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) => - { - cleanedLegacyShims = true; - return new CliInstallResult(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(cleanedLegacyShims, Is.True); - Assert.That(persistedUserPath, Is.True); - } - [Test] public void FinishSuccessfulBundleInstall_WhenPathPersistenceFailsReturnsPathFailure() { @@ -947,7 +702,6 @@ public void FinishSuccessfulBundleInstall_WhenPathPersistenceFailsReturnsPathFai new CliInstallResult(true, ""), "C:\\Users\\masamichi\\Programs\\uloop\\bin", RuntimePlatform.WindowsEditor, - platform => new CliInstallResult(false, "legacy failed"), platform => { appliedCurrentPath = true; }, (installDirectory, platform) => new CliInstallResult(true, ""), (installDirectory, platform) => new CliInstallResult(false, "path failed")); @@ -1015,6 +769,30 @@ public void IsDefaultInstallDirectoryForCurrentUser_WhenWindowsSharedDirectoryRe Assert.That(result, Is.False); } + [Test] + public void IsPackageOwnedInstallPath_WhenExecutableMatchesInstallDirectoryReturnsTrue() + { + // Verifies that uninstall is available only for the package-owned command path. + bool result = NativeCliInstaller.IsPackageOwnedInstallPath( + "C:/Users/masamichi/AppData/Local/Programs/uloop/bin/uloop.exe", + "C:\\Users\\masamichi\\AppData\\Local\\Programs\\uloop\\bin", + RuntimePlatform.WindowsEditor); + + Assert.That(result, Is.True); + } + + [Test] + public void IsPackageOwnedInstallPath_WhenExecutableIsSharedCommandReturnsFalse() + { + // Verifies that same-version shared commands do not route the settings button to uninstall. + bool result = NativeCliInstaller.IsPackageOwnedInstallPath( + "C:\\Tools\\uloop.exe", + "C:\\Users\\masamichi\\AppData\\Local\\Programs\\uloop\\bin", + RuntimePlatform.WindowsEditor); + + Assert.That(result, Is.False); + } + [Test] public void ShouldRemoveInstallDirectoryFromPath_WhenWindowsDefaultDirectoryReturnsTrue() { diff --git a/Assets/Tests/Editor/SkillsTargetSelectionResolverTests.cs b/Assets/Tests/Editor/SkillsTargetSelectionResolverTests.cs index 13c1d36a6..607752d71 100644 --- a/Assets/Tests/Editor/SkillsTargetSelectionResolverTests.cs +++ b/Assets/Tests/Editor/SkillsTargetSelectionResolverTests.cs @@ -42,6 +42,7 @@ public void IsInstalled_ReturnsExpectedStateForTarget( packageVersion: "1.7.3", needsUpdate: false, needsDowngrade: false, + canUninstallCli: true, isInstallingCli: false, isChecking: false, isClaudeSkillsInstalled: true, diff --git a/Packages/src/Editor/CLI/CliConstants.cs b/Packages/src/Editor/CLI/CliConstants.cs index 2a522f321..4821fb683 100644 --- a/Packages/src/Editor/CLI/CliConstants.cs +++ b/Packages/src/Editor/CLI/CliConstants.cs @@ -4,7 +4,6 @@ public static class CliConstants { public const string EXECUTABLE_NAME = "uloop"; public const string VERSION_FLAG = "--version"; - public const int GLOBAL_INSTALL_TIMEOUT_MS = 30000; 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"; @@ -19,7 +18,7 @@ public static class CliConstants 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 WINDOWS_NPM_BIN_DIR_NAME = "npm"; + public const string WINDOWS_NODE_GLOBAL_BIN_DIR_NAME = "npm"; public const string NATIVE_INSTALL_DIR_NAME = "uloop"; public const string NATIVE_INSTALL_BIN_DIR_NAME = "bin"; public const string POSIX_PATH_SEPARATOR = ":"; @@ -35,7 +34,7 @@ public static class CliConstants public const string WINDOWS_POWERSHELL_SHIM_NAME = EXECUTABLE_NAME + ".ps1"; 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 LEGACY_TYPESCRIPT_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/CliInstallationDetector.cs b/Packages/src/Editor/CLI/CliInstallationDetector.cs index 421879e85..cb68cff10 100644 --- a/Packages/src/Editor/CLI/CliInstallationDetector.cs +++ b/Packages/src/Editor/CLI/CliInstallationDetector.cs @@ -8,11 +8,24 @@ namespace io.github.hatayama.UnityCliLoop { + internal readonly struct CliInstallationDetection + { + public CliInstallationDetection(string version, string executablePath) + { + Version = version; + ExecutablePath = executablePath; + } + + public string Version { get; } + public string ExecutablePath { get; } + } + public static class CliInstallationDetector { private const int PROCESS_TIMEOUT_MS = 5000; private static string _cachedCliVersion; + private static string _cachedCliExecutablePath; private static bool _cacheInitialized; private static bool _isRefreshing; @@ -26,6 +39,11 @@ public static string GetCachedCliVersion() return _cacheInitialized ? _cachedCliVersion : null; } + public static string GetCachedCliExecutablePath() + { + return _cacheInitialized ? _cachedCliExecutablePath : null; + } + public static bool IsCheckCompleted() { return _cacheInitialized; @@ -41,8 +59,9 @@ public static async Task RefreshCliVersionAsync(CancellationToken ct) _isRefreshing = true; try { - string version = await DetectCliVersionAsync(ct); - _cachedCliVersion = version; + CliInstallationDetection detection = await DetectCliInstallationAsync(ct); + _cachedCliVersion = detection.Version; + _cachedCliExecutablePath = detection.ExecutablePath; _cacheInitialized = true; } finally @@ -90,25 +109,32 @@ internal static bool AreSkillsInstalled( public static async Task ForceRefreshCliVersionAsync(CancellationToken ct) { - string version = await DetectCliVersionAsync(ct); - _cachedCliVersion = version; + CliInstallationDetection detection = await DetectCliInstallationAsync(ct); + _cachedCliVersion = detection.Version; + _cachedCliExecutablePath = detection.ExecutablePath; _cacheInitialized = true; } public static void InvalidateCache() { _cachedCliVersion = null; + _cachedCliExecutablePath = null; _cacheInitialized = false; _isRefreshing = false; } - private static Task DetectCliVersionAsync(CancellationToken ct) + private static Task DetectCliInstallationAsync(CancellationToken ct) { RuntimePlatform platform = Application.platform; - return Task.Run(() => DetectCliVersionBlocking(platform, ct), ct); + return Task.Run(() => DetectCliInstallationBlocking(platform, ct), ct); } internal static string DetectCliVersionBlocking(RuntimePlatform platform, CancellationToken ct) + { + return DetectCliInstallationBlocking(platform, ct).Version; + } + + internal static CliInstallationDetection DetectCliInstallationBlocking(RuntimePlatform platform, CancellationToken ct) { string executablePath = NodeEnvironmentResolver.FindExecutablePathAtPlatform( CliConstants.EXECUTABLE_NAME, @@ -129,7 +155,7 @@ internal static string DetectCliVersionBlocking(RuntimePlatform platform, Cancel Process process = ProcessStartHelper.TryStart(startInfo); if (process == null) { - return null; + return new CliInstallationDetection(null, executablePath); } StringBuilder outputBuilder = new StringBuilder(); @@ -159,7 +185,7 @@ internal static string DetectCliVersionBlocking(RuntimePlatform platform, Cancel { try { process.Kill(); } catch (System.InvalidOperationException) { } process.Dispose(); - return null; + return new CliInstallationDetection(null, executablePath); } // Parameterless WaitForExit flushes async output buffers @@ -169,7 +195,8 @@ internal static string DetectCliVersionBlocking(RuntimePlatform platform, Cancel bool failed = process.ExitCode != 0 || string.IsNullOrEmpty(output); process.Dispose(); - return failed ? null : output; + string version = failed ? null : output; + return new CliInstallationDetection(version, executablePath); } catch (Exception ex) { @@ -178,7 +205,7 @@ internal static string DetectCliVersionBlocking(RuntimePlatform platform, Cancel { UnityEngine.Debug.LogWarning($"[UnityCliLoop] Failed to detect CLI version: {ex.Message}"); } - return null; + return new CliInstallationDetection(null, executablePath); } } } diff --git a/Packages/src/Editor/CLI/NativeCliInstaller.cs b/Packages/src/Editor/CLI/NativeCliInstaller.cs index a8193e2f7..1b33f95b3 100644 --- a/Packages/src/Editor/CLI/NativeCliInstaller.cs +++ b/Packages/src/Editor/CLI/NativeCliInstaller.cs @@ -37,7 +37,7 @@ public static class NativeCliInstaller public static NativeCliInstallCommand GetInstallCommand( RuntimePlatform platform, string packageVersion, - bool removeLegacyNpm) + bool removeLegacyLaunchers) { UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(packageVersion), "packageVersion must not be null or empty"); @@ -47,7 +47,7 @@ public static NativeCliInstallCommand GetInstallCommand( string scriptUrl = BuildReleaseAssetUrl(releaseTag, CliConstants.WINDOWS_INSTALL_SCRIPT_NAME); string command = $"$env:{CliConstants.INSTALL_VERSION_ENVIRONMENT_VARIABLE}='{releaseTag}'; " + - BuildWindowsRemoveLegacyAssignment(removeLegacyNpm) + + BuildWindowsRemoveLegacyAssignment(removeLegacyLaunchers) + $"irm '{scriptUrl}' | iex"; return new NativeCliInstallCommand( "powershell", @@ -58,7 +58,6 @@ public static NativeCliInstallCommand GetInstallCommand( 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", @@ -97,7 +96,6 @@ public static async Task InstallAsync( result, installDirectory, platform, - currentPlatform => RemoveLegacyNpmPackageIfPresent(currentPlatform, RunInstallCommand), ApplyInstallDirectoryToCurrentProcessPath, CleanupLegacyCommandShims, (currentInstallDirectory, currentPlatform) => PersistInstallDirectoryToUserPath( @@ -326,142 +324,20 @@ internal static CliInstallResult RemoveInstallDirectoryFromUserPath( } } - 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); - } - - public static bool HasLegacyNpmInstallation(RuntimePlatform platform) - { - string applicationData = Environment.GetEnvironmentVariable(CliConstants.WINDOWS_APPDATA_ENVIRONMENT_VARIABLE); - string installDirectory = GetInstallDirectoryForCurrentUser(platform); - string nativeUloopPath = string.IsNullOrWhiteSpace(installDirectory) - ? null - : GetGlobalCliInstallPath(installDirectory, platform); - return HasLegacyNpmInstallation(platform, RunInstallCommand, applicationData, nativeUloopPath); - } - - internal static bool HasLegacyNpmInstallation( - RuntimePlatform platform, - Func runCommand, - string applicationData) - { - return HasLegacyNpmInstallation(platform, runCommand, applicationData, nativeUloopPath: null); - } - - internal static bool HasLegacyNpmInstallation( - RuntimePlatform platform, - Func runCommand, - string applicationData, - string nativeUloopPath) - { - UnityEngine.Debug.Assert(runCommand != null, "runCommand must not be null"); - - if (HasLegacyNpmPackage(platform, runCommand)) - { - return true; - } - - return HasLegacyCommandShims(platform, applicationData, nativeUloopPath); - } - - internal static bool HasLegacyNpmPackage( - RuntimePlatform platform, - Func runCommand) - { - UnityEngine.Debug.Assert(runCommand != null, "runCommand must not be null"); - - NativeCliInstallCommand detectCommand = GetLegacyNpmListCommand(platform); - CliInstallResult detectResult = runCommand(detectCommand, platform); - return detectResult.Success; - } - - internal static bool HasLegacyCommandShims( - RuntimePlatform platform, - string applicationData, - string nativeUloopPath) - { - if (platform != RuntimePlatform.WindowsEditor || string.IsNullOrWhiteSpace(applicationData)) - { - return false; - } - - string legacyBinDirectory = Path.Combine(applicationData, CliConstants.WINDOWS_NPM_BIN_DIR_NAME); - return HasLegacyCommandShimsInDirectory(legacyBinDirectory, nativeUloopPath); - } - - internal static bool HasLegacyCommandShimsInDirectory( - string legacyBinDirectory, - string nativeUloopPath) - { - UnityEngine.Debug.Assert(!string.IsNullOrEmpty(legacyBinDirectory), "legacyBinDirectory must not be null or empty"); - - if (!Directory.Exists(legacyBinDirectory)) - { - return false; - } - - string[] shimPaths = - { - Path.Combine(legacyBinDirectory, CliConstants.EXECUTABLE_NAME), - Path.Combine(legacyBinDirectory, CliConstants.WINDOWS_CMD_SHIM_NAME), - Path.Combine(legacyBinDirectory, CliConstants.WINDOWS_POWERSHELL_SHIM_NAME) - }; - - foreach (string shimPath in shimPaths) - { - if (IsPackageOwnedCommandShim(shimPath, nativeUloopPath)) - { - return true; - } - } - - return false; - } - internal static CliInstallResult FinishSuccessfulBundleInstall( CliInstallResult installResult, string installDirectory, RuntimePlatform platform, - Func removeLegacyNpmPackage, Action applyInstallDirectoryToCurrentProcessPath, Func cleanupLegacyCommandShims, 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(cleanupLegacyCommandShims != null, "cleanupLegacyCommandShims 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 cleanupResult = cleanupLegacyCommandShims(installDirectory, platform); CliInstallResult persistResult = persistInstallDirectoryToUserPath(installDirectory, platform); @@ -475,7 +351,7 @@ internal static CliInstallResult FinishSuccessfulBundleInstall( return cleanupResult; } - return result; + return installResult; } internal static CliInstallResult CleanupLegacyCommandShims(string installDirectory, RuntimePlatform platform) @@ -493,7 +369,7 @@ internal static CliInstallResult CleanupLegacyCommandShims(string installDirecto return new CliInstallResult(true, ""); } - string legacyBinDirectory = Path.Combine(applicationData, CliConstants.WINDOWS_NPM_BIN_DIR_NAME); + string legacyBinDirectory = Path.Combine(applicationData, CliConstants.WINDOWS_NODE_GLOBAL_BIN_DIR_NAME); string nativeUloopPath = GetGlobalCliInstallPath(installDirectory, platform); CliInstallResult cleanupResult = CleanupLegacyCommandShimsInDirectory(legacyBinDirectory, nativeUloopPath); if (!cleanupResult.Success) @@ -501,7 +377,7 @@ internal static CliInstallResult CleanupLegacyCommandShims(string installDirecto return cleanupResult; } - return RemoveLegacyNpmBinDirectoryFromPathIfUnused( + return RemoveUnusedLegacyBinDirectoryFromPath( legacyBinDirectory, platform, Environment.GetEnvironmentVariable, @@ -560,7 +436,7 @@ internal static CliInstallResult CleanupLegacyCommandShimsInDirectory( return new CliInstallResult(true, ""); } - internal static CliInstallResult RemoveLegacyNpmBinDirectoryFromPathIfUnused( + internal static CliInstallResult RemoveUnusedLegacyBinDirectoryFromPath( string legacyBinDirectory, RuntimePlatform platform, Func getUserEnvironmentVariable, @@ -581,7 +457,7 @@ internal static CliInstallResult RemoveLegacyNpmBinDirectoryFromPathIfUnused( try { - if (HasNpmBinCommandEntries(legacyBinDirectory)) + if (HasCommandEntriesBesidesNodeModules(legacyBinDirectory)) { return new CliInstallResult(true, ""); } @@ -605,23 +481,23 @@ internal static CliInstallResult RemoveLegacyNpmBinDirectoryFromPathIfUnused( } catch (IOException ex) { - return BuildLegacyNpmPathCleanupFailure(ex); + return BuildUnusedLegacyBinPathCleanupFailure(ex); } catch (UnauthorizedAccessException ex) { - return BuildLegacyNpmPathCleanupFailure(ex); + return BuildUnusedLegacyBinPathCleanupFailure(ex); } catch (SecurityException ex) { - return BuildLegacyNpmPathCleanupFailure(ex); + return BuildUnusedLegacyBinPathCleanupFailure(ex); } catch (ArgumentException ex) { - return BuildLegacyNpmPathCleanupFailure(ex); + return BuildUnusedLegacyBinPathCleanupFailure(ex); } catch (NotSupportedException ex) { - return BuildLegacyNpmPathCleanupFailure(ex); + return BuildUnusedLegacyBinPathCleanupFailure(ex); } } @@ -824,6 +700,45 @@ internal static bool ShouldRemoveInstallDirectoryFromPath( localAppData); } + internal static bool IsPackageOwnedCurrentUserInstallPath( + string executablePath, + RuntimePlatform platform) + { + if (string.IsNullOrWhiteSpace(executablePath)) + { + return false; + } + + string installDirectory = GetInstallDirectoryForCurrentUser(platform); + if (string.IsNullOrWhiteSpace(installDirectory)) + { + return false; + } + + return IsPackageOwnedInstallPath(executablePath, installDirectory, platform); + } + + internal static bool IsPackageOwnedInstallPath( + string executablePath, + string installDirectory, + RuntimePlatform platform) + { + UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(installDirectory), "installDirectory must not be null or empty"); + + if (string.IsNullOrWhiteSpace(executablePath)) + { + return false; + } + + string expectedPath = GetGlobalCliInstallPath(installDirectory, platform); + string normalizedExecutablePath = NormalizePathForComparison(executablePath, platform); + string normalizedExpectedPath = NormalizePathForComparison(expectedPath, platform); + return string.Equals( + normalizedExecutablePath, + normalizedExpectedPath, + GetPathComparison(platform)); + } + private static string GetInstallDirectoryForCurrentUser(RuntimePlatform platform) { string configuredInstallDirectory = Environment.GetEnvironmentVariable(CliConstants.INSTALL_DIR_ENVIRONMENT_VARIABLE); @@ -863,6 +778,19 @@ private static StringComparison GetPathComparison(RuntimePlatform platform) : StringComparison.Ordinal; } + private static string NormalizePathForComparison(string path, RuntimePlatform platform) + { + UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(path), "path must not be null or empty"); + + string normalizedPath = path.Trim().Trim('"'); + if (platform != RuntimePlatform.WindowsEditor) + { + return normalizedPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + } + + return normalizedPath.TrimEnd('\\', '/').Replace('/', '\\'); + } + private static void ReplaceInstalledCliFromStaged(string stagedInstallPath, string installPath) { UnityEngine.Debug.Assert(!string.IsNullOrEmpty(stagedInstallPath), "stagedInstallPath must not be null or empty"); @@ -902,19 +830,19 @@ private static CliInstallResult BuildLegacyShimCleanupFailure(Exception ex) UnityEngine.Debug.Assert(ex != null, "ex must not be null"); string errorOutput = - "Installed the uLoop CLI binary, but failed to remove a legacy npm uloop launcher. " - + "Remove the stale launcher manually from the npm global bin directory.\n" + "Installed the uLoop CLI binary, but failed to remove a package-owned legacy uloop launcher. " + + "Remove the stale launcher manually from the Windows Node.js global bin directory.\n" + ex.Message; return new CliInstallResult(false, errorOutput); } - private static CliInstallResult BuildLegacyNpmPathCleanupFailure(Exception ex) + private static CliInstallResult BuildUnusedLegacyBinPathCleanupFailure(Exception ex) { UnityEngine.Debug.Assert(ex != null, "ex must not be null"); string errorOutput = - "Installed the uLoop CLI binary, but failed to remove an unused npm global bin directory from the Windows User PATH. " - + "Remove the unused npm global bin directory manually if it no longer contains command shims.\n" + "Installed the uLoop CLI binary, but failed to remove an unused legacy command bin directory from the Windows User PATH. " + + "Remove the unused legacy command bin directory manually if it no longer contains command shims.\n" + ex.Message; return new CliInstallResult(false, errorOutput); } @@ -1019,28 +947,15 @@ private static void DeletePackageOwnedCommandShim(string shimPath, string native File.Delete(shimPath); } - private static bool IsPackageOwnedCommandShim(string shimPath, string nativeUloopPath) - { - UnityEngine.Debug.Assert(!string.IsNullOrEmpty(shimPath), "shimPath must not be null or empty"); - - if (!File.Exists(shimPath)) - { - return false; - } - - string content = ReadCommandShimContentOrNull(shimPath); - return IsPackageOwnedCommandShimContent(content, nativeUloopPath); - } - - internal static bool IsLegacyNpmShimContent(string content) + internal static bool IsLegacyTypeScriptPackageShimContent(string content) { if (string.IsNullOrEmpty(content)) { return false; } - string unixPackagePath = $"node_modules/{CliConstants.LEGACY_NPM_PACKAGE_NAME}"; - string windowsPackagePath = $"node_modules\\{CliConstants.LEGACY_NPM_PACKAGE_NAME}"; + string unixPackagePath = $"node_modules/{CliConstants.LEGACY_TYPESCRIPT_PACKAGE_NAME}"; + string windowsPackagePath = $"node_modules\\{CliConstants.LEGACY_TYPESCRIPT_PACKAGE_NAME}"; return content.IndexOf(unixPackagePath, StringComparison.OrdinalIgnoreCase) >= 0 || content.IndexOf(windowsPackagePath, StringComparison.OrdinalIgnoreCase) >= 0; } @@ -1059,38 +974,10 @@ internal static bool IsNativeForwardingShimContent(string content, string native private static bool IsPackageOwnedCommandShimContent(string content, string nativeUloopPath) { - return IsLegacyNpmShimContent(content) + return IsLegacyTypeScriptPackageShimContent(content) || IsNativeForwardingShimContent(content, nativeUloopPath); } - private static string ReadCommandShimContentOrNull(string shimPath) - { - try - { - return File.ReadAllText(shimPath); - } - catch (IOException) - { - return null; - } - catch (UnauthorizedAccessException) - { - return null; - } - catch (SecurityException) - { - return null; - } - catch (ArgumentException) - { - return null; - } - catch (NotSupportedException) - { - return null; - } - } - private static bool ContainsDefaultWindowsInstallCommandReference(string content) { string commandPath = Path.Combine( @@ -1132,7 +1019,7 @@ private static bool ContainsIgnoreCase(string content, string value) return content.IndexOf(value, StringComparison.OrdinalIgnoreCase) >= 0; } - private static bool HasNpmBinCommandEntries(string legacyBinDirectory) + private static bool HasCommandEntriesBesidesNodeModules(string legacyBinDirectory) { UnityEngine.Debug.Assert(!string.IsNullOrEmpty(legacyBinDirectory), "legacyBinDirectory must not be null or empty"); @@ -1223,109 +1110,9 @@ private static string QuoteProcessArgument(string value) 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) + private static string BuildWindowsRemoveLegacyAssignment(bool removeLegacyLaunchers) { - if (!removeLegacyNpm) + if (!removeLegacyLaunchers) { return ""; } @@ -1333,16 +1120,6 @@ private static string BuildWindowsRemoveLegacyAssignment(bool removeLegacyNpm) 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/LegacyNpmRemovalPrompt.cs b/Packages/src/Editor/UI/LegacyNpmRemovalPrompt.cs deleted file mode 100644 index 29ee4a5b6..000000000 --- a/Packages/src/Editor/UI/LegacyNpmRemovalPrompt.cs +++ /dev/null @@ -1,42 +0,0 @@ -using System; -using UnityEditor; -using UnityEngine; - -namespace io.github.hatayama.UnityCliLoop -{ - internal static class LegacyNpmRemovalPrompt - { - private const string DialogTitle = "Remove Old uLoop CLI?"; - private const string DialogMessage = - "An older Node.js/npm uLoop CLI installation was found. It can shadow the native Go CLI and make the uloop command fail.\n\n" - + "Remove the old npm CLI before installing the native CLI?"; - private const string ConfirmButtonText = "Remove Old CLI and Install"; - private const string CancelButtonText = "Cancel"; - - public static bool ConfirmInstallCanProceed(RuntimePlatform platform) - { - bool hasLegacyNpmInstallation = NativeCliInstaller.HasLegacyNpmInstallation(platform); - return ConfirmInstallCanProceed( - hasLegacyNpmInstallation, - (title, message, ok, cancel) => EditorUtility.DisplayDialog(title, message, ok, cancel)); - } - - internal static bool ConfirmInstallCanProceed( - bool hasLegacyNpmInstallation, - Func displayDialog) - { - Debug.Assert(displayDialog != null, "displayDialog must not be null"); - - if (!hasLegacyNpmInstallation) - { - return true; - } - - return displayDialog( - DialogTitle, - DialogMessage, - ConfirmButtonText, - CancelButtonText); - } - } -} diff --git a/Packages/src/Editor/UI/LegacyNpmRemovalPrompt.cs.meta b/Packages/src/Editor/UI/LegacyNpmRemovalPrompt.cs.meta deleted file mode 100644 index c25ba436a..000000000 --- a/Packages/src/Editor/UI/LegacyNpmRemovalPrompt.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 0d5fec7469a3b16488d06c700fc94bb3 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Packages/src/Editor/UI/McpEditorWindow.cs b/Packages/src/Editor/UI/McpEditorWindow.cs index 7231a073c..b677ae9a4 100644 --- a/Packages/src/Editor/UI/McpEditorWindow.cs +++ b/Packages/src/Editor/UI/McpEditorWindow.cs @@ -576,6 +576,7 @@ private void RefreshCliSetupSection(bool includeSkillDirectoryChecks = true) private CliSetupData CreateCliSetupData(bool includeSkillDirectoryChecks = true) { string cliVersion = CliInstallationDetector.GetCachedCliVersion(); + string cliExecutablePath = CliInstallationDetector.GetCachedCliExecutablePath(); string packageVersion = McpConstants.PackageInfo.version; string projectRoot = UnityMcpPathResolver.GetProjectRoot(); CliInstallResult projectLocalResult = ProjectLocalCliAutoInstaller.EnsureProjectLocalCliCurrent( @@ -588,6 +589,9 @@ private CliSetupData CreateCliSetupData(bool includeSkillDirectoryChecks = true) } bool isCliInstalled = cliVersion != null; + bool canUninstallCli = NativeCliInstaller.IsPackageOwnedCurrentUserInstallPath( + cliExecutablePath, + Application.platform); bool isChecking = !CliInstallationDetector.IsCheckCompleted() || _isRefreshingVersion || !includeSkillDirectoryChecks; @@ -604,6 +608,7 @@ private CliSetupData CreateCliSetupData(bool includeSkillDirectoryChecks = true) packageVersion, needsUpdate, needsDowngrade, + canUninstallCli, _isInstallingCli, isChecking, isClaudeSkillsInstalled: false, @@ -702,11 +707,6 @@ private async void HandleInstallCli() return; } - if (!LegacyNpmRemovalPrompt.ConfirmInstallCanProceed(Application.platform)) - { - return; - } - bool wasCliInstalledBeforeInstall = CliInstallationDetector.IsCliInstalled(); _isInstallingCli = true; RefreshCliSetupSection(); @@ -742,19 +742,25 @@ private async void HandleInstallCli() private bool ShouldUninstallCliFromPrimaryButton() { string cliVersion = CliInstallationDetector.GetCachedCliVersion(); + string cliExecutablePath = CliInstallationDetector.GetCachedCliExecutablePath(); + bool canUninstallCli = NativeCliInstaller.IsPackageOwnedCurrentUserInstallPath( + cliExecutablePath, + Application.platform); return ShouldUninstallCliFromPrimaryButton( cliVersion, - McpConstants.PackageInfo.version); + McpConstants.PackageInfo.version, + canUninstallCli); } internal static bool ShouldUninstallCliFromPrimaryButton( string cliVersion, - string packageVersion) + string packageVersion, + bool canUninstallCli) { bool isCliInstalled = cliVersion != null; bool needsUpdate = IsCliUpdateNeeded(cliVersion, packageVersion); bool needsDowngrade = IsCliDowngradeNeeded(cliVersion, packageVersion); - return CliSetupSection.IsUninstallCliAction(isCliInstalled, needsUpdate, needsDowngrade); + return CliSetupSection.IsUninstallCliAction(isCliInstalled, needsUpdate, needsDowngrade, canUninstallCli); } internal static bool IsCliUpdateNeeded(string cliVersion, string packageVersion) diff --git a/Packages/src/Editor/UI/McpEditorWindowViewData.cs b/Packages/src/Editor/UI/McpEditorWindowViewData.cs index 822baa935..c33cadd76 100644 --- a/Packages/src/Editor/UI/McpEditorWindowViewData.cs +++ b/Packages/src/Editor/UI/McpEditorWindowViewData.cs @@ -99,6 +99,7 @@ public record CliSetupData public readonly string PackageVersion; public readonly bool NeedsUpdate; public readonly bool NeedsDowngrade; + public readonly bool CanUninstallCli; public readonly bool IsInstallingCli; public readonly bool IsChecking; public readonly bool IsClaudeSkillsInstalled; @@ -118,6 +119,7 @@ public CliSetupData( string packageVersion, bool needsUpdate, bool needsDowngrade, + bool canUninstallCli, bool isInstallingCli, bool isChecking, bool isClaudeSkillsInstalled, @@ -136,6 +138,7 @@ public CliSetupData( PackageVersion = packageVersion; NeedsUpdate = needsUpdate; NeedsDowngrade = needsDowngrade; + CanUninstallCli = canUninstallCli; IsInstallingCli = isInstallingCli; IsChecking = isChecking; IsClaudeSkillsInstalled = isClaudeSkillsInstalled; diff --git a/Packages/src/Editor/UI/Setup/SetupWizardWindow.cs b/Packages/src/Editor/UI/Setup/SetupWizardWindow.cs index 6156370e4..155f2fea0 100644 --- a/Packages/src/Editor/UI/Setup/SetupWizardWindow.cs +++ b/Packages/src/Editor/UI/Setup/SetupWizardWindow.cs @@ -852,11 +852,6 @@ internal static string GetSkillInstallStatusClass( private async void HandleInstallCli() { - if (!LegacyNpmRemovalPrompt.ConfirmInstallCanProceed(Application.platform)) - { - return; - } - bool wasCliInstalledBeforeInstall = CliInstallationDetector.IsCliInstalled(); _isInstallingCli = true; UpdateCliStep(false, null, false); diff --git a/Packages/src/Editor/UI/UIToolkit/Components/CliSetupSection.cs b/Packages/src/Editor/UI/UIToolkit/Components/CliSetupSection.cs index 8821871b4..2fc6ded47 100644 --- a/Packages/src/Editor/UI/UIToolkit/Components/CliSetupSection.cs +++ b/Packages/src/Editor/UI/UIToolkit/Components/CliSetupSection.cs @@ -111,6 +111,7 @@ private void UpdateInstallCliButton(CliSetupData data) data.IsChecking, data.NeedsUpdate, data.NeedsDowngrade, + data.CanUninstallCli, data.CliVersion, data.PackageVersion); bool enabled = IsInstallCliButtonEnabled( @@ -193,6 +194,7 @@ internal static string GetInstallCliButtonText( bool isChecking, bool needsUpdate, bool needsDowngrade, + bool canUninstallCli, string cliVersion, string packageVersion) { @@ -201,7 +203,7 @@ internal static string GetInstallCliButtonText( return "Checking..."; } - bool isUninstallAction = IsUninstallCliAction(isCliInstalled, needsUpdate, needsDowngrade); + bool isUninstallAction = IsUninstallCliAction(isCliInstalled, needsUpdate, needsDowngrade, canUninstallCli); if (isInstallingCli) { return isUninstallAction ? "Uninstalling..." : "Installing..."; @@ -222,7 +224,7 @@ internal static string GetInstallCliButtonText( return $"Downgrade CLI (v{cliVersion} \u2192 v{packageVersion})"; } - return "Uninstall CLI"; + return canUninstallCli ? "Uninstall CLI" : "Install CLI"; } internal static bool IsInstallCliButtonEnabled( @@ -235,9 +237,10 @@ internal static bool IsInstallCliButtonEnabled( internal static bool IsUninstallCliAction( bool isCliInstalled, bool needsUpdate, - bool needsDowngrade) + bool needsDowngrade, + bool canUninstallCli) { - return isCliInstalled && !needsUpdate && !needsDowngrade; + return canUninstallCli && isCliInstalled && !needsUpdate && !needsDowngrade; } internal static string GetInstallSkillsButtonText( diff --git a/Packages/src/GoCli~/.go-version b/Packages/src/GoCli~/.go-version new file mode 100644 index 000000000..dd43a143f --- /dev/null +++ b/Packages/src/GoCli~/.go-version @@ -0,0 +1 @@ +1.26.1 diff --git a/Packages/src/GoCli~/dist/darwin-amd64/uloop-core b/Packages/src/GoCli~/dist/darwin-amd64/uloop-core index 90b5c1232..1fa8ba10b 100755 Binary files a/Packages/src/GoCli~/dist/darwin-amd64/uloop-core and b/Packages/src/GoCli~/dist/darwin-amd64/uloop-core differ diff --git a/Packages/src/GoCli~/dist/darwin-amd64/uloop-dispatcher b/Packages/src/GoCli~/dist/darwin-amd64/uloop-dispatcher index 59eb74286..677a1447b 100755 Binary files a/Packages/src/GoCli~/dist/darwin-amd64/uloop-dispatcher and b/Packages/src/GoCli~/dist/darwin-amd64/uloop-dispatcher differ diff --git a/Packages/src/GoCli~/dist/darwin-arm64/uloop-core b/Packages/src/GoCli~/dist/darwin-arm64/uloop-core index a4f148972..17c388060 100755 Binary files a/Packages/src/GoCli~/dist/darwin-arm64/uloop-core and b/Packages/src/GoCli~/dist/darwin-arm64/uloop-core differ diff --git a/Packages/src/GoCli~/dist/darwin-arm64/uloop-dispatcher b/Packages/src/GoCli~/dist/darwin-arm64/uloop-dispatcher index 66b9c50ae..572c746dc 100755 Binary files a/Packages/src/GoCli~/dist/darwin-arm64/uloop-dispatcher and b/Packages/src/GoCli~/dist/darwin-arm64/uloop-dispatcher differ diff --git a/Packages/src/GoCli~/dist/windows-amd64/uloop-core.exe b/Packages/src/GoCli~/dist/windows-amd64/uloop-core.exe index 434477acb..0b9d37019 100755 Binary files a/Packages/src/GoCli~/dist/windows-amd64/uloop-core.exe and b/Packages/src/GoCli~/dist/windows-amd64/uloop-core.exe differ diff --git a/Packages/src/GoCli~/dist/windows-amd64/uloop-dispatcher.exe b/Packages/src/GoCli~/dist/windows-amd64/uloop-dispatcher.exe index e0bf40404..fb9d26659 100755 Binary files a/Packages/src/GoCli~/dist/windows-amd64/uloop-dispatcher.exe and b/Packages/src/GoCli~/dist/windows-amd64/uloop-dispatcher.exe differ diff --git a/Packages/src/GoCli~/internal/cli/completion.go b/Packages/src/GoCli~/internal/cli/completion.go index 21b715aa4..66cccbabe 100644 --- a/Packages/src/GoCli~/internal/cli/completion.go +++ b/Packages/src/GoCli~/internal/cli/completion.go @@ -275,7 +275,14 @@ func getHomeDirectoryForShell( return userHomeDirectory() } - return environmentHomeDirectory() + home, err := environmentHomeDirectory() + if err != nil { + return "", err + } + if goos == "windows" && isPosixShell(shellName) { + return normalizeWindowsPosixHomeDirectory(home), nil + } + return home, nil } func getPwshProfilePath(home string, goos string) string { @@ -285,6 +292,42 @@ func getPwshProfilePath(home string, goos string) string { return filepath.Join(home, ".config", "powershell", "Microsoft.PowerShell_profile.ps1") } +func isPosixShell(shellName string) bool { + return shellName == "bash" || shellName == "zsh" +} + +func normalizeWindowsPosixHomeDirectory(home string) string { + if home == "" { + return home + } + if len(home) >= 3 && home[0] == '/' && isASCIIAlpha(home[1]) && home[2] == '/' { + return windowsDrivePath(home[1], home[3:]) + } + if len(home) >= 7 && strings.HasPrefix(home, "/mnt/") && isASCIIAlpha(home[5]) && home[6] == '/' { + return windowsDrivePath(home[5], home[7:]) + } + return home +} + +func windowsDrivePath(driveLetter byte, rest string) string { + drive := string(toUpperASCIILetter(driveLetter)) + `:\` + if rest == "" { + return drive + } + return drive + strings.ReplaceAll(rest, "/", `\`) +} + +func isASCIIAlpha(value byte) bool { + return (value >= 'A' && value <= 'Z') || (value >= 'a' && value <= 'z') +} + +func toUpperASCIILetter(value byte) byte { + if value >= 'a' && value <= 'z' { + return value - ('a' - 'A') + } + return value +} + func installCompletionScript(configPath string, shellName string, script string) error { if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { return err diff --git a/Packages/src/GoCli~/internal/cli/completion_test.go b/Packages/src/GoCli~/internal/cli/completion_test.go index 7bc975002..3f8743974 100644 --- a/Packages/src/GoCli~/internal/cli/completion_test.go +++ b/Packages/src/GoCli~/internal/cli/completion_test.go @@ -239,8 +239,8 @@ func TestGetHomeDirectoryForShellOnWindowsPowerShellIgnoresHomeOverride(t *testi } } -func TestGetHomeDirectoryForShellOnWindowsBashUsesHomeOverride(t *testing.T) { - // Tests that Windows POSIX shells still honor HOME for Git Bash and MSYS configs. +func TestGetHomeDirectoryForShellOnWindowsBashNormalizesMsysHome(t *testing.T) { + // Tests that Windows POSIX shells convert Git Bash HOME to a Win32 path before file writes. environmentHomeCalls := 0 userHomeCalls := 0 @@ -260,7 +260,7 @@ func TestGetHomeDirectoryForShellOnWindowsBashUsesHomeOverride(t *testing.T) { t.Fatalf("getHomeDirectoryForShell failed: %v", err) } - if home != "/c/Users/masamichi" { + if home != `C:\Users\masamichi` { t.Fatalf("windows bash home mismatch: %s", home) } if environmentHomeCalls != 1 { @@ -270,3 +270,45 @@ func TestGetHomeDirectoryForShellOnWindowsBashUsesHomeOverride(t *testing.T) { t.Fatalf("user home should not be used for Windows bash") } } + +func TestGetHomeDirectoryForShellOnWindowsZshNormalizesWslHome(t *testing.T) { + // Tests that Windows POSIX shell HOME values from /mnt/c are safe for Win32 file APIs. + home, err := getHomeDirectoryForShell( + "zsh", + "windows", + func() (string, error) { + return "/mnt/c/Users/masamichi", nil + }, + func() (string, error) { + return `C:\Users\ignored`, nil + }, + ) + if err != nil { + t.Fatalf("getHomeDirectoryForShell failed: %v", err) + } + + if home != `C:\Users\masamichi` { + t.Fatalf("windows zsh home mismatch: %s", home) + } +} + +func TestGetHomeDirectoryForShellOnWindowsBashNormalizesWslDriveRoot(t *testing.T) { + // Tests that a WSL drive-root HOME is converted before Win32 file APIs receive it. + home, err := getHomeDirectoryForShell( + "bash", + "windows", + func() (string, error) { + return "/mnt/c/", nil + }, + func() (string, error) { + return `C:\Users\ignored`, nil + }, + ) + if err != nil { + t.Fatalf("getHomeDirectoryForShell failed: %v", err) + } + + if home != `C:\` { + t.Fatalf("windows bash drive-root home mismatch: %s", home) + } +} diff --git a/Packages/src/README.md b/Packages/src/README.md index 38a72e2b9..97eacd7cf 100644 --- a/Packages/src/README.md +++ b/Packages/src/README.md @@ -96,11 +96,7 @@ 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 -``` +On Windows, set `ULOOP_REMOVE_LEGACY=1` to remove package-owned legacy `uloop` launcher shims and an unused legacy command bin directory from User PATH: ```powershell $env:ULOOP_REMOVE_LEGACY = "1" diff --git a/Packages/src/README_ja.md b/Packages/src/README_ja.md index dc3e01ebb..1d5c8a370 100644 --- a/Packages/src/README_ja.md +++ b/Packages/src/README_ja.md @@ -97,11 +97,7 @@ 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 -``` +Windows では `ULOOP_REMOVE_LEGACY=1` を設定すると、package-owned な古い `uloop` launcher shim と、未使用の legacy command bin directory を User PATH から削除できます。 ```powershell $env:ULOOP_REMOVE_LEGACY = "1" diff --git a/README.md b/README.md index 38a72e2b9..97eacd7cf 100644 --- a/README.md +++ b/README.md @@ -96,11 +96,7 @@ 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 -``` +On Windows, set `ULOOP_REMOVE_LEGACY=1` to remove package-owned legacy `uloop` launcher shims and an unused legacy command bin directory from User PATH: ```powershell $env:ULOOP_REMOVE_LEGACY = "1" diff --git a/README_ja.md b/README_ja.md index dc3e01ebb..1d5c8a370 100644 --- a/README_ja.md +++ b/README_ja.md @@ -97,11 +97,7 @@ 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 -``` +Windows では `ULOOP_REMOVE_LEGACY=1` を設定すると、package-owned な古い `uloop` launcher shim と、未使用の legacy command bin directory を User PATH から削除できます。 ```powershell $env:ULOOP_REMOVE_LEGACY = "1" diff --git a/scripts/build-go-cli.sh b/scripts/build-go-cli.sh index d66c745f4..d88314335 100755 --- a/scripts/build-go-cli.sh +++ b/scripts/build-go-cli.sh @@ -5,6 +5,9 @@ ROOT_DIR=$(CDPATH= cd "$(dirname "$0")/.." && pwd) GO_CLI_DIR="$ROOT_DIR/Packages/src/GoCli~" DIST_DIR="$GO_CLI_DIR/dist" +. "$ROOT_DIR/scripts/go-cli-toolchain.sh" +require_go_cli_toolchain "$ROOT_DIR" + build_binary() { os="$1" arch="$2" diff --git a/scripts/check-go-cli.sh b/scripts/check-go-cli.sh index a30376c61..b8c58c766 100755 --- a/scripts/check-go-cli.sh +++ b/scripts/check-go-cli.sh @@ -4,6 +4,9 @@ set -eu ROOT_DIR=$(CDPATH= cd "$(dirname "$0")/.." && pwd) GO_CLI_DIR="$ROOT_DIR/Packages/src/GoCli~" +. "$ROOT_DIR/scripts/go-cli-toolchain.sh" +require_go_cli_toolchain "$ROOT_DIR" + if ! command -v golangci-lint >/dev/null 2>&1; then echo "golangci-lint is required. Install it before running Go CLI checks." >&2 echo "https://golangci-lint.run/welcome/install/" >&2 diff --git a/scripts/go-cli-toolchain.sh b/scripts/go-cli-toolchain.sh new file mode 100644 index 000000000..d7ac40822 --- /dev/null +++ b/scripts/go-cli-toolchain.sh @@ -0,0 +1,32 @@ +#!/bin/sh +set -eu + +require_go_cli_toolchain() { + root_dir="$1" + version_file="$root_dir/Packages/src/GoCli~/.go-version" + + if [ ! -f "$version_file" ]; then + echo "Go CLI toolchain version file is missing: $version_file" >&2 + exit 1 + fi + + required_go_version=$(sed -n '1{s/[[:space:]]//g;p;q;}' "$version_file") + if [ -z "$required_go_version" ]; then + echo "Go CLI toolchain version file is empty: $version_file" >&2 + exit 1 + fi + + if ! command -v go >/dev/null 2>&1; then + echo "Go $required_go_version is required to build the Go CLI dist files." >&2 + exit 1 + fi + + actual_go_version=$(go env GOVERSION) + if [ "$actual_go_version" = "go$required_go_version" ]; then + return + fi + + echo "Go $required_go_version is required to build the Go CLI dist files, but found $actual_go_version." >&2 + echo "Use the same Go version as CI before running scripts/check-go-cli.sh or scripts/build-go-cli.sh." >&2 + exit 1 +} diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 17a1eb4ee..052501aa2 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -1,7 +1,6 @@ $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 @@ -9,7 +8,6 @@ $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" @@ -27,77 +25,6 @@ function Test-RemoveLegacyEnabled { 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) { @@ -114,7 +41,7 @@ function Report-PathShadowing { Write-Host "Move $InstallDir earlier in PATH, or remove the legacy installation if it owns that command." } -function Test-LegacyUloopShimContent { +function Test-LegacyTypeScriptLauncherShimContent { param( [Parameter(Mandatory = $true)] [string]$Content @@ -153,7 +80,7 @@ function Test-PackageOwnedUloopShimContent { [string]$NativeUloopPath ) - return (Test-LegacyUloopShimContent -Content $Content) ` + return (Test-LegacyTypeScriptLauncherShimContent -Content $Content) ` -or (Test-NativeUloopShimContent -Content $Content -NativeUloopPath $NativeUloopPath) } @@ -224,7 +151,7 @@ function Remove-LegacyUloopShims { } } -function Test-NpmBinContainsCommandEntries { +function Test-LegacyBinContainsCommandEntries { param( [Parameter(Mandatory = $true)] [string]$LegacyBinDir @@ -245,13 +172,13 @@ function Test-NpmBinContainsCommandEntries { return $false } -function Remove-LegacyNpmBinPathIfUnused { +function Remove-UnusedLegacyBinPath { if (-not $env:APPDATA) { return } $LegacyBinDir = Join-Path $env:APPDATA "npm" - if (Test-NpmBinContainsCommandEntries -LegacyBinDir $LegacyBinDir) { + if (Test-LegacyBinContainsCommandEntries -LegacyBinDir $LegacyBinDir) { return } @@ -259,7 +186,7 @@ function Remove-LegacyNpmBinPathIfUnused { $UpdatedUserPath = Remove-PathEntry -PathValue $UserPath -EntryToRemove $LegacyBinDir if ($UserPath -and (-not [string]::Equals($UserPath, $UpdatedUserPath, [System.StringComparison]::OrdinalIgnoreCase))) { [Environment]::SetEnvironmentVariable("Path", $UpdatedUserPath, "User") - Write-Host "Removed unused npm global bin directory from User PATH: $LegacyBinDir" + Write-Host "Removed unused legacy command bin directory from User PATH: $LegacyBinDir" } $UpdatedProcessPath = Remove-PathEntry -PathValue $env:Path -EntryToRemove $LegacyBinDir @@ -308,7 +235,6 @@ try { $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 @@ -330,10 +256,8 @@ try { Assert-UloopVersionSucceeds -UloopPath $FinalUloopPath if (Test-RemoveLegacyEnabled) { Remove-LegacyUloopShims -NativeUloopPath $FinalUloopPath - Remove-LegacyNpmBinPathIfUnused + Remove-UnusedLegacyBinPath } - Confirm-ActiveUloopAfterLegacyCleanup - Write-LegacyNpmWarningIfPresent Report-PathShadowing } finally { diff --git a/scripts/install.sh b/scripts/install.sh index 78e6aebf2..631ced7f0 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -4,67 +4,6 @@ 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) @@ -146,7 +85,6 @@ 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 mv -f "$staged_uloop_path" "$INSTALL_DIR/uloop" staged_uloop_path="" @@ -160,6 +98,4 @@ case ":$PATH:" in esac "$INSTALL_DIR/uloop" --version -ensure_active_uloop_after_legacy_cleanup -report_legacy_npm_if_present report_path_shadowing