From 5fd4800a6a4a5e7a0e20bb3cdbab03e4daf832f2 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 1 May 2025 03:25:22 +0000 Subject: [PATCH 1/6] Add support for non-administrator CLI upgrades on Windows Co-Authored-By: jhaynie@agentuity.com --- internal/util/upgrade.go | 100 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/internal/util/upgrade.go b/internal/util/upgrade.go index 78a0d2e7..85d99a24 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(strings.ReplaceAll(exe, "\\", "/")) + localAppDataPath := strings.ToLower(strings.ReplaceAll(filepath.Join(localAppData, "Agentuity"), "\\", "/")) + + return strings.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,71 @@ 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-zip") + if err != nil { + return fmt.Errorf("failed to create temp directory: %w", err) + } + defer os.RemoveAll(tempDir) + + assetName := getReleaseAssetName() // This returns the zip file + assetURL := fmt.Sprintf("https://github.com/agentuity/cli/releases/download/v%s/%s", version, assetName) + assetPath := filepath.Join(tempDir, assetName) + + var downloadErr error + downloadAction := func() { + if err := downloadFile(assetURL, assetPath); 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/v%s/%s", 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, assetName) + if err1 != nil { + checksumErr = fmt.Errorf("failed to get checksum: %w", err1) + return + } + + valid, err2 = verifyChecksum(assetPath, 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") + } + + return replaceBinary(ctx, logger, assetPath, version) +} + 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") From f4f696075da8ce7b09135b59076c0317d3ef9568 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 1 May 2025 03:29:13 +0000 Subject: [PATCH 2/6] Use filepath package for platform-independent path handling Co-Authored-By: jhaynie@agentuity.com --- internal/util/upgrade.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/util/upgrade.go b/internal/util/upgrade.go index 85d99a24..693274db 100644 --- a/internal/util/upgrade.go +++ b/internal/util/upgrade.go @@ -121,10 +121,10 @@ func isWindowsUserInstallation() bool { return false } - exePath := strings.ToLower(strings.ReplaceAll(exe, "\\", "/")) - localAppDataPath := strings.ToLower(strings.ReplaceAll(filepath.Join(localAppData, "Agentuity"), "\\", "/")) + exePath := strings.ToLower(filepath.Clean(exe)) + localAppDataPath := strings.ToLower(filepath.Clean(filepath.Join(localAppData, "Agentuity"))) - return strings.HasPrefix(exePath, localAppDataPath) + return filepath.HasPrefix(exePath, localAppDataPath) } func getReleaseAssetName() string { From 4e667d2cfcfd43b123d8587ce82f0407402a72de Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 1 May 2025 03:43:41 +0000 Subject: [PATCH 3/6] Fix URL construction for release asset downloads Co-Authored-By: jhaynie@agentuity.com --- internal/util/upgrade.go | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/internal/util/upgrade.go b/internal/util/upgrade.go index 693274db..6f5f3871 100644 --- a/internal/util/upgrade.go +++ b/internal/util/upgrade.go @@ -683,7 +683,11 @@ func upgradeWithWindowsUser(ctx context.Context, logger logger.Logger, version s defer os.RemoveAll(tempDir) assetName := getReleaseAssetName() // This returns the zip file - assetURL := fmt.Sprintf("https://github.com/agentuity/cli/releases/download/v%s/%s", version, assetName) + versionPrefix := "v" + if strings.HasPrefix(version, "v") { + versionPrefix = "" + } + assetURL := fmt.Sprintf("https://github.com/agentuity/cli/releases/download/%s%s/%s", versionPrefix, version, assetName) assetPath := filepath.Join(tempDir, assetName) var downloadErr error @@ -698,7 +702,7 @@ func upgradeWithWindowsUser(ctx context.Context, logger logger.Logger, version s } checksumFileName := "checksums.txt" - checksumURL := fmt.Sprintf("https://github.com/agentuity/cli/releases/download/v%s/%s", version, checksumFileName) + checksumURL := fmt.Sprintf("https://github.com/agentuity/cli/releases/download/%s%s/%s", versionPrefix, version, checksumFileName) checksumPath := filepath.Join(tempDir, checksumFileName) var checksumDownloadErr error @@ -751,10 +755,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 @@ -832,10 +837,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 From 805bfda14d8f652089c6df5571a5ba378eb59889 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 1 May 2025 13:55:04 +0000 Subject: [PATCH 4/6] Fix Windows non-admin upgrade to use MSI installer instead of ZIP Co-Authored-By: jhaynie@agentuity.com --- internal/util/upgrade.go | 49 +++++++++++++++++++++++++++++++++------- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/internal/util/upgrade.go b/internal/util/upgrade.go index 6f5f3871..339f963c 100644 --- a/internal/util/upgrade.go +++ b/internal/util/upgrade.go @@ -676,23 +676,35 @@ func upgradeWithHomebrew(ctx context.Context, logger logger.Logger) error { } func upgradeWithWindowsUser(ctx context.Context, logger logger.Logger, version string) error { - tempDir, err := os.MkdirTemp("", "agentuity-upgrade-zip") + tempDir, err := os.MkdirTemp("", "agentuity-upgrade-msi") if err != nil { return fmt.Errorf("failed to create temp directory: %w", err) } defer os.RemoveAll(tempDir) - assetName := getReleaseAssetName() // This returns the zip file + var arch string + if runtime.GOARCH == "amd64" { + arch = "x64" + } else if runtime.GOARCH == "386" { + arch = "x86" + } else if runtime.GOARCH == "arm64" { + arch = "arm64" + } else { + return fmt.Errorf("unsupported architecture: %s", runtime.GOARCH) + } + + installerName := fmt.Sprintf("agentuity-%s.msi", arch) + versionPrefix := "v" if strings.HasPrefix(version, "v") { versionPrefix = "" } - assetURL := fmt.Sprintf("https://github.com/agentuity/cli/releases/download/%s%s/%s", versionPrefix, version, assetName) - assetPath := filepath.Join(tempDir, assetName) + 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(assetURL, assetPath); err != nil { + if err := downloadFile(installerURL, installerPath); err != nil { downloadErr = fmt.Errorf("failed to download release asset: %w", err) } } @@ -721,13 +733,13 @@ func upgradeWithWindowsUser(ctx context.Context, logger logger.Logger, version s var valid bool verifyAction := func() { var err1, err2 error - checksum, err1 = getChecksumFromFile(checksumPath, assetName) + checksum, err1 = getChecksumFromFile(checksumPath, installerName) if err1 != nil { checksumErr = fmt.Errorf("failed to get checksum: %w", err1) return } - valid, err2 = verifyChecksum(assetPath, checksum) + valid, err2 = verifyChecksum(installerPath, checksum) if err2 != nil { checksumErr = fmt.Errorf("failed to verify checksum: %w", err2) } @@ -741,7 +753,28 @@ func upgradeWithWindowsUser(ctx context.Context, logger logger.Logger, version s return fmt.Errorf("checksum verification failed") } - return replaceBinary(ctx, logger, assetPath, version) + 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 { From 391e489e9f19948514b1a59a8f56dfbfc1b36028 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 1 May 2025 14:01:34 +0000 Subject: [PATCH 5/6] Fix MSI installer URL construction for non-admin upgrades Co-Authored-By: jhaynie@agentuity.com --- internal/util/upgrade.go | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/internal/util/upgrade.go b/internal/util/upgrade.go index 339f963c..20bef075 100644 --- a/internal/util/upgrade.go +++ b/internal/util/upgrade.go @@ -682,18 +682,7 @@ func upgradeWithWindowsUser(ctx context.Context, logger logger.Logger, version s } defer os.RemoveAll(tempDir) - var arch string - if runtime.GOARCH == "amd64" { - arch = "x64" - } else if runtime.GOARCH == "386" { - arch = "x86" - } else if runtime.GOARCH == "arm64" { - arch = "arm64" - } else { - return fmt.Errorf("unsupported architecture: %s", runtime.GOARCH) - } - - installerName := fmt.Sprintf("agentuity-%s.msi", arch) + installerName := getMsiInstallerName() versionPrefix := "v" if strings.HasPrefix(version, "v") { From a2318d3918f3d425b82b41940f4bbb88e3deb420 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 1 May 2025 18:02:16 +0000 Subject: [PATCH 6/6] Fix Windows non-admin upgrade process by improving temporary directory handling and MSI execution Co-Authored-By: jhaynie@agentuity.com --- internal/util/upgrade.go | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/internal/util/upgrade.go b/internal/util/upgrade.go index 20bef075..5c51288d 100644 --- a/internal/util/upgrade.go +++ b/internal/util/upgrade.go @@ -680,7 +680,6 @@ func upgradeWithWindowsUser(ctx context.Context, logger logger.Logger, version s if err != nil { return fmt.Errorf("failed to create temp directory: %w", err) } - defer os.RemoveAll(tempDir) installerName := getMsiInstallerName() @@ -699,6 +698,7 @@ func upgradeWithWindowsUser(ctx context.Context, logger logger.Logger, version s } tui.ShowSpinner(fmt.Sprintf("Downloading %s...", version), downloadAction) if downloadErr != nil { + os.RemoveAll(tempDir) // Clean up on error return downloadErr } @@ -714,6 +714,7 @@ func upgradeWithWindowsUser(ctx context.Context, logger logger.Logger, version s } tui.ShowSpinner("Downloading checksum...", checksumAction) if checksumDownloadErr != nil { + os.RemoveAll(tempDir) // Clean up on error return checksumDownloadErr } @@ -735,29 +736,29 @@ func upgradeWithWindowsUser(ctx context.Context, logger logger.Logger, version s } tui.ShowSpinner("Verifying checksum...", verifyAction) if checksumErr != nil { + os.RemoveAll(tempDir) // Clean up on error return checksumErr } if !valid { + os.RemoveAll(tempDir) // Clean up on error 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) + cmd := exec.CommandContext(ctx, "msiexec.exe", "/i", installerPath, "/qn", "ALLUSERS=0") + cmd.Dir = tempDir if out, err := cmd.CombinedOutput(); err != nil { installErr = fmt.Errorf("failed to install MSI: %w\nOutput: %s", err, string(out)) } } tui.ShowSpinner("Installing...", installAction) + + defer os.RemoveAll(tempDir) + if installErr != nil { return installErr }