From ef4a966310dfec8bf9c184d3fa26ef12c2ca90a3 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Tue, 24 Mar 2026 18:20:07 -0400 Subject: [PATCH 01/10] Rename Homebrew cask to basecamp-cli and fix upgrade detection The CLI upgrade path confused the desktop app cask with the CLI because and resolve ambiguously. Update the packaging and upgrade flow to use a renamed CLI cask: - rename the Homebrew cask from { "ok": true, "data": { "auth": { "account": "2914079", "status": "authenticated" }, "commands": { "common": [ "basecamp todo \"content\"", "basecamp done \u003cid\u003e", "basecamp comment \u003cid\u003e \u003ctext\u003e" ], "quick_start": [ "basecamp projects list", "basecamp todos list", "basecamp search \"query\"" ] }, "context": {}, "version": "0.4.1-0.20260318172421-90913592bc41+dirty" }, "summary": "basecamp v0.4.1-0.20260318172421-90913592bc41+dirty - logged in @ 2914079" } to - rename the Scoop manifest from { "ok": true, "data": { "auth": { "account": "2914079", "status": "authenticated" }, "commands": { "common": [ "basecamp todo \"content\"", "basecamp done \u003cid\u003e", "basecamp comment \u003cid\u003e \u003ctext\u003e" ], "quick_start": [ "basecamp projects list", "basecamp todos list", "basecamp search \"query\"" ] }, "context": {}, "version": "0.4.1-0.20260318172421-90913592bc41+dirty" }, "summary": "basecamp v0.4.1-0.20260318172421-90913592bc41+dirty - logged in @ 2914079" } to - update install/release docs to use - change upgrade detection to check - add path detection for cask installs - change upgrades to use - detect legacy installs from and print migration steps This avoids false positives when the desktop app cask is installed and provides a guided migration path for existing CLI users. Tests: - go test ./internal/commands/... - bin/ci (fails at unrelated smoke coverage gate) --- .goreleaser.yaml | 8 ++-- README.md | 4 +- RELEASING.md | 8 ++-- install.md | 4 +- internal/commands/upgrade.go | 65 ++++++++++++++++++++++++------- internal/commands/upgrade_test.go | 31 ++++++++++++--- 6 files changed, 90 insertions(+), 30 deletions(-) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index ccbd08ec..e3962c95 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -131,7 +131,7 @@ release: **macOS:** ```sh - brew install --cask basecamp/tap/basecamp + brew install --cask basecamp/tap/basecamp-cli basecamp auth login ``` @@ -160,14 +160,14 @@ release: **Windows:** ```sh scoop bucket add basecamp https://github.com/basecamp/homebrew-tap - scoop install basecamp + scoop install basecamp-cli basecamp auth login ``` **Other platforms:** download the matching archive from the assets below. homebrew_casks: - - name: basecamp + - name: basecamp-cli repository: owner: basecamp name: homebrew-tap @@ -184,7 +184,7 @@ homebrew_casks: skip_upload: auto scoops: - - name: basecamp + - name: basecamp-cli repository: owner: basecamp name: homebrew-tap diff --git a/README.md b/README.md index b161d465..5ed2f52d 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ That's it. You now have full access to Basecamp from your terminal. **Brew / macOS** ``` -brew install --cask basecamp/tap/basecamp +brew install --cask basecamp/tap/basecamp-cli ``` **Arch Linux / Omarchy (AUR):** @@ -40,7 +40,7 @@ Arm64: substitute `arm64` for `amd64` in the filename. Verify the SHA-256 checks **Scoop (Windows):** ```bash scoop bucket add basecamp https://github.com/basecamp/homebrew-tap -scoop install basecamp +scoop install basecamp-cli ``` **Shell script:** diff --git a/RELEASING.md b/RELEASING.md index 25ba0f0f..8056f70e 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -28,8 +28,8 @@ make release VERSION=0.2.0 DRY_RUN=1 - Signs and notarizes macOS binaries (Developer ID via GoReleaser/quill) - Signs checksums with cosign (keyless via Sigstore OIDC) - Generates SBOM for supply chain transparency - - Updates Homebrew cask in `basecamp/homebrew-tap` - - Updates Scoop manifest in `basecamp/homebrew-tap` + - Updates Homebrew cask (`basecamp-cli`) in `basecamp/homebrew-tap` + - Updates Scoop manifest (`basecamp-cli`) in `basecamp/homebrew-tap` - Updates AUR `basecamp-cli` package (when `AUR_KEY` is configured) - Verifies Nix flake builds successfully @@ -88,8 +88,8 @@ make update-nix-hash | Channel | Location | Updated by | |---------|----------|------------| | GitHub Releases | [basecamp/basecamp-cli](https://github.com/basecamp/basecamp-cli/releases) | GoReleaser | -| Homebrew cask | `basecamp/homebrew-tap` Casks/ | GoReleaser | -| Scoop | `basecamp/homebrew-tap` root | GoReleaser | +| Homebrew cask (`basecamp-cli`) | `basecamp/homebrew-tap` Casks/ | GoReleaser | +| Scoop (`basecamp-cli`) | `basecamp/homebrew-tap` root | GoReleaser | | AUR | `basecamp-cli` | GoReleaser | | deb/rpm/apk packages | GitHub Release assets | GoReleaser (nfpm) | | Nix flake | `flake.nix` in repo | Self-serve (`nix profile install github:basecamp/basecamp-cli`) | diff --git a/install.md b/install.md index b5ce7274..496db55e 100644 --- a/install.md +++ b/install.md @@ -28,13 +28,13 @@ Alternatively install manually: ### Option A: Homebrew (macOS/Linux) — Recommended ```bash -brew install --cask basecamp/tap/basecamp +brew install --cask basecamp/tap/basecamp-cli ``` ### Option B: Scoop (Windows) ```bash scoop bucket add basecamp https://github.com/basecamp/homebrew-tap -scoop install basecamp +scoop install basecamp-cli ``` ### Option C: Linux package (Debian/Ubuntu, Fedora/RHEL, Alpine) diff --git a/internal/commands/upgrade.go b/internal/commands/upgrade.go index 86015032..765ef537 100644 --- a/internal/commands/upgrade.go +++ b/internal/commands/upgrade.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "os/exec" + "path/filepath" "strings" "github.com/spf13/cobra" @@ -14,10 +15,17 @@ import ( "github.com/basecamp/basecamp-cli/internal/version" ) -// versionChecker and homebrewChecker abstract external checks for testability. +const ( + homebrewCask = "basecamp/tap/basecamp-cli" + legacyHomebrewCask = "basecamp/tap/basecamp" + homebrewCaskroom = "/Caskroom/" +) + +// versionChecker and homebrew checkers abstract external checks for testability. var ( - versionChecker = fetchLatestVersion - homebrewChecker = isHomebrew + versionChecker = fetchLatestVersion + homebrewChecker = isHomebrew + legacyHomebrewCasker = hasLegacyHomebrewCask ) // NewUpgradeCmd creates the upgrade command. @@ -66,13 +74,30 @@ func runUpgrade(cmd *cobra.Command, args []string) error { fmt.Fprintf(w, "update available: %s\n", latest) ctx := cmd.Context() + if legacyHomebrewCasker(ctx) { + fmt.Fprintln(w) + fmt.Fprintln(w, "The CLI cask has been renamed. To upgrade, run:") + fmt.Fprintf(w, " brew uninstall --cask %s\n", legacyHomebrewCask) + fmt.Fprintf(w, " brew install --cask %s\n", homebrewCask) + return app.OK( + map[string]string{ + "status": "migration_required", + "from": current, + "to": latest, + "legacy_cask": legacyHomebrewCask, + "replacement": homebrewCask, + }, + output.WithSummary("Homebrew cask rename detected — manual migration required"), + ) + } + if homebrewChecker(ctx) { fmt.Fprintln(w, "Upgrading via Homebrew…") - upgrade := exec.CommandContext(ctx, "brew", "upgrade", "basecamp") + upgrade := exec.CommandContext(ctx, "brew", "upgrade", "--cask", homebrewCask) upgrade.Stdout = w upgrade.Stderr = cmd.ErrOrStderr() if err := upgrade.Run(); err != nil { - return fmt.Errorf("brew upgrade failed: %w", err) + return fmt.Errorf("brew upgrade failed for cask %s: %w", homebrewCask, err) } return app.OK( map[string]string{"status": "upgraded", "from": current, "to": latest}, @@ -90,20 +115,34 @@ func runUpgrade(cmd *cobra.Command, args []string) error { ) } -// isHomebrew returns true if the binary appears to be installed via Homebrew. +// isHomebrew returns true if the CLI appears to be installed via the renamed Homebrew cask. func isHomebrew(ctx context.Context) bool { exe, err := os.Executable() - if err != nil { - return false + if err == nil { + if resolved, resolveErr := filepath.EvalSymlinks(exe); resolveErr == nil { + exe = resolved + } + if strings.Contains(exe, homebrewCaskroom) { + return true + } } - // Check common Homebrew prefix paths - if strings.Contains(exe, "/Cellar/") || strings.Contains(exe, "/homebrew/") { - return true + return homebrewHasCask(ctx, homebrewCask) +} + +func hasLegacyHomebrewCask(ctx context.Context) bool { + return homebrewHasCask(ctx, legacyHomebrewCask) +} + +func homebrewHasCask(ctx context.Context, cask string) bool { + switch cask { + case homebrewCask, legacyHomebrewCask: + // allowed + default: + return false } - // Check if brew knows about us - out, err := exec.CommandContext(ctx, "brew", "list", "basecamp").CombinedOutput() + out, err := exec.CommandContext(ctx, "brew", "list", "--cask", cask).CombinedOutput() //nolint:gosec // G204: cask is validated against known constants above if err != nil { return false } diff --git a/internal/commands/upgrade_test.go b/internal/commands/upgrade_test.go index 388bd2a3..4ce9a70a 100644 --- a/internal/commands/upgrade_test.go +++ b/internal/commands/upgrade_test.go @@ -13,8 +13,8 @@ import ( "github.com/basecamp/basecamp-cli/internal/version" ) -// stubUpgradeCheckers overrides versionChecker and homebrewChecker for tests. -func stubUpgradeCheckers(t *testing.T, latestVersion string, isBrew bool) { +// stubUpgradeCheckers overrides version and Homebrew detection for tests. +func stubUpgradeCheckers(t *testing.T, latestVersion string, isBrew bool, hasLegacyCask bool) { t.Helper() origVC := versionChecker @@ -24,6 +24,10 @@ func stubUpgradeCheckers(t *testing.T, latestVersion string, isBrew bool) { origHC := homebrewChecker homebrewChecker = func(context.Context) bool { return isBrew } t.Cleanup(func() { homebrewChecker = origHC }) + + origLegacy := legacyHomebrewCasker + legacyHomebrewCasker = func(context.Context) bool { return hasLegacyCask } + t.Cleanup(func() { legacyHomebrewCasker = origLegacy }) } // executeUpgradeCommand runs the upgrade command and returns the combined @@ -63,7 +67,7 @@ func TestUpgradeAlreadyCurrent(t *testing.T) { version.Version = "1.2.3" t.Cleanup(func() { version.Version = orig }) - stubUpgradeCheckers(t, "1.2.3", false) + stubUpgradeCheckers(t, "1.2.3", false, false) cmdOut, err := executeUpgradeCommand(t, app) require.NoError(t, err) @@ -78,7 +82,7 @@ func TestUpgradeAvailable(t *testing.T) { version.Version = "1.2.3" t.Cleanup(func() { version.Version = orig }) - stubUpgradeCheckers(t, "1.3.0", false) + stubUpgradeCheckers(t, "1.3.0", false, false) cmdOut, err := executeUpgradeCommand(t, app) require.NoError(t, err) @@ -110,7 +114,7 @@ func TestUpgradeOutputGoesToWriter(t *testing.T) { version.Version = "1.0.0" t.Cleanup(func() { version.Version = orig }) - stubUpgradeCheckers(t, "1.0.0", false) + stubUpgradeCheckers(t, "1.0.0", false, false) cmd := NewUpgradeCmd() cmd.SetArgs(nil) @@ -135,3 +139,20 @@ func TestUpgradeOutputGoesToWriter(t *testing.T) { assert.Contains(t, buf.String(), "Current version: 1.0.0") assert.Contains(t, buf.String(), "already up to date") } + +func TestUpgradeLegacyCaskMigrationInstructions(t *testing.T) { + app, appBuf := setupPeopleTestApp(t) + + orig := version.Version + version.Version = "1.2.3" + t.Cleanup(func() { version.Version = orig }) + + stubUpgradeCheckers(t, "1.3.0", false, true) + + cmdOut, err := executeUpgradeCommand(t, app) + require.NoError(t, err) + assert.Contains(t, cmdOut, "The CLI cask has been renamed. To upgrade, run:") + assert.Contains(t, cmdOut, "brew uninstall --cask basecamp/tap/basecamp") + assert.Contains(t, cmdOut, "brew install --cask basecamp/tap/basecamp-cli") + assert.Contains(t, appBuf.String(), "migration_required") +} From 5cce36900970f9ae48bac72b5df24d5a8d9afa3b Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 25 Mar 2026 08:44:47 -0400 Subject: [PATCH 02/10] Fix Homebrew upgrade detection for renamed cask Tie Homebrew upgrade behavior to the running binary's provenance instead of installed cask presence. - detect the renamed cask via resolved executable path under /Caskroom/basecamp-cli/ - detect the legacy cask via resolved executable path under /Caskroom/basecamp/ - prefer upgrading the renamed cask before showing legacy migration instructions - restore isUpdateAvailable() handling for older latest-release values - tighten upgrade regression tests for mixed-install and migration cases --- internal/commands/upgrade.go | 87 ++++++++++++++++--------------- internal/commands/upgrade_test.go | 29 +++++++++-- 2 files changed, 71 insertions(+), 45 deletions(-) diff --git a/internal/commands/upgrade.go b/internal/commands/upgrade.go index 765ef537..d863105f 100644 --- a/internal/commands/upgrade.go +++ b/internal/commands/upgrade.go @@ -3,6 +3,7 @@ package commands import ( "context" "fmt" + "io" "os" "os/exec" "path/filepath" @@ -16,16 +17,18 @@ import ( ) const ( - homebrewCask = "basecamp/tap/basecamp-cli" - legacyHomebrewCask = "basecamp/tap/basecamp" - homebrewCaskroom = "/Caskroom/" + homebrewCask = "basecamp/tap/basecamp-cli" + legacyHomebrewCask = "basecamp/tap/basecamp" + homebrewCaskroomPath = "/Caskroom/basecamp-cli/" + legacyHomebrewCaskroomPath = "/Caskroom/basecamp/" ) -// versionChecker and homebrew checkers abstract external checks for testability. +// versionChecker and Homebrew helpers abstract external checks for testability. var ( versionChecker = fetchLatestVersion homebrewChecker = isHomebrew legacyHomebrewCasker = hasLegacyHomebrewCask + homebrewUpgrader = upgradeHomebrew ) // NewUpgradeCmd creates the upgrade command. @@ -74,6 +77,17 @@ func runUpgrade(cmd *cobra.Command, args []string) error { fmt.Fprintf(w, "update available: %s\n", latest) ctx := cmd.Context() + if homebrewChecker(ctx) { + fmt.Fprintln(w, "Upgrading via Homebrew…") + if err := homebrewUpgrader(ctx, w, cmd.ErrOrStderr()); err != nil { + return fmt.Errorf("brew upgrade failed for cask %s: %w", homebrewCask, err) + } + return app.OK( + map[string]string{"status": "upgraded", "from": current, "to": latest}, + output.WithSummary(fmt.Sprintf("Upgraded %s → %s", current, latest)), + ) + } + if legacyHomebrewCasker(ctx) { fmt.Fprintln(w) fmt.Fprintln(w, "The CLI cask has been renamed. To upgrade, run:") @@ -91,20 +105,6 @@ func runUpgrade(cmd *cobra.Command, args []string) error { ) } - if homebrewChecker(ctx) { - fmt.Fprintln(w, "Upgrading via Homebrew…") - upgrade := exec.CommandContext(ctx, "brew", "upgrade", "--cask", homebrewCask) - upgrade.Stdout = w - upgrade.Stderr = cmd.ErrOrStderr() - if err := upgrade.Run(); err != nil { - return fmt.Errorf("brew upgrade failed for cask %s: %w", homebrewCask, err) - } - return app.OK( - map[string]string{"status": "upgraded", "from": current, "to": latest}, - output.WithSummary(fmt.Sprintf("Upgraded %s → %s", current, latest)), - ) - } - downloadURL := fmt.Sprintf("https://github.com/basecamp/basecamp-cli/releases/tag/v%s", latest) fmt.Fprintln(w) fmt.Fprintf(w, "Download the latest release from:\n") @@ -115,36 +115,41 @@ func runUpgrade(cmd *cobra.Command, args []string) error { ) } -// isHomebrew returns true if the CLI appears to be installed via the renamed Homebrew cask. -func isHomebrew(ctx context.Context) bool { - exe, err := os.Executable() - if err == nil { - if resolved, resolveErr := filepath.EvalSymlinks(exe); resolveErr == nil { - exe = resolved - } - if strings.Contains(exe, homebrewCaskroom) { - return true - } - } - - return homebrewHasCask(ctx, homebrewCask) +func upgradeHomebrew(ctx context.Context, stdout io.Writer, stderr io.Writer) error { + upgrade := exec.CommandContext(ctx, "brew", "upgrade", "--cask", homebrewCask) + upgrade.Stdout = stdout + upgrade.Stderr = stderr + return upgrade.Run() } -func hasLegacyHomebrewCask(ctx context.Context) bool { - return homebrewHasCask(ctx, legacyHomebrewCask) +// isHomebrew returns true if the running CLI binary appears to come from the renamed Homebrew cask. +func isHomebrew(_ context.Context) bool { + exe, ok := resolvedExecutablePath() + if !ok { + return false + } + + return strings.Contains(exe, homebrewCaskroomPath) } -func homebrewHasCask(ctx context.Context, cask string) bool { - switch cask { - case homebrewCask, legacyHomebrewCask: - // allowed - default: +func hasLegacyHomebrewCask(_ context.Context) bool { + exe, ok := resolvedExecutablePath() + if !ok { return false } - out, err := exec.CommandContext(ctx, "brew", "list", "--cask", cask).CombinedOutput() //nolint:gosec // G204: cask is validated against known constants above + return strings.Contains(exe, legacyHomebrewCaskroomPath) +} + +func resolvedExecutablePath() (string, bool) { + exe, err := os.Executable() if err != nil { - return false + return "", false + } + + if resolved, resolveErr := filepath.EvalSymlinks(exe); resolveErr == nil { + exe = resolved } - return strings.TrimSpace(string(out)) != "" + + return filepath.ToSlash(exe), true } diff --git a/internal/commands/upgrade_test.go b/internal/commands/upgrade_test.go index 4ce9a70a..6a58f5a2 100644 --- a/internal/commands/upgrade_test.go +++ b/internal/commands/upgrade_test.go @@ -3,6 +3,7 @@ package commands import ( "bytes" "context" + "io" "testing" "github.com/spf13/cobra" @@ -13,7 +14,7 @@ import ( "github.com/basecamp/basecamp-cli/internal/version" ) -// stubUpgradeCheckers overrides version and Homebrew detection for tests. +// stubUpgradeCheckers overrides version and Homebrew helpers for tests. func stubUpgradeCheckers(t *testing.T, latestVersion string, isBrew bool, hasLegacyCask bool) { t.Helper() @@ -28,6 +29,10 @@ func stubUpgradeCheckers(t *testing.T, latestVersion string, isBrew bool, hasLeg origLegacy := legacyHomebrewCasker legacyHomebrewCasker = func(context.Context) bool { return hasLegacyCask } t.Cleanup(func() { legacyHomebrewCasker = origLegacy }) + + origUpgrader := homebrewUpgrader + homebrewUpgrader = func(context.Context, io.Writer, io.Writer) error { return nil } + t.Cleanup(func() { homebrewUpgrader = origUpgrader }) } // executeUpgradeCommand runs the upgrade command and returns the combined @@ -97,7 +102,7 @@ func TestUpgradeSuppressesOlderLatestRelease(t *testing.T) { version.Version = "0.4.1-0.20260313174735-243815fa23b2" t.Cleanup(func() { version.Version = orig }) - stubUpgradeCheckers(t, "0.4.0", false) + stubUpgradeCheckers(t, "0.4.0", false, false) cmdOut, err := executeUpgradeCommand(t, app) require.NoError(t, err) @@ -140,6 +145,22 @@ func TestUpgradeOutputGoesToWriter(t *testing.T) { assert.Contains(t, buf.String(), "already up to date") } +func TestUpgradePrefersRenamedHomebrewCaskOverLegacyMigration(t *testing.T) { + app, appBuf := setupPeopleTestApp(t) + + orig := version.Version + version.Version = "1.2.3" + t.Cleanup(func() { version.Version = orig }) + + stubUpgradeCheckers(t, "1.3.0", true, true) + + cmdOut, err := executeUpgradeCommand(t, app) + require.NoError(t, err) + assert.Contains(t, cmdOut, "Upgrading via Homebrew…") + assert.Contains(t, appBuf.String(), "upgraded") + assert.NotContains(t, appBuf.String(), "migration_required") +} + func TestUpgradeLegacyCaskMigrationInstructions(t *testing.T) { app, appBuf := setupPeopleTestApp(t) @@ -152,7 +173,7 @@ func TestUpgradeLegacyCaskMigrationInstructions(t *testing.T) { cmdOut, err := executeUpgradeCommand(t, app) require.NoError(t, err) assert.Contains(t, cmdOut, "The CLI cask has been renamed. To upgrade, run:") - assert.Contains(t, cmdOut, "brew uninstall --cask basecamp/tap/basecamp") - assert.Contains(t, cmdOut, "brew install --cask basecamp/tap/basecamp-cli") + assert.Contains(t, cmdOut, " brew uninstall --cask basecamp/tap/basecamp\n") + assert.Contains(t, cmdOut, " brew install --cask basecamp/tap/basecamp-cli\n") assert.Contains(t, appBuf.String(), "migration_required") } From 3ccfbd098c4ba1146a8b43b9e03aae23e5afaeb0 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 25 Mar 2026 08:56:17 -0400 Subject: [PATCH 03/10] Add Scoop upgrade and migration handling Handle the Scoop manifest rename in upgrade detection and messaging. - detect the renamed Scoop app via resolved executable path under /scoop/apps/basecamp-cli/ - detect the legacy Scoop app via resolved executable path under /scoop/apps/basecamp/ - fall back to `scoop list` checks for the renamed and legacy app names - upgrade renamed Scoop installs with `scoop update basecamp-cli` - show migration instructions for legacy `basecamp` Scoop installs - add regression coverage for renamed, legacy, and mixed-install Scoop cases --- internal/commands/upgrade.go | 93 +++++++++++++++++++++++++++++-- internal/commands/upgrade_test.go | 65 +++++++++++++++++---- 2 files changed, 144 insertions(+), 14 deletions(-) diff --git a/internal/commands/upgrade.go b/internal/commands/upgrade.go index d863105f..691d13ee 100644 --- a/internal/commands/upgrade.go +++ b/internal/commands/upgrade.go @@ -19,16 +19,23 @@ import ( const ( homebrewCask = "basecamp/tap/basecamp-cli" legacyHomebrewCask = "basecamp/tap/basecamp" - homebrewCaskroomPath = "/Caskroom/basecamp-cli/" - legacyHomebrewCaskroomPath = "/Caskroom/basecamp/" + homebrewCaskroomPath = "/caskroom/basecamp-cli/" + legacyHomebrewCaskroomPath = "/caskroom/basecamp/" + scoopApp = "basecamp-cli" + legacyScoopApp = "basecamp" + scoopAppPath = "/scoop/apps/basecamp-cli/" + legacyScoopAppPath = "/scoop/apps/basecamp/" ) -// versionChecker and Homebrew helpers abstract external checks for testability. +// versionChecker and package manager helpers abstract external checks for testability. var ( versionChecker = fetchLatestVersion homebrewChecker = isHomebrew legacyHomebrewCasker = hasLegacyHomebrewCask homebrewUpgrader = upgradeHomebrew + scoopChecker = isScoop + legacyScoopChecker = hasLegacyScoop + scoopUpgrader = upgradeScoop ) // NewUpgradeCmd creates the upgrade command. @@ -88,6 +95,17 @@ func runUpgrade(cmd *cobra.Command, args []string) error { ) } + if scoopChecker(ctx) { + fmt.Fprintln(w, "Upgrading via Scoop…") + if err := scoopUpgrader(ctx, w, cmd.ErrOrStderr()); err != nil { + return fmt.Errorf("scoop update failed for app %s: %w", scoopApp, err) + } + return app.OK( + map[string]string{"status": "upgraded", "from": current, "to": latest}, + output.WithSummary(fmt.Sprintf("Upgraded %s → %s", current, latest)), + ) + } + if legacyHomebrewCasker(ctx) { fmt.Fprintln(w) fmt.Fprintln(w, "The CLI cask has been renamed. To upgrade, run:") @@ -105,6 +123,23 @@ func runUpgrade(cmd *cobra.Command, args []string) error { ) } + if legacyScoopChecker(ctx) { + fmt.Fprintln(w) + fmt.Fprintln(w, "The CLI Scoop manifest has been renamed. To upgrade, run:") + fmt.Fprintf(w, " scoop uninstall %s\n", legacyScoopApp) + fmt.Fprintf(w, " scoop install %s\n", scoopApp) + return app.OK( + map[string]string{ + "status": "migration_required", + "from": current, + "to": latest, + "legacy_manifest": legacyScoopApp, + "replacement": scoopApp, + }, + output.WithSummary("Scoop manifest rename detected — manual migration required"), + ) + } + downloadURL := fmt.Sprintf("https://github.com/basecamp/basecamp-cli/releases/tag/v%s", latest) fmt.Fprintln(w) fmt.Fprintf(w, "Download the latest release from:\n") @@ -122,6 +157,13 @@ func upgradeHomebrew(ctx context.Context, stdout io.Writer, stderr io.Writer) er return upgrade.Run() } +func upgradeScoop(ctx context.Context, stdout io.Writer, stderr io.Writer) error { + upgrade := exec.CommandContext(ctx, "scoop", "update", scoopApp) + upgrade.Stdout = stdout + upgrade.Stderr = stderr + return upgrade.Run() +} + // isHomebrew returns true if the running CLI binary appears to come from the renamed Homebrew cask. func isHomebrew(_ context.Context) bool { exe, ok := resolvedExecutablePath() @@ -141,6 +183,26 @@ func hasLegacyHomebrewCask(_ context.Context) bool { return strings.Contains(exe, legacyHomebrewCaskroomPath) } +// isScoop returns true if the running CLI binary appears to come from the renamed Scoop app, +// or if Scoop reports the renamed app is installed. +func isScoop(ctx context.Context) bool { + exe, ok := resolvedExecutablePath() + if ok && strings.Contains(exe, scoopAppPath) { + return true + } + + return scoopHasApp(ctx, scoopApp) +} + +func hasLegacyScoop(ctx context.Context) bool { + exe, ok := resolvedExecutablePath() + if ok && strings.Contains(exe, legacyScoopAppPath) { + return true + } + + return scoopHasApp(ctx, legacyScoopApp) +} + func resolvedExecutablePath() (string, bool) { exe, err := os.Executable() if err != nil { @@ -151,5 +213,28 @@ func resolvedExecutablePath() (string, bool) { exe = resolved } - return filepath.ToSlash(exe), true + return strings.ToLower(filepath.ToSlash(exe)), true +} + +func scoopHasApp(ctx context.Context, app string) bool { + switch app { + case scoopApp, legacyScoopApp: + // allowed + default: + return false + } + + out, err := exec.CommandContext(ctx, "scoop", "list", app).CombinedOutput() //nolint:gosec // G204: app is validated against known constants above + if err != nil { + return false + } + + for _, line := range strings.Split(string(out), "\n") { + fields := strings.Fields(strings.TrimSpace(line)) + if len(fields) > 0 && fields[0] == app { + return true + } + } + + return false } diff --git a/internal/commands/upgrade_test.go b/internal/commands/upgrade_test.go index 6a58f5a2..460ee803 100644 --- a/internal/commands/upgrade_test.go +++ b/internal/commands/upgrade_test.go @@ -14,8 +14,8 @@ import ( "github.com/basecamp/basecamp-cli/internal/version" ) -// stubUpgradeCheckers overrides version and Homebrew helpers for tests. -func stubUpgradeCheckers(t *testing.T, latestVersion string, isBrew bool, hasLegacyCask bool) { +// stubUpgradeCheckers overrides version and package manager helpers for tests. +func stubUpgradeCheckers(t *testing.T, latestVersion string, isBrew bool, hasLegacyCask bool, isScoopInstall bool, hasLegacyScoopInstall bool) { t.Helper() origVC := versionChecker @@ -30,9 +30,21 @@ func stubUpgradeCheckers(t *testing.T, latestVersion string, isBrew bool, hasLeg legacyHomebrewCasker = func(context.Context) bool { return hasLegacyCask } t.Cleanup(func() { legacyHomebrewCasker = origLegacy }) - origUpgrader := homebrewUpgrader + origHU := homebrewUpgrader homebrewUpgrader = func(context.Context, io.Writer, io.Writer) error { return nil } - t.Cleanup(func() { homebrewUpgrader = origUpgrader }) + t.Cleanup(func() { homebrewUpgrader = origHU }) + + origSC := scoopChecker + scoopChecker = func(context.Context) bool { return isScoopInstall } + t.Cleanup(func() { scoopChecker = origSC }) + + origLegacyScoop := legacyScoopChecker + legacyScoopChecker = func(context.Context) bool { return hasLegacyScoopInstall } + t.Cleanup(func() { legacyScoopChecker = origLegacyScoop }) + + origSU := scoopUpgrader + scoopUpgrader = func(context.Context, io.Writer, io.Writer) error { return nil } + t.Cleanup(func() { scoopUpgrader = origSU }) } // executeUpgradeCommand runs the upgrade command and returns the combined @@ -72,7 +84,7 @@ func TestUpgradeAlreadyCurrent(t *testing.T) { version.Version = "1.2.3" t.Cleanup(func() { version.Version = orig }) - stubUpgradeCheckers(t, "1.2.3", false, false) + stubUpgradeCheckers(t, "1.2.3", false, false, false, false) cmdOut, err := executeUpgradeCommand(t, app) require.NoError(t, err) @@ -87,7 +99,7 @@ func TestUpgradeAvailable(t *testing.T) { version.Version = "1.2.3" t.Cleanup(func() { version.Version = orig }) - stubUpgradeCheckers(t, "1.3.0", false, false) + stubUpgradeCheckers(t, "1.3.0", false, false, false, false) cmdOut, err := executeUpgradeCommand(t, app) require.NoError(t, err) @@ -102,7 +114,7 @@ func TestUpgradeSuppressesOlderLatestRelease(t *testing.T) { version.Version = "0.4.1-0.20260313174735-243815fa23b2" t.Cleanup(func() { version.Version = orig }) - stubUpgradeCheckers(t, "0.4.0", false, false) + stubUpgradeCheckers(t, "0.4.0", false, false, false, false) cmdOut, err := executeUpgradeCommand(t, app) require.NoError(t, err) @@ -119,7 +131,7 @@ func TestUpgradeOutputGoesToWriter(t *testing.T) { version.Version = "1.0.0" t.Cleanup(func() { version.Version = orig }) - stubUpgradeCheckers(t, "1.0.0", false, false) + stubUpgradeCheckers(t, "1.0.0", false, false, false, false) cmd := NewUpgradeCmd() cmd.SetArgs(nil) @@ -152,7 +164,7 @@ func TestUpgradePrefersRenamedHomebrewCaskOverLegacyMigration(t *testing.T) { version.Version = "1.2.3" t.Cleanup(func() { version.Version = orig }) - stubUpgradeCheckers(t, "1.3.0", true, true) + stubUpgradeCheckers(t, "1.3.0", true, true, false, false) cmdOut, err := executeUpgradeCommand(t, app) require.NoError(t, err) @@ -168,7 +180,7 @@ func TestUpgradeLegacyCaskMigrationInstructions(t *testing.T) { version.Version = "1.2.3" t.Cleanup(func() { version.Version = orig }) - stubUpgradeCheckers(t, "1.3.0", false, true) + stubUpgradeCheckers(t, "1.3.0", false, true, false, false) cmdOut, err := executeUpgradeCommand(t, app) require.NoError(t, err) @@ -177,3 +189,36 @@ func TestUpgradeLegacyCaskMigrationInstructions(t *testing.T) { assert.Contains(t, cmdOut, " brew install --cask basecamp/tap/basecamp-cli\n") assert.Contains(t, appBuf.String(), "migration_required") } + +func TestUpgradePrefersRenamedScoopAppOverLegacyMigration(t *testing.T) { + app, appBuf := setupPeopleTestApp(t) + + orig := version.Version + version.Version = "1.2.3" + t.Cleanup(func() { version.Version = orig }) + + stubUpgradeCheckers(t, "1.3.0", false, false, true, true) + + cmdOut, err := executeUpgradeCommand(t, app) + require.NoError(t, err) + assert.Contains(t, cmdOut, "Upgrading via Scoop…") + assert.Contains(t, appBuf.String(), "upgraded") + assert.NotContains(t, appBuf.String(), "migration_required") +} + +func TestUpgradeLegacyScoopMigrationInstructions(t *testing.T) { + app, appBuf := setupPeopleTestApp(t) + + orig := version.Version + version.Version = "1.2.3" + t.Cleanup(func() { version.Version = orig }) + + stubUpgradeCheckers(t, "1.3.0", false, false, false, true) + + cmdOut, err := executeUpgradeCommand(t, app) + require.NoError(t, err) + assert.Contains(t, cmdOut, "The CLI Scoop manifest has been renamed. To upgrade, run:") + assert.Contains(t, cmdOut, " scoop uninstall basecamp\n") + assert.Contains(t, cmdOut, " scoop install basecamp-cli\n") + assert.Contains(t, appBuf.String(), "migration_required") +} From 94983bc66de352e9392c83910591f1f26235c60c Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 25 Mar 2026 09:15:49 -0400 Subject: [PATCH 04/10] Key Scoop upgrade detection to running binary provenance Mirror the Homebrew fix for Scoop: decide upgrade and migration behavior from the resolved executable path, not from whether a Scoop package is installed somewhere on the machine. Using `scoop list` as a fallback can misclassify mixed-install setups and upgrade or migrate a dormant Scoop install instead of the binary the user actually invoked. Keeping both Homebrew and Scoop keyed to binary provenance avoids those false positives. Add focused tests for renamed and legacy Scoop path detection. --- internal/commands/upgrade.go | 65 ++++++++++--------------------- internal/commands/upgrade_test.go | 24 ++++++++++++ 2 files changed, 45 insertions(+), 44 deletions(-) diff --git a/internal/commands/upgrade.go b/internal/commands/upgrade.go index 691d13ee..d4e97946 100644 --- a/internal/commands/upgrade.go +++ b/internal/commands/upgrade.go @@ -29,13 +29,14 @@ const ( // versionChecker and package manager helpers abstract external checks for testability. var ( - versionChecker = fetchLatestVersion - homebrewChecker = isHomebrew - legacyHomebrewCasker = hasLegacyHomebrewCask - homebrewUpgrader = upgradeHomebrew - scoopChecker = isScoop - legacyScoopChecker = hasLegacyScoop - scoopUpgrader = upgradeScoop + versionChecker = fetchLatestVersion + executablePathResolver = resolvedExecutablePath + homebrewChecker = isHomebrew + legacyHomebrewCasker = hasLegacyHomebrewCask + homebrewUpgrader = upgradeHomebrew + scoopChecker = isScoop + legacyScoopChecker = hasLegacyScoop + scoopUpgrader = upgradeScoop ) // NewUpgradeCmd creates the upgrade command. @@ -166,7 +167,7 @@ func upgradeScoop(ctx context.Context, stdout io.Writer, stderr io.Writer) error // isHomebrew returns true if the running CLI binary appears to come from the renamed Homebrew cask. func isHomebrew(_ context.Context) bool { - exe, ok := resolvedExecutablePath() + exe, ok := executablePathResolver() if !ok { return false } @@ -175,7 +176,7 @@ func isHomebrew(_ context.Context) bool { } func hasLegacyHomebrewCask(_ context.Context) bool { - exe, ok := resolvedExecutablePath() + exe, ok := executablePathResolver() if !ok { return false } @@ -183,24 +184,23 @@ func hasLegacyHomebrewCask(_ context.Context) bool { return strings.Contains(exe, legacyHomebrewCaskroomPath) } -// isScoop returns true if the running CLI binary appears to come from the renamed Scoop app, -// or if Scoop reports the renamed app is installed. -func isScoop(ctx context.Context) bool { - exe, ok := resolvedExecutablePath() - if ok && strings.Contains(exe, scoopAppPath) { - return true +// isScoop returns true if the running CLI binary appears to come from the renamed Scoop app. +func isScoop(_ context.Context) bool { + exe, ok := executablePathResolver() + if !ok { + return false } - return scoopHasApp(ctx, scoopApp) + return strings.Contains(exe, scoopAppPath) } -func hasLegacyScoop(ctx context.Context) bool { - exe, ok := resolvedExecutablePath() - if ok && strings.Contains(exe, legacyScoopAppPath) { - return true +func hasLegacyScoop(_ context.Context) bool { + exe, ok := executablePathResolver() + if !ok { + return false } - return scoopHasApp(ctx, legacyScoopApp) + return strings.Contains(exe, legacyScoopAppPath) } func resolvedExecutablePath() (string, bool) { @@ -215,26 +215,3 @@ func resolvedExecutablePath() (string, bool) { return strings.ToLower(filepath.ToSlash(exe)), true } - -func scoopHasApp(ctx context.Context, app string) bool { - switch app { - case scoopApp, legacyScoopApp: - // allowed - default: - return false - } - - out, err := exec.CommandContext(ctx, "scoop", "list", app).CombinedOutput() //nolint:gosec // G204: app is validated against known constants above - if err != nil { - return false - } - - for _, line := range strings.Split(string(out), "\n") { - fields := strings.Fields(strings.TrimSpace(line)) - if len(fields) > 0 && fields[0] == app { - return true - } - } - - return false -} diff --git a/internal/commands/upgrade_test.go b/internal/commands/upgrade_test.go index 460ee803..5acb3a15 100644 --- a/internal/commands/upgrade_test.go +++ b/internal/commands/upgrade_test.go @@ -14,6 +14,14 @@ import ( "github.com/basecamp/basecamp-cli/internal/version" ) +func stubExecutablePathResolver(t *testing.T, path string, ok bool) { + t.Helper() + + orig := executablePathResolver + executablePathResolver = func() (string, bool) { return path, ok } + t.Cleanup(func() { executablePathResolver = orig }) +} + // stubUpgradeCheckers overrides version and package manager helpers for tests. func stubUpgradeCheckers(t *testing.T, latestVersion string, isBrew bool, hasLegacyCask bool, isScoopInstall bool, hasLegacyScoopInstall bool) { t.Helper() @@ -222,3 +230,19 @@ func TestUpgradeLegacyScoopMigrationInstructions(t *testing.T) { assert.Contains(t, cmdOut, " scoop install basecamp-cli\n") assert.Contains(t, appBuf.String(), "migration_required") } + +func TestIsScoopUsesExecutablePathProvenance(t *testing.T) { + stubExecutablePathResolver(t, "/Users/alice/scoop/apps/basecamp-cli/current/basecamp.exe", true) + assert.True(t, isScoop(context.Background())) + + stubExecutablePathResolver(t, "/Users/alice/bin/basecamp", true) + assert.False(t, isScoop(context.Background())) +} + +func TestHasLegacyScoopUsesExecutablePathProvenance(t *testing.T) { + stubExecutablePathResolver(t, "/Users/alice/scoop/apps/basecamp/current/basecamp.exe", true) + assert.True(t, hasLegacyScoop(context.Background())) + + stubExecutablePathResolver(t, "/Users/alice/bin/basecamp", true) + assert.False(t, hasLegacyScoop(context.Background())) +} From 939cdc0150eda4b89eacf528ccf7abe8fa306653 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 25 Mar 2026 09:41:09 -0400 Subject: [PATCH 05/10] Handle Scoop shim detection in upgrade When the CLI is invoked via Scoop, the executable on PATH is usually the shim under /scoop/shims rather than the app path under /scoop/apps. Teach upgrade detection to treat the shim as Scoop provenance and resolve renamed vs legacy installs via `scoop prefix`. Also refactor the upgrade test stubs to use a named options struct so the mixed Homebrew/Scoop cases stay readable and harder to mis-wire. --- internal/commands/upgrade.go | 53 +++++++++++++++++++--- internal/commands/upgrade_test.go | 74 ++++++++++++++++++++++++------- 2 files changed, 105 insertions(+), 22 deletions(-) diff --git a/internal/commands/upgrade.go b/internal/commands/upgrade.go index d4e97946..1769b197 100644 --- a/internal/commands/upgrade.go +++ b/internal/commands/upgrade.go @@ -25,12 +25,15 @@ const ( legacyScoopApp = "basecamp" scoopAppPath = "/scoop/apps/basecamp-cli/" legacyScoopAppPath = "/scoop/apps/basecamp/" + scoopShimPath = "/scoop/shims/" + scoopCommandBaseName = "basecamp" ) // versionChecker and package manager helpers abstract external checks for testability. var ( versionChecker = fetchLatestVersion executablePathResolver = resolvedExecutablePath + scoopPrefixChecker = hasScoopPrefix homebrewChecker = isHomebrew legacyHomebrewCasker = hasLegacyHomebrewCask homebrewUpgrader = upgradeHomebrew @@ -185,22 +188,60 @@ func hasLegacyHomebrewCask(_ context.Context) bool { } // isScoop returns true if the running CLI binary appears to come from the renamed Scoop app. -func isScoop(_ context.Context) bool { +func isScoop(ctx context.Context) bool { + return detectScoopInstallSource(ctx) == scoopInstallSourceRenamed +} + +func hasLegacyScoop(ctx context.Context) bool { + return detectScoopInstallSource(ctx) == scoopInstallSourceLegacy +} + +type scoopInstallSource int + +const ( + scoopInstallSourceUnknown scoopInstallSource = iota + scoopInstallSourceRenamed + scoopInstallSourceLegacy +) + +func detectScoopInstallSource(ctx context.Context) scoopInstallSource { exe, ok := executablePathResolver() if !ok { + return scoopInstallSourceUnknown + } + + switch { + case strings.Contains(exe, scoopAppPath): + return scoopInstallSourceRenamed + case strings.Contains(exe, legacyScoopAppPath): + return scoopInstallSourceLegacy + case isScoopShimExecutable(exe) && scoopPrefixChecker(ctx, scoopApp): + return scoopInstallSourceRenamed + case isScoopShimExecutable(exe) && scoopPrefixChecker(ctx, legacyScoopApp): + return scoopInstallSourceLegacy + default: + return scoopInstallSourceUnknown + } +} + +func isScoopShimExecutable(exe string) bool { + if !strings.Contains(exe, scoopShimPath) { return false } - return strings.Contains(exe, scoopAppPath) + name := strings.TrimSuffix(filepath.Base(exe), filepath.Ext(exe)) + return name == scoopCommandBaseName } -func hasLegacyScoop(_ context.Context) bool { - exe, ok := executablePathResolver() - if !ok { +func hasScoopPrefix(ctx context.Context, app string) bool { + switch app { + case scoopApp, legacyScoopApp: + // allowed + default: return false } - return strings.Contains(exe, legacyScoopAppPath) + return exec.CommandContext(ctx, "scoop", "prefix", app).Run() == nil //nolint:gosec // G204: app is validated against known constants above } func resolvedExecutablePath() (string, bool) { diff --git a/internal/commands/upgrade_test.go b/internal/commands/upgrade_test.go index 5acb3a15..1bc3c1cc 100644 --- a/internal/commands/upgrade_test.go +++ b/internal/commands/upgrade_test.go @@ -22,36 +22,60 @@ func stubExecutablePathResolver(t *testing.T, path string, ok bool) { t.Cleanup(func() { executablePathResolver = orig }) } +func stubScoopPrefixChecker(t *testing.T, check func(context.Context, string) bool) { + t.Helper() + + orig := scoopPrefixChecker + scoopPrefixChecker = check + t.Cleanup(func() { scoopPrefixChecker = orig }) +} + +type upgradeCheckersStub struct { + latestVersion string + isBrew bool + hasLegacyCask bool + isScoop bool + hasLegacyScoop bool + homebrewUpgrade func(context.Context, io.Writer, io.Writer) error + scoopUpgrade func(context.Context, io.Writer, io.Writer) error +} + // stubUpgradeCheckers overrides version and package manager helpers for tests. -func stubUpgradeCheckers(t *testing.T, latestVersion string, isBrew bool, hasLegacyCask bool, isScoopInstall bool, hasLegacyScoopInstall bool) { +func stubUpgradeCheckers(t *testing.T, stub upgradeCheckersStub) { t.Helper() origVC := versionChecker - versionChecker = func() (string, error) { return latestVersion, nil } + versionChecker = func() (string, error) { return stub.latestVersion, nil } t.Cleanup(func() { versionChecker = origVC }) origHC := homebrewChecker - homebrewChecker = func(context.Context) bool { return isBrew } + homebrewChecker = func(context.Context) bool { return stub.isBrew } t.Cleanup(func() { homebrewChecker = origHC }) origLegacy := legacyHomebrewCasker - legacyHomebrewCasker = func(context.Context) bool { return hasLegacyCask } + legacyHomebrewCasker = func(context.Context) bool { return stub.hasLegacyCask } t.Cleanup(func() { legacyHomebrewCasker = origLegacy }) origHU := homebrewUpgrader - homebrewUpgrader = func(context.Context, io.Writer, io.Writer) error { return nil } + homebrewUpgrader = stub.homebrewUpgrade + if homebrewUpgrader == nil { + homebrewUpgrader = func(context.Context, io.Writer, io.Writer) error { return nil } + } t.Cleanup(func() { homebrewUpgrader = origHU }) origSC := scoopChecker - scoopChecker = func(context.Context) bool { return isScoopInstall } + scoopChecker = func(context.Context) bool { return stub.isScoop } t.Cleanup(func() { scoopChecker = origSC }) origLegacyScoop := legacyScoopChecker - legacyScoopChecker = func(context.Context) bool { return hasLegacyScoopInstall } + legacyScoopChecker = func(context.Context) bool { return stub.hasLegacyScoop } t.Cleanup(func() { legacyScoopChecker = origLegacyScoop }) origSU := scoopUpgrader - scoopUpgrader = func(context.Context, io.Writer, io.Writer) error { return nil } + scoopUpgrader = stub.scoopUpgrade + if scoopUpgrader == nil { + scoopUpgrader = func(context.Context, io.Writer, io.Writer) error { return nil } + } t.Cleanup(func() { scoopUpgrader = origSU }) } @@ -92,7 +116,7 @@ func TestUpgradeAlreadyCurrent(t *testing.T) { version.Version = "1.2.3" t.Cleanup(func() { version.Version = orig }) - stubUpgradeCheckers(t, "1.2.3", false, false, false, false) + stubUpgradeCheckers(t, upgradeCheckersStub{latestVersion: "1.2.3"}) cmdOut, err := executeUpgradeCommand(t, app) require.NoError(t, err) @@ -107,7 +131,7 @@ func TestUpgradeAvailable(t *testing.T) { version.Version = "1.2.3" t.Cleanup(func() { version.Version = orig }) - stubUpgradeCheckers(t, "1.3.0", false, false, false, false) + stubUpgradeCheckers(t, upgradeCheckersStub{latestVersion: "1.3.0"}) cmdOut, err := executeUpgradeCommand(t, app) require.NoError(t, err) @@ -122,7 +146,7 @@ func TestUpgradeSuppressesOlderLatestRelease(t *testing.T) { version.Version = "0.4.1-0.20260313174735-243815fa23b2" t.Cleanup(func() { version.Version = orig }) - stubUpgradeCheckers(t, "0.4.0", false, false, false, false) + stubUpgradeCheckers(t, upgradeCheckersStub{latestVersion: "0.4.0"}) cmdOut, err := executeUpgradeCommand(t, app) require.NoError(t, err) @@ -139,7 +163,7 @@ func TestUpgradeOutputGoesToWriter(t *testing.T) { version.Version = "1.0.0" t.Cleanup(func() { version.Version = orig }) - stubUpgradeCheckers(t, "1.0.0", false, false, false, false) + stubUpgradeCheckers(t, upgradeCheckersStub{latestVersion: "1.0.0"}) cmd := NewUpgradeCmd() cmd.SetArgs(nil) @@ -172,7 +196,7 @@ func TestUpgradePrefersRenamedHomebrewCaskOverLegacyMigration(t *testing.T) { version.Version = "1.2.3" t.Cleanup(func() { version.Version = orig }) - stubUpgradeCheckers(t, "1.3.0", true, true, false, false) + stubUpgradeCheckers(t, upgradeCheckersStub{latestVersion: "1.3.0", isBrew: true, hasLegacyCask: true}) cmdOut, err := executeUpgradeCommand(t, app) require.NoError(t, err) @@ -188,7 +212,7 @@ func TestUpgradeLegacyCaskMigrationInstructions(t *testing.T) { version.Version = "1.2.3" t.Cleanup(func() { version.Version = orig }) - stubUpgradeCheckers(t, "1.3.0", false, true, false, false) + stubUpgradeCheckers(t, upgradeCheckersStub{latestVersion: "1.3.0", hasLegacyCask: true}) cmdOut, err := executeUpgradeCommand(t, app) require.NoError(t, err) @@ -205,7 +229,7 @@ func TestUpgradePrefersRenamedScoopAppOverLegacyMigration(t *testing.T) { version.Version = "1.2.3" t.Cleanup(func() { version.Version = orig }) - stubUpgradeCheckers(t, "1.3.0", false, false, true, true) + stubUpgradeCheckers(t, upgradeCheckersStub{latestVersion: "1.3.0", isScoop: true, hasLegacyScoop: true}) cmdOut, err := executeUpgradeCommand(t, app) require.NoError(t, err) @@ -221,7 +245,7 @@ func TestUpgradeLegacyScoopMigrationInstructions(t *testing.T) { version.Version = "1.2.3" t.Cleanup(func() { version.Version = orig }) - stubUpgradeCheckers(t, "1.3.0", false, false, false, true) + stubUpgradeCheckers(t, upgradeCheckersStub{latestVersion: "1.3.0", hasLegacyScoop: true}) cmdOut, err := executeUpgradeCommand(t, app) require.NoError(t, err) @@ -239,6 +263,15 @@ func TestIsScoopUsesExecutablePathProvenance(t *testing.T) { assert.False(t, isScoop(context.Background())) } +func TestIsScoopDetectsRenamedShimViaPrefix(t *testing.T) { + stubExecutablePathResolver(t, "/Users/alice/scoop/shims/basecamp.exe", true) + stubScoopPrefixChecker(t, func(_ context.Context, app string) bool { + return app == scoopApp + }) + + assert.True(t, isScoop(context.Background())) +} + func TestHasLegacyScoopUsesExecutablePathProvenance(t *testing.T) { stubExecutablePathResolver(t, "/Users/alice/scoop/apps/basecamp/current/basecamp.exe", true) assert.True(t, hasLegacyScoop(context.Background())) @@ -246,3 +279,12 @@ func TestHasLegacyScoopUsesExecutablePathProvenance(t *testing.T) { stubExecutablePathResolver(t, "/Users/alice/bin/basecamp", true) assert.False(t, hasLegacyScoop(context.Background())) } + +func TestHasLegacyScoopDetectsLegacyShimViaPrefix(t *testing.T) { + stubExecutablePathResolver(t, "/Users/alice/scoop/shims/basecamp.exe", true) + stubScoopPrefixChecker(t, func(_ context.Context, app string) bool { + return app == legacyScoopApp + }) + + assert.True(t, hasLegacyScoop(context.Background())) +} From c6dcf026b16004195a81065db0afbd1744b8462d Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 25 Mar 2026 10:09:51 -0400 Subject: [PATCH 06/10] Handle global Scoop upgrade detection --- internal/commands/upgrade.go | 55 +++++++++++++++++++++-------- internal/commands/upgrade_test.go | 58 +++++++++++++++++++++++++++++-- 2 files changed, 97 insertions(+), 16 deletions(-) diff --git a/internal/commands/upgrade.go b/internal/commands/upgrade.go index 1769b197..cfb9764b 100644 --- a/internal/commands/upgrade.go +++ b/internal/commands/upgrade.go @@ -26,20 +26,22 @@ const ( scoopAppPath = "/scoop/apps/basecamp-cli/" legacyScoopAppPath = "/scoop/apps/basecamp/" scoopShimPath = "/scoop/shims/" + globalScoopRootPath = "/programdata/scoop/" scoopCommandBaseName = "basecamp" ) // versionChecker and package manager helpers abstract external checks for testability. var ( - versionChecker = fetchLatestVersion - executablePathResolver = resolvedExecutablePath - scoopPrefixChecker = hasScoopPrefix - homebrewChecker = isHomebrew - legacyHomebrewCasker = hasLegacyHomebrewCask - homebrewUpgrader = upgradeHomebrew - scoopChecker = isScoop - legacyScoopChecker = hasLegacyScoop - scoopUpgrader = upgradeScoop + versionChecker = fetchLatestVersion + executablePathResolver = resolvedExecutablePath + scoopPrefixChecker = hasScoopPrefix + homebrewChecker = isHomebrew + legacyHomebrewCasker = hasLegacyHomebrewCask + homebrewUpgrader = upgradeHomebrew + scoopChecker = isScoop + legacyScoopChecker = hasLegacyScoop + scoopGlobalScopeChecker = isGlobalScoopInstall + scoopUpgrader = upgradeScoop ) // NewUpgradeCmd creates the upgrade command. @@ -100,8 +102,9 @@ func runUpgrade(cmd *cobra.Command, args []string) error { } if scoopChecker(ctx) { + global := scoopGlobalScopeChecker(ctx) fmt.Fprintln(w, "Upgrading via Scoop…") - if err := scoopUpgrader(ctx, w, cmd.ErrOrStderr()); err != nil { + if err := scoopUpgrader(ctx, global, w, cmd.ErrOrStderr()); err != nil { return fmt.Errorf("scoop update failed for app %s: %w", scoopApp, err) } return app.OK( @@ -128,10 +131,11 @@ func runUpgrade(cmd *cobra.Command, args []string) error { } if legacyScoopChecker(ctx) { + global := scoopGlobalScopeChecker(ctx) fmt.Fprintln(w) fmt.Fprintln(w, "The CLI Scoop manifest has been renamed. To upgrade, run:") - fmt.Fprintf(w, " scoop uninstall %s\n", legacyScoopApp) - fmt.Fprintf(w, " scoop install %s\n", scoopApp) + fmt.Fprintf(w, " scoop uninstall%s %s\n", scoopGlobalFlag(global), legacyScoopApp) + fmt.Fprintf(w, " scoop install%s %s\n", scoopGlobalFlag(global), scoopApp) return app.OK( map[string]string{ "status": "migration_required", @@ -161,8 +165,14 @@ func upgradeHomebrew(ctx context.Context, stdout io.Writer, stderr io.Writer) er return upgrade.Run() } -func upgradeScoop(ctx context.Context, stdout io.Writer, stderr io.Writer) error { - upgrade := exec.CommandContext(ctx, "scoop", "update", scoopApp) +func upgradeScoop(ctx context.Context, global bool, stdout io.Writer, stderr io.Writer) error { + args := []string{"update"} + if global { + args = append(args, "-g") + } + args = append(args, scoopApp) + + upgrade := exec.CommandContext(ctx, "scoop", args...) upgrade.Stdout = stdout upgrade.Stderr = stderr return upgrade.Run() @@ -244,6 +254,23 @@ func hasScoopPrefix(ctx context.Context, app string) bool { return exec.CommandContext(ctx, "scoop", "prefix", app).Run() == nil //nolint:gosec // G204: app is validated against known constants above } +func isGlobalScoopInstall(_ context.Context) bool { + exe, ok := executablePathResolver() + if !ok { + return false + } + + return strings.Contains(exe, globalScoopRootPath) +} + +func scoopGlobalFlag(global bool) string { + if global { + return " -g" + } + + return "" +} + func resolvedExecutablePath() (string, bool) { exe, err := os.Executable() if err != nil { diff --git a/internal/commands/upgrade_test.go b/internal/commands/upgrade_test.go index 1bc3c1cc..cdccccdc 100644 --- a/internal/commands/upgrade_test.go +++ b/internal/commands/upgrade_test.go @@ -36,8 +36,9 @@ type upgradeCheckersStub struct { hasLegacyCask bool isScoop bool hasLegacyScoop bool + isGlobalScoop bool homebrewUpgrade func(context.Context, io.Writer, io.Writer) error - scoopUpgrade func(context.Context, io.Writer, io.Writer) error + scoopUpgrade func(context.Context, bool, io.Writer, io.Writer) error } // stubUpgradeCheckers overrides version and package manager helpers for tests. @@ -71,10 +72,14 @@ func stubUpgradeCheckers(t *testing.T, stub upgradeCheckersStub) { legacyScoopChecker = func(context.Context) bool { return stub.hasLegacyScoop } t.Cleanup(func() { legacyScoopChecker = origLegacyScoop }) + origGlobalScoop := scoopGlobalScopeChecker + scoopGlobalScopeChecker = func(context.Context) bool { return stub.isGlobalScoop } + t.Cleanup(func() { scoopGlobalScopeChecker = origGlobalScoop }) + origSU := scoopUpgrader scoopUpgrader = stub.scoopUpgrade if scoopUpgrader == nil { - scoopUpgrader = func(context.Context, io.Writer, io.Writer) error { return nil } + scoopUpgrader = func(context.Context, bool, io.Writer, io.Writer) error { return nil } } t.Cleanup(func() { scoopUpgrader = origSU }) } @@ -255,6 +260,47 @@ func TestUpgradeLegacyScoopMigrationInstructions(t *testing.T) { assert.Contains(t, appBuf.String(), "migration_required") } +func TestUpgradeGlobalScoopUsesGlobalUpdate(t *testing.T) { + app, appBuf := setupPeopleTestApp(t) + + orig := version.Version + version.Version = "1.2.3" + t.Cleanup(func() { version.Version = orig }) + + var gotGlobal bool + stubUpgradeCheckers(t, upgradeCheckersStub{ + latestVersion: "1.3.0", + isScoop: true, + isGlobalScoop: true, + scoopUpgrade: func(_ context.Context, global bool, _ io.Writer, _ io.Writer) error { + gotGlobal = global + return nil + }, + }) + + _, err := executeUpgradeCommand(t, app) + require.NoError(t, err) + assert.True(t, gotGlobal) + assert.Contains(t, appBuf.String(), "upgraded") +} + +func TestUpgradeGlobalLegacyScoopMigrationInstructions(t *testing.T) { + app, appBuf := setupPeopleTestApp(t) + + orig := version.Version + version.Version = "1.2.3" + t.Cleanup(func() { version.Version = orig }) + + stubUpgradeCheckers(t, upgradeCheckersStub{latestVersion: "1.3.0", hasLegacyScoop: true, isGlobalScoop: true}) + + cmdOut, err := executeUpgradeCommand(t, app) + require.NoError(t, err) + assert.Contains(t, cmdOut, "The CLI Scoop manifest has been renamed. To upgrade, run:") + assert.Contains(t, cmdOut, " scoop uninstall -g basecamp\n") + assert.Contains(t, cmdOut, " scoop install -g basecamp-cli\n") + assert.Contains(t, appBuf.String(), "migration_required") +} + func TestIsScoopUsesExecutablePathProvenance(t *testing.T) { stubExecutablePathResolver(t, "/Users/alice/scoop/apps/basecamp-cli/current/basecamp.exe", true) assert.True(t, isScoop(context.Background())) @@ -288,3 +334,11 @@ func TestHasLegacyScoopDetectsLegacyShimViaPrefix(t *testing.T) { assert.True(t, hasLegacyScoop(context.Background())) } + +func TestIsGlobalScoopInstallUsesExecutablePathProvenance(t *testing.T) { + stubExecutablePathResolver(t, "c:/programdata/scoop/apps/basecamp-cli/current/basecamp.exe", true) + assert.True(t, isGlobalScoopInstall(context.Background())) + + stubExecutablePathResolver(t, "/Users/alice/scoop/apps/basecamp-cli/current/basecamp.exe", true) + assert.False(t, isGlobalScoopInstall(context.Background())) +} From d775547908a688a25f8cd3dfa26fe069dcacad88 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 25 Mar 2026 10:22:22 -0400 Subject: [PATCH 07/10] Address upgrade review feedback --- internal/commands/upgrade.go | 16 +++++++++++++++- internal/commands/upgrade_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/internal/commands/upgrade.go b/internal/commands/upgrade.go index cfb9764b..941bdf3d 100644 --- a/internal/commands/upgrade.go +++ b/internal/commands/upgrade.go @@ -260,7 +260,21 @@ func isGlobalScoopInstall(_ context.Context) bool { return false } - return strings.Contains(exe, globalScoopRootPath) + return hasPathPrefix(exe, globalScoopRootPath) +} + +func hasPathPrefix(path string, prefix string) bool { + prefix = strings.TrimSuffix(prefix, "/") + path = stripWindowsVolume(path) + return path == prefix || strings.HasPrefix(path, prefix+"/") +} + +func stripWindowsVolume(path string) string { + if len(path) >= 2 && path[1] == ':' { + return path[2:] + } + + return path } func scoopGlobalFlag(global bool) string { diff --git a/internal/commands/upgrade_test.go b/internal/commands/upgrade_test.go index cdccccdc..cce73202 100644 --- a/internal/commands/upgrade_test.go +++ b/internal/commands/upgrade_test.go @@ -301,6 +301,32 @@ func TestUpgradeGlobalLegacyScoopMigrationInstructions(t *testing.T) { assert.Contains(t, appBuf.String(), "migration_required") } +func TestIsHomebrewUsesExecutablePathProvenance(t *testing.T) { + stubExecutablePathResolver(t, "/opt/homebrew/caskroom/basecamp-cli/1.2.3/basecamp", true) + assert.True(t, isHomebrew(context.Background())) + + stubExecutablePathResolver(t, "/usr/local/bin/basecamp", true) + assert.False(t, isHomebrew(context.Background())) +} + +func TestIsHomebrewReturnsFalseWhenExecutablePathUnavailable(t *testing.T) { + stubExecutablePathResolver(t, "", false) + assert.False(t, isHomebrew(context.Background())) +} + +func TestHasLegacyHomebrewCaskUsesExecutablePathProvenance(t *testing.T) { + stubExecutablePathResolver(t, "/opt/homebrew/caskroom/basecamp/1.2.3/basecamp", true) + assert.True(t, hasLegacyHomebrewCask(context.Background())) + + stubExecutablePathResolver(t, "/usr/local/bin/basecamp", true) + assert.False(t, hasLegacyHomebrewCask(context.Background())) +} + +func TestHasLegacyHomebrewCaskReturnsFalseWhenExecutablePathUnavailable(t *testing.T) { + stubExecutablePathResolver(t, "", false) + assert.False(t, hasLegacyHomebrewCask(context.Background())) +} + func TestIsScoopUsesExecutablePathProvenance(t *testing.T) { stubExecutablePathResolver(t, "/Users/alice/scoop/apps/basecamp-cli/current/basecamp.exe", true) assert.True(t, isScoop(context.Background())) @@ -339,6 +365,9 @@ func TestIsGlobalScoopInstallUsesExecutablePathProvenance(t *testing.T) { stubExecutablePathResolver(t, "c:/programdata/scoop/apps/basecamp-cli/current/basecamp.exe", true) assert.True(t, isGlobalScoopInstall(context.Background())) + stubExecutablePathResolver(t, "/users/alice/programdata/scoop/apps/basecamp-cli/current/basecamp.exe", true) + assert.False(t, isGlobalScoopInstall(context.Background())) + stubExecutablePathResolver(t, "/Users/alice/scoop/apps/basecamp-cli/current/basecamp.exe", true) assert.False(t, isGlobalScoopInstall(context.Background())) } From 6de0c4e47bed108a9da70eb1588977a3667fd5e6 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 25 Mar 2026 10:44:29 -0400 Subject: [PATCH 08/10] Keep Scoop shim upgrade detection scope-aware --- internal/commands/upgrade.go | 42 ++++++++++++++++------ internal/commands/upgrade_test.go | 58 ++++++++++++++++++++++++++----- 2 files changed, 82 insertions(+), 18 deletions(-) diff --git a/internal/commands/upgrade.go b/internal/commands/upgrade.go index 941bdf3d..39c305fe 100644 --- a/internal/commands/upgrade.go +++ b/internal/commands/upgrade.go @@ -34,7 +34,7 @@ const ( var ( versionChecker = fetchLatestVersion executablePathResolver = resolvedExecutablePath - scoopPrefixChecker = hasScoopPrefix + scoopPrefixResolver = resolveScoopPrefix homebrewChecker = isHomebrew legacyHomebrewCasker = hasLegacyHomebrewCask homebrewUpgrader = upgradeHomebrew @@ -225,13 +225,17 @@ func detectScoopInstallSource(ctx context.Context) scoopInstallSource { return scoopInstallSourceRenamed case strings.Contains(exe, legacyScoopAppPath): return scoopInstallSourceLegacy - case isScoopShimExecutable(exe) && scoopPrefixChecker(ctx, scoopApp): - return scoopInstallSourceRenamed - case isScoopShimExecutable(exe) && scoopPrefixChecker(ctx, legacyScoopApp): - return scoopInstallSourceLegacy - default: - return scoopInstallSourceUnknown + case isScoopShimExecutable(exe): + global := hasPathPrefix(exe, globalScoopRootPath) + if prefix, ok := scoopPrefixResolver(ctx, scoopApp); ok && scoopPrefixMatchesShimScope(prefix, global) { + return scoopInstallSourceRenamed + } + if prefix, ok := scoopPrefixResolver(ctx, legacyScoopApp); ok && scoopPrefixMatchesShimScope(prefix, global) { + return scoopInstallSourceLegacy + } } + + return scoopInstallSourceUnknown } func isScoopShimExecutable(exe string) bool { @@ -243,15 +247,33 @@ func isScoopShimExecutable(exe string) bool { return name == scoopCommandBaseName } -func hasScoopPrefix(ctx context.Context, app string) bool { +func resolveScoopPrefix(ctx context.Context, app string) (string, bool) { switch app { case scoopApp, legacyScoopApp: // allowed default: - return false + return "", false + } + + out, err := exec.CommandContext(ctx, "scoop", "prefix", app).Output() //nolint:gosec // G204: app is validated against known constants above + if err != nil { + return "", false + } + + prefix := strings.ToLower(filepath.ToSlash(strings.TrimSpace(string(out)))) + if prefix == "" { + return "", false + } + + return prefix, true +} + +func scoopPrefixMatchesShimScope(prefix string, global bool) bool { + if global { + return hasPathPrefix(prefix, globalScoopRootPath) } - return exec.CommandContext(ctx, "scoop", "prefix", app).Run() == nil //nolint:gosec // G204: app is validated against known constants above + return !hasPathPrefix(prefix, globalScoopRootPath) } func isGlobalScoopInstall(_ context.Context) bool { diff --git a/internal/commands/upgrade_test.go b/internal/commands/upgrade_test.go index cce73202..77ae85cc 100644 --- a/internal/commands/upgrade_test.go +++ b/internal/commands/upgrade_test.go @@ -22,12 +22,12 @@ func stubExecutablePathResolver(t *testing.T, path string, ok bool) { t.Cleanup(func() { executablePathResolver = orig }) } -func stubScoopPrefixChecker(t *testing.T, check func(context.Context, string) bool) { +func stubScoopPrefixResolver(t *testing.T, resolve func(context.Context, string) (string, bool)) { t.Helper() - orig := scoopPrefixChecker - scoopPrefixChecker = check - t.Cleanup(func() { scoopPrefixChecker = orig }) + orig := scoopPrefixResolver + scoopPrefixResolver = resolve + t.Cleanup(func() { scoopPrefixResolver = orig }) } type upgradeCheckersStub struct { @@ -337,8 +337,12 @@ func TestIsScoopUsesExecutablePathProvenance(t *testing.T) { func TestIsScoopDetectsRenamedShimViaPrefix(t *testing.T) { stubExecutablePathResolver(t, "/Users/alice/scoop/shims/basecamp.exe", true) - stubScoopPrefixChecker(t, func(_ context.Context, app string) bool { - return app == scoopApp + stubScoopPrefixResolver(t, func(_ context.Context, app string) (string, bool) { + if app == scoopApp { + return "/users/alice/scoop/apps/basecamp-cli/current", true + } + + return "", false }) assert.True(t, isScoop(context.Background())) @@ -354,10 +358,48 @@ func TestHasLegacyScoopUsesExecutablePathProvenance(t *testing.T) { func TestHasLegacyScoopDetectsLegacyShimViaPrefix(t *testing.T) { stubExecutablePathResolver(t, "/Users/alice/scoop/shims/basecamp.exe", true) - stubScoopPrefixChecker(t, func(_ context.Context, app string) bool { - return app == legacyScoopApp + stubScoopPrefixResolver(t, func(_ context.Context, app string) (string, bool) { + if app == legacyScoopApp { + return "/users/alice/scoop/apps/basecamp/current", true + } + + return "", false + }) + + assert.True(t, hasLegacyScoop(context.Background())) +} + +func TestIsScoopShimIgnoresOppositeScopePrefix(t *testing.T) { + stubExecutablePathResolver(t, "c:/programdata/scoop/shims/basecamp.exe", true) + stubScoopPrefixResolver(t, func(_ context.Context, app string) (string, bool) { + switch app { + case scoopApp: + return "/users/alice/scoop/apps/basecamp-cli/current", true + case legacyScoopApp: + return "/programdata/scoop/apps/basecamp/current", true + default: + return "", false + } }) + assert.False(t, isScoop(context.Background())) + assert.True(t, hasLegacyScoop(context.Background())) +} + +func TestHasLegacyScoopShimIgnoresOppositeScopePrefix(t *testing.T) { + stubExecutablePathResolver(t, "/users/alice/scoop/shims/basecamp.exe", true) + stubScoopPrefixResolver(t, func(_ context.Context, app string) (string, bool) { + switch app { + case scoopApp: + return "/programdata/scoop/apps/basecamp-cli/current", true + case legacyScoopApp: + return "/users/alice/scoop/apps/basecamp/current", true + default: + return "", false + } + }) + + assert.False(t, isScoop(context.Background())) assert.True(t, hasLegacyScoop(context.Background())) } From ea73699ac7c9d39780d7a3999a26610a41ec1c29 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 25 Mar 2026 10:47:45 -0400 Subject: [PATCH 09/10] Fix lint in Scoop scope detection --- internal/commands/upgrade.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/commands/upgrade.go b/internal/commands/upgrade.go index 39c305fe..6c5abd37 100644 --- a/internal/commands/upgrade.go +++ b/internal/commands/upgrade.go @@ -226,7 +226,7 @@ func detectScoopInstallSource(ctx context.Context) scoopInstallSource { case strings.Contains(exe, legacyScoopAppPath): return scoopInstallSourceLegacy case isScoopShimExecutable(exe): - global := hasPathPrefix(exe, globalScoopRootPath) + global := hasGlobalScoopPathPrefix(exe) if prefix, ok := scoopPrefixResolver(ctx, scoopApp); ok && scoopPrefixMatchesShimScope(prefix, global) { return scoopInstallSourceRenamed } @@ -270,10 +270,10 @@ func resolveScoopPrefix(ctx context.Context, app string) (string, bool) { func scoopPrefixMatchesShimScope(prefix string, global bool) bool { if global { - return hasPathPrefix(prefix, globalScoopRootPath) + return hasGlobalScoopPathPrefix(prefix) } - return !hasPathPrefix(prefix, globalScoopRootPath) + return !hasGlobalScoopPathPrefix(prefix) } func isGlobalScoopInstall(_ context.Context) bool { @@ -282,11 +282,11 @@ func isGlobalScoopInstall(_ context.Context) bool { return false } - return hasPathPrefix(exe, globalScoopRootPath) + return hasGlobalScoopPathPrefix(exe) } -func hasPathPrefix(path string, prefix string) bool { - prefix = strings.TrimSuffix(prefix, "/") +func hasGlobalScoopPathPrefix(path string) bool { + prefix := strings.TrimSuffix(globalScoopRootPath, "/") path = stripWindowsVolume(path) return path == prefix || strings.HasPrefix(path, prefix+"/") } From be0d3aee85b9b17478ff88f2dd295b1c992b3540 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 25 Mar 2026 11:04:30 -0400 Subject: [PATCH 10/10] Clarify Scoop prefix scope handling --- internal/commands/upgrade.go | 3 +++ internal/commands/upgrade_test.go | 13 +++++++++++++ 2 files changed, 16 insertions(+) diff --git a/internal/commands/upgrade.go b/internal/commands/upgrade.go index 6c5abd37..f164ade0 100644 --- a/internal/commands/upgrade.go +++ b/internal/commands/upgrade.go @@ -247,6 +247,9 @@ func isScoopShimExecutable(exe string) bool { return name == scoopCommandBaseName } +// resolveScoopPrefix returns the installed app root reported by `scoop prefix`. +// Scoop already checks local installs first, then global installs, so there is +// no separate scope flag to thread through here. func resolveScoopPrefix(ctx context.Context, app string) (string, bool) { switch app { case scoopApp, legacyScoopApp: diff --git a/internal/commands/upgrade_test.go b/internal/commands/upgrade_test.go index 77ae85cc..79cda77c 100644 --- a/internal/commands/upgrade_test.go +++ b/internal/commands/upgrade_test.go @@ -348,6 +348,19 @@ func TestIsScoopDetectsRenamedShimViaPrefix(t *testing.T) { assert.True(t, isScoop(context.Background())) } +func TestIsScoopDetectsGlobalRenamedShimViaPrefix(t *testing.T) { + stubExecutablePathResolver(t, "c:/programdata/scoop/shims/basecamp.exe", true) + stubScoopPrefixResolver(t, func(_ context.Context, app string) (string, bool) { + if app == scoopApp { + return "/programdata/scoop/apps/basecamp-cli/current", true + } + + return "", false + }) + + assert.True(t, isScoop(context.Background())) +} + func TestHasLegacyScoopUsesExecutablePathProvenance(t *testing.T) { stubExecutablePathResolver(t, "/Users/alice/scoop/apps/basecamp/current/basecamp.exe", true) assert.True(t, hasLegacyScoop(context.Background()))