diff --git a/Assets/Tests/Editor/CliSetupApplicationServiceTests.cs b/Assets/Tests/Editor/CliSetupApplicationServiceTests.cs index a926bf09d..19f605e68 100644 --- a/Assets/Tests/Editor/CliSetupApplicationServiceTests.cs +++ b/Assets/Tests/Editor/CliSetupApplicationServiceTests.cs @@ -31,14 +31,14 @@ public async Task InstallGlobalCliAsync_UsesMinimumRequiredCliReleaseTag() } [Test] - public void GetMinimumRequiredCliVersion_RequiresSingleBinaryCliRelease() + public void GetMinimumRequiredCliVersion_RequiresTerminalUninstallCliRelease() { - // Verifies this package release rejects CLIs older than the single-binary release. + // Verifies this package release rejects CLIs older than the terminal uninstall command. CliSetupApplicationService service = new( new FakeCliInstallationDetector(new string[] { null }), new FakeNativeCliInstaller()); - Assert.That(service.GetMinimumRequiredCliVersion(), Is.EqualTo("3.0.0-beta.7")); + Assert.That(service.GetMinimumRequiredCliVersion(), Is.EqualTo("3.0.0-beta.8")); } [Test] diff --git a/Assets/Tests/Editor/NativeCliInstallerTests.cs b/Assets/Tests/Editor/NativeCliInstallerTests.cs index 93cc28944..7e4955880 100644 --- a/Assets/Tests/Editor/NativeCliInstallerTests.cs +++ b/Assets/Tests/Editor/NativeCliInstallerTests.cs @@ -252,6 +252,52 @@ public void RunInstallCommand_WhenInstallerDoesNotExitReturnsFailure() Assert.That(result.ErrorOutput, Does.Contain("timed out")); } + [Test] + public void RunUninstallCommand_WhenCanceledReportsUninstallCommand() + { + // Verifies shared setup command cancellation reports the uninstall operation. + NativeCliInstallCommand command = BuildLongRunningInstallCommand(); + using CancellationTokenSource cts = new(); + cts.CancelAfter(10); + + CliInstallResult result = NativeCliInstaller.RunUninstallCommand( + command, + "/Users/masamichi/.local/bin", + cts.Token, + 1000); + + Assert.That(result.Success, Is.False); + Assert.That(result.ErrorOutput, Is.EqualTo("Global CLI uninstall command was canceled.")); + } + + [Test] + public void BuildUninstallCommand_OnMacRunsInstalledLauncher() + { + // Verifies that editor uninstall delegates removal to the installed uloop command. + NativeCliInstallCommand command = NativeCliInstaller.BuildUninstallCommand( + "/Users/masamichi/.local/bin", + RuntimePlatform.OSXEditor); + + Assert.That(command.FileName, Is.EqualTo("/Users/masamichi/.local/bin/uloop")); + Assert.That(command.Arguments, Is.EqualTo("uninstall")); + Assert.That(command.ManualCommand, Is.EqualTo("\"/Users/masamichi/.local/bin/uloop\" uninstall")); + } + + [Test] + public void BuildUninstallCommand_OnWindowsRunsInstalledLauncher() + { + // Verifies that Windows editor uninstall delegates removal to the installed uloop command. + NativeCliInstallCommand command = NativeCliInstaller.BuildUninstallCommand( + "C:\\Users\\masamichi\\AppData\\Local\\Programs\\uloop\\bin", + RuntimePlatform.WindowsEditor); + + Assert.That(command.FileName, Does.Contain("C:\\Users\\masamichi\\AppData\\Local\\Programs\\uloop\\bin")); + Assert.That(command.FileName, Does.EndWith("uloop.exe")); + Assert.That(command.Arguments, Is.EqualTo("uninstall")); + Assert.That(command.ManualCommand, Does.Contain("C:\\Users\\masamichi\\AppData\\Local\\Programs\\uloop\\bin")); + Assert.That(command.ManualCommand, Does.EndWith("uloop.exe\" uninstall")); + } + [Test] public void BuildPathWithInstallDirectory_OnWindowsPrependsMissingNativeInstallDir() { diff --git a/Packages/src/Cli~/contract.json b/Packages/src/Cli~/contract.json index 6024064c8..cf1df11b7 100644 --- a/Packages/src/Cli~/contract.json +++ b/Packages/src/Cli~/contract.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "cliVersion": "3.0.0-beta.7" + "cliVersion": "3.0.0-beta.8" } diff --git a/Packages/src/Cli~/dist/darwin-amd64/uloop b/Packages/src/Cli~/dist/darwin-amd64/uloop index ba396e734..7b8fe4da5 100755 Binary files a/Packages/src/Cli~/dist/darwin-amd64/uloop and b/Packages/src/Cli~/dist/darwin-amd64/uloop differ diff --git a/Packages/src/Cli~/dist/darwin-arm64/uloop b/Packages/src/Cli~/dist/darwin-arm64/uloop index 6d90f3b8f..985235b21 100755 Binary files a/Packages/src/Cli~/dist/darwin-arm64/uloop and b/Packages/src/Cli~/dist/darwin-arm64/uloop differ diff --git a/Packages/src/Cli~/dist/windows-amd64/uloop.exe b/Packages/src/Cli~/dist/windows-amd64/uloop.exe index 092bc0f8a..600fa8138 100755 Binary files a/Packages/src/Cli~/dist/windows-amd64/uloop.exe and b/Packages/src/Cli~/dist/windows-amd64/uloop.exe differ diff --git a/Packages/src/Cli~/internal/architecture/architecture_test.go b/Packages/src/Cli~/internal/architecture/architecture_test.go index 24a5caae9..06da2dfb3 100644 --- a/Packages/src/Cli~/internal/architecture/architecture_test.go +++ b/Packages/src/Cli~/internal/architecture/architecture_test.go @@ -68,7 +68,7 @@ func TestCliInternalPackagesStayInsideExplicitBoundaries(t *testing.T) { if goPackage.ImportPath == cliModulePath+"/internal/architecture" { continue } - for _, boundary := range []string{"/internal/cli", "/internal/project", "/internal/skills", "/internal/tools", "/internal/unityipc", "/internal/update", "/internal/version"} { + for _, boundary := range []string{"/internal/cli", "/internal/project", "/internal/skills", "/internal/tools", "/internal/uninstall", "/internal/unityipc", "/internal/update", "/internal/version"} { if strings.Contains(goPackage.ImportPath, boundary) { goto nextPackage } diff --git a/Packages/src/Cli~/internal/cli/command_registry.go b/Packages/src/Cli~/internal/cli/command_registry.go index 42fdac3d4..985993eb7 100644 --- a/Packages/src/Cli~/internal/cli/command_registry.go +++ b/Packages/src/Cli~/internal/cli/command_registry.go @@ -14,6 +14,7 @@ var nativeCommands = []nativeCommandEntry{ {name: "skills", description: "List, install, or uninstall agent skills"}, {name: "completion", description: "Print or install shell completion"}, {name: "update", description: "Update the global uloop launcher binary"}, + {name: "uninstall", description: "Remove the global uloop launcher binary"}, } func nativeCommandNamesForCompletion() []string { diff --git a/Packages/src/Cli~/internal/cli/completion_test.go b/Packages/src/Cli~/internal/cli/completion_test.go index c93d4c14a..2c8eca815 100644 --- a/Packages/src/Cli~/internal/cli/completion_test.go +++ b/Packages/src/Cli~/internal/cli/completion_test.go @@ -26,7 +26,7 @@ func TestCompletionListCommandsIncludesNativeCommandsAndDefaultTools(t *testing. } output := stdout.String() - for _, command := range []string{"completion", "focus-window", "sync"} { + for _, command := range []string{"completion", "focus-window", "sync", "uninstall"} { if !strings.Contains(output, command) { t.Fatalf("command %s was not listed: %s", command, output) } diff --git a/Packages/src/Cli~/internal/cli/error_envelope.go b/Packages/src/Cli~/internal/cli/error_envelope.go index a4c27b92c..83bfe7e1f 100644 --- a/Packages/src/Cli~/internal/cli/error_envelope.go +++ b/Packages/src/Cli~/internal/cli/error_envelope.go @@ -178,6 +178,21 @@ func classifyError(err error, context errorContext) cliError { } } + if message == uninstallUnsupportedOSMessage { + return cliError{ + ErrorCode: errorCodeInvalidArgument, + Phase: errorPhaseExecution, + Message: message, + Retryable: false, + SafeToRetry: false, + Command: context.command, + NextActions: []string{ + "Run `uloop uninstall` on macOS or Windows.", + "Remove the uloop launcher binary manually on this platform.", + }, + } + } + return internalCLIError(message, context) } diff --git a/Packages/src/Cli~/internal/cli/help_test.go b/Packages/src/Cli~/internal/cli/help_test.go index c0a657205..b1ec656ed 100644 --- a/Packages/src/Cli~/internal/cli/help_test.go +++ b/Packages/src/Cli~/internal/cli/help_test.go @@ -20,6 +20,7 @@ func TestPrintLauncherHelpListsNativeCommandsAndLiveToolGuidance(t *testing.T) { " focus-window", " list", " skills", + " uninstall", "Unity tool commands are project-specific.", "uloop list", "--project-path ", @@ -54,6 +55,7 @@ func TestPrintProjectLocalHelpListsNativeCommandsAndLiveToolGuidance(t *testing. " focus-window", " list", " sync", + " uninstall", "Unity tool commands are project-specific.", "--project-path ", "uloop --project-path /path/to/project list", diff --git a/Packages/src/Cli~/internal/cli/run.go b/Packages/src/Cli~/internal/cli/run.go index 74074d205..78d2f33f1 100644 --- a/Packages/src/Cli~/internal/cli/run.go +++ b/Packages/src/Cli~/internal/cli/run.go @@ -47,6 +47,9 @@ func RunProjectLocal(ctx context.Context, args []string, stdout io.Writer, stder if handled, code := tryHandleUpdateRequest(ctx, remainingArgs, stdout, stderr); handled { return code } + if handled, code := tryHandleUninstallRequest(ctx, remainingArgs, stdout, stderr); handled { + return code + } if handled, code := tryHandleLaunchRequest(ctx, remainingArgs, startPath, projectPath, stdout, stderr); handled { return code } diff --git a/Packages/src/Cli~/internal/cli/uninstall.go b/Packages/src/Cli~/internal/cli/uninstall.go new file mode 100644 index 000000000..694d8b11c --- /dev/null +++ b/Packages/src/Cli~/internal/cli/uninstall.go @@ -0,0 +1,113 @@ +package cli + +import ( + "context" + "errors" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + + "github.com/hatayama/unity-cli-loop/Packages/src/Cli/internal/uninstall" +) + +const ( + uninstallCommandName = "uninstall" + uninstallInstallDirEnvName = "ULOOP_INSTALL_DIR" + uninstallLocalAppDataEnvName = "LOCALAPPDATA" + uninstallUnsupportedOSMessage = uninstall.UnsupportedOSMessage +) + +func tryHandleUninstallRequest(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) (bool, int) { + if len(args) == 0 || args[0] != uninstallCommandName { + return false, 0 + } + if len(args) == 2 && isHelpRequest(args[1:]) { + printUninstallHelp(stdout) + return true, 0 + } + if len(args) > 1 { + writeClassifiedError(stderr, &argumentError{ + message: "Unknown uninstall option: " + args[1], + option: args[1], + command: uninstallCommandName, + nextActions: []string{"Run `uloop uninstall` without options."}, + }, errorContext{command: uninstallCommandName}) + return true, 1 + } + + installDir, err := resolveUninstallInstallDir(runtime.GOOS) + if err != nil { + writeClassifiedError(stderr, err, errorContext{command: uninstallCommandName}) + return true, 1 + } + uninstallCommand, err := uninstall.CommandForOS(runtime.GOOS, uninstall.Options{ + InstallDir: installDir, + CurrentPID: os.Getpid(), + }) + if err != nil { + writeClassifiedError(stderr, err, errorContext{command: uninstallCommandName}) + return true, 1 + } + + writeLine(stdout, "Uninstalling global uloop launcher...") + command := exec.CommandContext(ctx, uninstallCommand.Name, uninstallCommand.Args...) + command.Stdout = stdout + command.Stderr = stderr + if err := command.Run(); err != nil { + writeErrorEnvelope(stderr, cliError{ + ErrorCode: errorCodeInternalError, + Phase: errorPhaseExecution, + Message: "Uninstall failed: " + err.Error(), + Retryable: true, + SafeToRetry: true, + Command: uninstallCommandName, + NextActions: []string{"Retry `uloop uninstall` after checking file permissions."}, + Details: map[string]any{ + "cause": err.Error(), + }, + }) + return true, 1 + } + + if uninstallCommand.Deferred { + writeFormat(stdout, "Scheduled uloop launcher removal: %s\n", uninstallCommand.TargetPath) + } else { + writeFormat(stdout, "Removed uloop launcher: %s\n", uninstallCommand.TargetPath) + } + writeLine(stdout, "PATH settings were not changed. Remove the install directory from PATH manually if it is no longer needed.") + return true, 0 +} + +func printUninstallHelp(stdout io.Writer) { + writeLine(stdout, "Usage:") + writeLine(stdout, " uloop uninstall") + writeLine(stdout, "") + writeLine(stdout, "Removes the global uloop launcher binary from the install directory.") + writeLine(stdout, "Set ULOOP_INSTALL_DIR to uninstall from a custom install directory.") + writeLine(stdout, "PATH settings are not changed automatically.") +} + +func resolveUninstallInstallDir(goos string) (string, error) { + if installDir := os.Getenv(uninstallInstallDirEnvName); installDir != "" { + return installDir, nil + } + + switch goos { + case "darwin": + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".local", "bin"), nil + case "windows": + localAppData := os.Getenv(uninstallLocalAppDataEnvName) + if localAppData == "" { + return "", errors.New("LOCALAPPDATA is required to resolve the uloop install directory") + } + return filepath.Join(localAppData, "Programs", "uloop", "bin"), nil + default: + return "", errors.New(uninstallUnsupportedOSMessage) + } +} diff --git a/Packages/src/Cli~/internal/cli/uninstall_test.go b/Packages/src/Cli~/internal/cli/uninstall_test.go new file mode 100644 index 000000000..0bd35fcea --- /dev/null +++ b/Packages/src/Cli~/internal/cli/uninstall_test.go @@ -0,0 +1,24 @@ +package cli + +import ( + "bytes" + "context" + "strings" + "testing" +) + +func TestRunProjectLocalUninstallHelpDoesNotRequireUnityProject(t *testing.T) { + // Verifies uninstall help is available before Unity project resolution. + t.Chdir(t.TempDir()) + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := RunProjectLocal(context.Background(), []string{"uninstall", "--help"}, &stdout, &stderr) + + if code != 0 { + t.Fatalf("uninstall help failed: code=%d stderr=%s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "uloop uninstall") { + t.Fatalf("uninstall help output mismatch: %s", stdout.String()) + } +} diff --git a/Packages/src/Cli~/internal/tools/default-tools.json b/Packages/src/Cli~/internal/tools/default-tools.json index 874f1e270..d0ca68401 100644 --- a/Packages/src/Cli~/internal/tools/default-tools.json +++ b/Packages/src/Cli~/internal/tools/default-tools.json @@ -1,5 +1,5 @@ { - "version": "3.0.0-beta.7", + "version": "3.0.0-beta.8", "tools": [ { "name": "compile", diff --git a/Packages/src/Cli~/internal/uninstall/command.go b/Packages/src/Cli~/internal/uninstall/command.go new file mode 100644 index 000000000..f19d86067 --- /dev/null +++ b/Packages/src/Cli~/internal/uninstall/command.go @@ -0,0 +1,103 @@ +package uninstall + +import ( + "encoding/base64" + "errors" + "fmt" + "path/filepath" + "strings" + "unicode/utf16" +) + +const ( + UnsupportedOSMessage = "native uninstall is only supported on macOS and Windows" + PosixCommandName = "uloop" + WindowsCommandName = "uloop.exe" +) + +type Options struct { + InstallDir string + CurrentPID int +} + +type Command struct { + Name string + Args []string + TargetPath string + Deferred bool +} + +func CommandForOS(goos string, options Options) (Command, error) { + if options.InstallDir == "" { + return Command{}, errors.New("install directory is required") + } + + switch goos { + case "darwin": + targetPath := filepath.Join(options.InstallDir, PosixCommandName) + script := "rm -f " + shellQuote(targetPath) + return Command{ + Name: "sh", + Args: []string{"-c", script}, + TargetPath: targetPath, + }, nil + case "windows": + if options.CurrentPID <= 0 { + return Command{}, errors.New("current process id is required") + } + targetPath := windowsTargetPath(options.InstallDir) + return Command{ + Name: "powershell", + Args: windowsUninstallArgs(targetPath, options.CurrentPID), + TargetPath: targetPath, + Deferred: true, + }, nil + default: + return Command{}, errors.New(UnsupportedOSMessage) + } +} + +func windowsUninstallArgs(targetPath string, currentPID int) []string { + deleteScript := windowsDeletionScript(targetPath, currentPID) + launchScript := fmt.Sprintf( + "$EncodedDeletion = '%s'\nStart-Process -FilePath 'powershell' -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-EncodedCommand',$EncodedDeletion) -WindowStyle Hidden\n", + encodePowerShellCommand(deleteScript), + ) + return []string{ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-EncodedCommand", + encodePowerShellCommand(launchScript), + } +} + +func windowsDeletionScript(targetPath string, currentPID int) string { + return fmt.Sprintf( + "$Target = %s\n$ParentPid = %d\nWait-Process -Id $ParentPid -ErrorAction SilentlyContinue\nif (Test-Path -LiteralPath $Target) {\n Remove-Item -LiteralPath $Target -Force -ErrorAction SilentlyContinue\n}\n", + powerShellSingleQuote(targetPath), + currentPID, + ) +} + +func encodePowerShellCommand(script string) string { + encoded := utf16.Encode([]rune(script)) + bytes := make([]byte, 0, len(encoded)*2) + for _, value := range encoded { + bytes = append(bytes, byte(value), byte(value>>8)) + } + return base64.StdEncoding.EncodeToString(bytes) +} + +func powerShellSingleQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", "''") + "'" +} + +func windowsTargetPath(installDir string) string { + trimmed := strings.TrimRight(installDir, `\/`) + return trimmed + `\` + WindowsCommandName +} + +func shellQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" +} diff --git a/Packages/src/Cli~/internal/uninstall/command_test.go b/Packages/src/Cli~/internal/uninstall/command_test.go new file mode 100644 index 000000000..fe54dd59b --- /dev/null +++ b/Packages/src/Cli~/internal/uninstall/command_test.go @@ -0,0 +1,76 @@ +package uninstall + +import ( + "strings" + "testing" +) + +func TestCommandForDarwinRemovesUloopFromInstallDirectory(t *testing.T) { + // Verifies macOS uninstall removes the launcher binary from the selected install directory. + command, err := CommandForOS("darwin", Options{ + InstallDir: "/Users/test/.local/bin", + CurrentPID: 1234, + }) + if err != nil { + t.Fatalf("CommandForOS failed: %v", err) + } + + if command.Name != "sh" { + t.Fatalf("command name mismatch: %s", command.Name) + } + joinedArgs := strings.Join(command.Args, " ") + if !strings.Contains(joinedArgs, "/Users/test/.local/bin/uloop") { + t.Fatalf("target path missing: %s", joinedArgs) + } + if !strings.Contains(joinedArgs, "rm -f") { + t.Fatalf("remove command missing: %s", joinedArgs) + } + if command.TargetPath != "/Users/test/.local/bin/uloop" { + t.Fatalf("target path mismatch: %s", command.TargetPath) + } +} + +func TestCommandForWindowsSchedulesRemovalAfterCurrentProcessExits(t *testing.T) { + // Verifies Windows uninstall defers deletion until the running launcher process exits. + command, err := CommandForOS("windows", Options{ + InstallDir: `C:\Users\test\AppData\Local\Programs\uloop\bin`, + CurrentPID: 5678, + }) + if err != nil { + t.Fatalf("CommandForOS failed: %v", err) + } + + if command.Name != "powershell" { + t.Fatalf("command name mismatch: %s", command.Name) + } + joinedArgs := strings.Join(command.Args, " ") + if !strings.Contains(joinedArgs, "-EncodedCommand") { + t.Fatalf("encoded command flag missing: %s", joinedArgs) + } + deletionScript := windowsDeletionScript(command.TargetPath, 5678) + for _, expected := range []string{"Wait-Process -Id $ParentPid", "$ParentPid = 5678"} { + if !strings.Contains(deletionScript, expected) { + t.Fatalf("expected %s in deletion script: %s", expected, deletionScript) + } + } + if !command.Deferred { + t.Fatal("windows uninstall should be deferred") + } + if command.TargetPath != `C:\Users\test\AppData\Local\Programs\uloop\bin\uloop.exe` { + t.Fatalf("target path mismatch: %s", command.TargetPath) + } +} + +func TestCommandForOSRejectsUnsupportedOS(t *testing.T) { + // Verifies unsupported platforms fail before building any destructive command. + _, err := CommandForOS("linux", Options{ + InstallDir: "/tmp/bin", + CurrentPID: 1234, + }) + if err == nil { + t.Fatal("expected unsupported OS error") + } + if !strings.Contains(err.Error(), "macOS and Windows") { + t.Fatalf("unexpected unsupported OS error: %v", err) + } +} diff --git a/Packages/src/Editor/Domain/CliConstants.cs b/Packages/src/Editor/Domain/CliConstants.cs index 71afaabc0..c98c9e55e 100644 --- a/Packages/src/Editor/Domain/CliConstants.cs +++ b/Packages/src/Editor/Domain/CliConstants.cs @@ -6,7 +6,7 @@ namespace io.github.hatayama.UnityCliLoop.Domain public static class CliConstants { public const string EXECUTABLE_NAME = "uloop"; - public const string MINIMUM_REQUIRED_CLI_VERSION = "3.0.0-beta.7"; + public const string MINIMUM_REQUIRED_CLI_VERSION = "3.0.0-beta.8"; public const string VERSION_FLAG = "--version"; public const string SHORT_VERSION_FLAG = "-v"; public const string RAW_CONTENT_BASE_URL = "https://raw.githubusercontent.com/hatayama/unity-cli-loop"; diff --git a/Packages/src/Editor/Infrastructure/CLI/NativeCliInstaller.cs b/Packages/src/Editor/Infrastructure/CLI/NativeCliInstaller.cs index e694f2719..6bf114d12 100644 --- a/Packages/src/Editor/Infrastructure/CLI/NativeCliInstaller.cs +++ b/Packages/src/Editor/Infrastructure/CLI/NativeCliInstaller.cs @@ -129,8 +129,10 @@ public static async Task InstallAsync( return result; } - public static async Task UninstallAsync(RuntimePlatform platform) + public static async Task UninstallAsync(RuntimePlatform platform, CancellationToken ct) { + ct.ThrowIfCancellationRequested(); + string installDirectory = GetInstallDirectoryForCurrentUser(platform); if (string.IsNullOrWhiteSpace(installDirectory)) { @@ -139,23 +141,23 @@ public static async Task UninstallAsync(RuntimePlatform platfo $"Could not resolve the global CLI install directory. Set {CliConstants.INSTALL_DIR_ENVIRONMENT_VARIABLE} and try again."); } - CliInstallResult result = await Task.Run(() => UninstallGlobalCli(installDirectory, platform)); - if (!result.Success) - { - return result; - } + NativeCliInstallCommand command = BuildUninstallCommand(installDirectory, platform); + return await Task.Run( + () => RunUninstallCommand(command, installDirectory, ct, INSTALL_PROCESS_TIMEOUT_MS), + ct); + } - if (!ShouldRemoveInstallDirectoryFromPath(installDirectory, platform)) - { - return result; - } + internal static NativeCliInstallCommand BuildUninstallCommand( + string installDirectory, + RuntimePlatform platform) + { + UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(installDirectory), "installDirectory must not be null or empty"); - RemoveInstallDirectoryFromCurrentProcessPath(installDirectory, platform); - return RemoveInstallDirectoryFromUserPath( - installDirectory, - platform, - Environment.GetEnvironmentVariable, - Environment.SetEnvironmentVariable); + string installPath = GetGlobalCliInstallPath(installDirectory, platform); + return new NativeCliInstallCommand( + installPath, + "uninstall", + $"{QuoteProcessArgument(installPath)} uninstall"); } internal static CliInstallResult RunInstallCommand( @@ -168,7 +170,49 @@ internal static CliInstallResult RunInstallCommand( UnityEngine.Debug.Assert(timeoutMs > 0, "timeoutMs must be greater than zero"); ct.ThrowIfCancellationRequested(); - ProcessStartInfo startInfo = new() { + return RunCliSetupCommand( + command, + ct, + timeoutMs, + "release CLI installer", + startInfo => { }); + } + + internal static CliInstallResult RunUninstallCommand( + NativeCliInstallCommand command, + string installDirectory, + CancellationToken ct, + int timeoutMs) + { + UnityEngine.Debug.Assert(!string.IsNullOrEmpty(installDirectory), "installDirectory must not be null or empty"); + + return RunCliSetupCommand( + command, + ct, + timeoutMs, + "global CLI uninstall command", + startInfo => + { + startInfo.EnvironmentVariables[CliConstants.INSTALL_DIR_ENVIRONMENT_VARIABLE] = installDirectory; + }); + } + + private static CliInstallResult RunCliSetupCommand( + NativeCliInstallCommand command, + CancellationToken ct, + int timeoutMs, + string commandDescription, + Action configureStartInfo) + { + UnityEngine.Debug.Assert(!string.IsNullOrEmpty(command.FileName), "command.FileName must not be null or empty"); + UnityEngine.Debug.Assert(!string.IsNullOrEmpty(command.Arguments), "command.Arguments must not be null or empty"); + UnityEngine.Debug.Assert(timeoutMs > 0, "timeoutMs must be greater than zero"); + UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(commandDescription), "commandDescription must not be null or empty"); + UnityEngine.Debug.Assert(configureStartInfo != null, "configureStartInfo must not be null"); + ct.ThrowIfCancellationRequested(); + + ProcessStartInfo startInfo = new() + { FileName = command.FileName, Arguments = command.Arguments, UseShellExecute = false, @@ -176,13 +220,14 @@ internal static CliInstallResult RunInstallCommand( RedirectStandardError = true, CreateNoWindow = true }; + configureStartInfo(startInfo); Process process = ProcessStartHelper.TryStart(startInfo); if (process == null) { return new CliInstallResult( false, - $"Failed to start release CLI installer: {command.FileName}"); + $"Failed to start {commandDescription}: {command.FileName}"); } StringBuilder standardOutputBuilder = new(); @@ -216,12 +261,18 @@ internal static CliInstallResult RunInstallCommand( if (canceled) { - return new CliInstallResult(false, "Release CLI installer was canceled."); + return new CliInstallResult( + false, + $"{BuildSentenceSubject(commandDescription)} was canceled."); } return new CliInstallResult( false, - BuildReleaseCliInstallTimeoutFailure(timeoutMs, timedOutErrorOutput, timedOutStandardOutput)); + BuildCliSetupCommandTimeoutFailure( + commandDescription, + timeoutMs, + timedOutErrorOutput, + timedOutStandardOutput)); } process.WaitForExit(); @@ -232,7 +283,7 @@ internal static CliInstallResult RunInstallCommand( return success ? new CliInstallResult(true, standardOutput) - : new CliInstallResult(false, BuildReleaseCliInstallFailure(errorOutput, standardOutput)); + : new CliInstallResult(false, BuildCliSetupCommandFailure(commandDescription, errorOutput, standardOutput)); } internal static CliInstallResult UninstallGlobalCli(string installDirectory, RuntimePlatform platform) @@ -694,8 +745,13 @@ private static CliInstallResult BuildCliUninstallFailure(Exception ex) return new CliInstallResult(false, errorOutput); } - private static string BuildReleaseCliInstallFailure(string errorOutput, string standardOutput) + private static string BuildCliSetupCommandFailure( + string commandDescription, + string errorOutput, + string standardOutput) { + UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(commandDescription), "commandDescription must not be null or empty"); + if (!string.IsNullOrWhiteSpace(errorOutput)) { return errorOutput; @@ -706,21 +762,32 @@ private static string BuildReleaseCliInstallFailure(string errorOutput, string s return standardOutput; } - return "Release CLI installer failed without output."; + return $"{BuildSentenceSubject(commandDescription)} failed without output."; } - private static string BuildReleaseCliInstallTimeoutFailure( + private static string BuildCliSetupCommandTimeoutFailure( + string commandDescription, int timeoutMs, string errorOutput, string standardOutput) { - string capturedOutput = BuildReleaseCliInstallFailure(errorOutput, standardOutput); - if (string.Equals(capturedOutput, "Release CLI installer failed without output.", StringComparison.Ordinal)) + UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(commandDescription), "commandDescription must not be null or empty"); + + string capturedOutput = BuildCliSetupCommandFailure(commandDescription, errorOutput, standardOutput); + string noOutputMessage = $"{BuildSentenceSubject(commandDescription)} failed without output."; + if (string.Equals(capturedOutput, noOutputMessage, StringComparison.Ordinal)) { - return $"Release CLI installer timed out after {timeoutMs} ms."; + return $"{BuildSentenceSubject(commandDescription)} timed out after {timeoutMs} ms."; } - return $"Release CLI installer timed out after {timeoutMs} ms.\n{capturedOutput}"; + return $"{BuildSentenceSubject(commandDescription)} timed out after {timeoutMs} ms.\n{capturedOutput}"; + } + + private static string BuildSentenceSubject(string value) + { + UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(value), "value must not be null or empty"); + + return char.ToUpperInvariant(value[0]) + value.Substring(1); } private static bool WaitForInstallProcessExit( @@ -972,7 +1039,7 @@ public Task InstallGlobalCliAsync( public Task UninstallGlobalCliAsync(RuntimePlatform platform, CancellationToken ct) { ct.ThrowIfCancellationRequested(); - return NativeCliInstaller.UninstallAsync(platform); + return NativeCliInstaller.UninstallAsync(platform, ct); } public NativeCliInstallCommand GetGlobalCliInstallCommand(