diff --git a/internal/util/upgrade.go b/internal/util/upgrade.go index 78a0d2e7..20bef075 100644 --- a/internal/util/upgrade.go +++ b/internal/util/upgrade.go @@ -107,6 +107,26 @@ func isWindowsMsiInstallation() bool { strings.Contains(strings.ToLower(exe), "\\program files (x86)\\") } +func isWindowsUserInstallation() bool { + if runtime.GOOS != "windows" { + return false + } + exe, err := os.Executable() + if err != nil { + return false + } + + localAppData := os.Getenv("LOCALAPPDATA") + if localAppData == "" { + return false + } + + exePath := strings.ToLower(filepath.Clean(exe)) + localAppDataPath := strings.ToLower(filepath.Clean(filepath.Join(localAppData, "Agentuity"))) + + return filepath.HasPrefix(exePath, localAppDataPath) +} + func getReleaseAssetName() string { goos := runtime.GOOS arch := runtime.GOARCH @@ -189,6 +209,21 @@ func UpgradeCLI(ctx context.Context, logger logger.Logger, force bool) error { return upgradeWithWindowsMsi(ctx, logger, release) } + if runtime.GOOS == "windows" && isWindowsUserInstallation() { + logger.Debug("Detected Windows user installation, upgrading without admin privileges") + release, err := GetLatestRelease(ctx) + if err != nil { + return fmt.Errorf("failed to get latest release: %w", err) + } + + if Version == release && !force { + tui.ShowSuccess("You are already on the latest version (%s)", release) + return nil + } + + return upgradeWithWindowsUser(ctx, logger, release) + } + release, err := GetLatestRelease(ctx) // Using public function from version.go if err != nil { return fmt.Errorf("failed to get latest release: %w", err) @@ -640,6 +675,97 @@ func upgradeWithHomebrew(ctx context.Context, logger logger.Logger) error { return nil } +func upgradeWithWindowsUser(ctx context.Context, logger logger.Logger, version string) error { + tempDir, err := os.MkdirTemp("", "agentuity-upgrade-msi") + if err != nil { + return fmt.Errorf("failed to create temp directory: %w", err) + } + defer os.RemoveAll(tempDir) + + installerName := getMsiInstallerName() + + versionPrefix := "v" + if strings.HasPrefix(version, "v") { + versionPrefix = "" + } + installerURL := fmt.Sprintf("https://github.com/agentuity/cli/releases/download/%s%s/%s", versionPrefix, version, installerName) + installerPath := filepath.Join(tempDir, installerName) + + var downloadErr error + downloadAction := func() { + if err := downloadFile(installerURL, installerPath); err != nil { + downloadErr = fmt.Errorf("failed to download release asset: %w", err) + } + } + tui.ShowSpinner(fmt.Sprintf("Downloading %s...", version), downloadAction) + if downloadErr != nil { + return downloadErr + } + + checksumFileName := "checksums.txt" + checksumURL := fmt.Sprintf("https://github.com/agentuity/cli/releases/download/%s%s/%s", versionPrefix, version, checksumFileName) + checksumPath := filepath.Join(tempDir, checksumFileName) + + var checksumDownloadErr error + checksumAction := func() { + if err := downloadFile(checksumURL, checksumPath); err != nil { + checksumDownloadErr = fmt.Errorf("failed to download checksum file: %w", err) + } + } + tui.ShowSpinner("Downloading checksum...", checksumAction) + if checksumDownloadErr != nil { + return checksumDownloadErr + } + + var checksumErr error + var checksum string + var valid bool + verifyAction := func() { + var err1, err2 error + checksum, err1 = getChecksumFromFile(checksumPath, installerName) + if err1 != nil { + checksumErr = fmt.Errorf("failed to get checksum: %w", err1) + return + } + + valid, err2 = verifyChecksum(installerPath, checksum) + if err2 != nil { + checksumErr = fmt.Errorf("failed to verify checksum: %w", err2) + } + } + tui.ShowSpinner("Verifying checksum...", verifyAction) + if checksumErr != nil { + return checksumErr + } + + if !valid { + return fmt.Errorf("checksum verification failed") + } + + logger.Debug("Installing MSI for current user only") + + batchFile := filepath.Join(tempDir, "install.bat") + batchContent := fmt.Sprintf("msiexec.exe /i \"%s\" /qn ALLUSERS=0", installerPath) + if err := os.WriteFile(batchFile, []byte(batchContent), 0755); err != nil { + return fmt.Errorf("failed to create batch file: %w", err) + } + + var installErr error + installAction := func() { + cmd := exec.CommandContext(ctx, "cmd.exe", "/C", batchFile) + if out, err := cmd.CombinedOutput(); err != nil { + installErr = fmt.Errorf("failed to install MSI: %w\nOutput: %s", err, string(out)) + } + } + tui.ShowSpinner("Installing...", installAction) + if installErr != nil { + return installErr + } + + tui.ShowSuccess("Successfully upgraded to version %s", version) + return nil +} + func upgradeWithWindowsMsi(ctx context.Context, logger logger.Logger, version string) error { if os.Getenv("CI") != "" || os.Getenv("GITHUB_ACTIONS") != "" || os.Getenv("NONINTERACTIVE") != "" { tui.ShowWarning("Non-interactive environment detected, skipping automatic MSI installation") @@ -651,10 +777,11 @@ func upgradeWithWindowsMsi(ctx context.Context, logger logger.Logger, version st defer os.RemoveAll(tempDir) installerName := getMsiInstallerName() + versionPrefix := "v" if strings.HasPrefix(version, "v") { - version = strings.TrimPrefix(version, "v") + versionPrefix = "" } - installerURL := fmt.Sprintf("https://github.com/agentuity/cli/releases/download/v%s/%s", version, installerName) + installerURL := fmt.Sprintf("https://github.com/agentuity/cli/releases/download/%s%s/%s", versionPrefix, version, installerName) installerPath := filepath.Join(tempDir, installerName) var downloadErr error @@ -732,10 +859,11 @@ if ($products) { } installerName := getMsiInstallerName() + versionPrefix := "v" if strings.HasPrefix(version, "v") { - version = strings.TrimPrefix(version, "v") + versionPrefix = "" } - installerURL := fmt.Sprintf("https://github.com/agentuity/cli/releases/download/v%s/%s", version, installerName) + installerURL := fmt.Sprintf("https://github.com/agentuity/cli/releases/download/%s%s/%s", versionPrefix, version, installerName) installerPath := filepath.Join(tempDir, installerName) var downloadErr error