Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Assets/Tests/Editor/CliSetupApplicationServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
46 changes: 46 additions & 0 deletions Assets/Tests/Editor/NativeCliInstallerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
2 changes: 1 addition & 1 deletion Packages/src/Cli~/contract.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"schemaVersion": 1,
"cliVersion": "3.0.0-beta.7"
"cliVersion": "3.0.0-beta.8"
}
Binary file modified Packages/src/Cli~/dist/darwin-amd64/uloop
Binary file not shown.
Binary file modified Packages/src/Cli~/dist/darwin-arm64/uloop
Binary file not shown.
Binary file modified Packages/src/Cli~/dist/windows-amd64/uloop.exe
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
1 change: 1 addition & 0 deletions Packages/src/Cli~/internal/cli/command_registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion Packages/src/Cli~/internal/cli/completion_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
15 changes: 15 additions & 0 deletions Packages/src/Cli~/internal/cli/error_envelope.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
2 changes: 2 additions & 0 deletions Packages/src/Cli~/internal/cli/help_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ func TestPrintLauncherHelpListsNativeCommandsAndLiveToolGuidance(t *testing.T) {
" focus-window",
" list",
" skills",
" uninstall",
"Unity tool commands are project-specific.",
"uloop list",
"--project-path <path>",
Expand Down Expand Up @@ -54,6 +55,7 @@ func TestPrintProjectLocalHelpListsNativeCommandsAndLiveToolGuidance(t *testing.
" focus-window",
" list",
" sync",
" uninstall",
"Unity tool commands are project-specific.",
"--project-path <path>",
"uloop --project-path /path/to/project list",
Expand Down
3 changes: 3 additions & 0 deletions Packages/src/Cli~/internal/cli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
113 changes: 113 additions & 0 deletions Packages/src/Cli~/internal/cli/uninstall.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
24 changes: 24 additions & 0 deletions Packages/src/Cli~/internal/cli/uninstall_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
2 changes: 1 addition & 1 deletion Packages/src/Cli~/internal/tools/default-tools.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": "3.0.0-beta.7",
"version": "3.0.0-beta.8",
"tools": [
{
"name": "compile",
Expand Down
Loading
Loading