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..f164ade0 100644 --- a/internal/commands/upgrade.go +++ b/internal/commands/upgrade.go @@ -3,8 +3,10 @@ package commands import ( "context" "fmt" + "io" "os" "os/exec" + "path/filepath" "strings" "github.com/spf13/cobra" @@ -14,10 +16,32 @@ 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" + homebrewCaskroomPath = "/caskroom/basecamp-cli/" + legacyHomebrewCaskroomPath = "/caskroom/basecamp/" + scoopApp = "basecamp-cli" + legacyScoopApp = "basecamp" + 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 - homebrewChecker = isHomebrew + versionChecker = fetchLatestVersion + executablePathResolver = resolvedExecutablePath + scoopPrefixResolver = resolveScoopPrefix + homebrewChecker = isHomebrew + legacyHomebrewCasker = hasLegacyHomebrewCask + homebrewUpgrader = upgradeHomebrew + scoopChecker = isScoop + legacyScoopChecker = hasLegacyScoop + scoopGlobalScopeChecker = isGlobalScoopInstall + scoopUpgrader = upgradeScoop ) // NewUpgradeCmd creates the upgrade command. @@ -68,11 +92,8 @@ func runUpgrade(cmd *cobra.Command, args []string) error { ctx := cmd.Context() if homebrewChecker(ctx) { fmt.Fprintln(w, "Upgrading via Homebrew…") - upgrade := exec.CommandContext(ctx, "brew", "upgrade", "basecamp") - upgrade.Stdout = w - upgrade.Stderr = cmd.ErrOrStderr() - if err := upgrade.Run(); err != nil { - return fmt.Errorf("brew upgrade failed: %w", err) + 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}, @@ -80,6 +101,53 @@ func runUpgrade(cmd *cobra.Command, args []string) error { ) } + if scoopChecker(ctx) { + global := scoopGlobalScopeChecker(ctx) + fmt.Fprintln(w, "Upgrading via Scoop…") + 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( + 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:") + 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 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 %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", + "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") @@ -90,22 +158,167 @@ func runUpgrade(cmd *cobra.Command, args []string) error { ) } -// isHomebrew returns true if the binary appears to be installed via Homebrew. -func isHomebrew(ctx context.Context) bool { - exe, err := os.Executable() - if err != nil { +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 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() +} + +// isHomebrew returns true if the running CLI binary appears to come from the renamed Homebrew cask. +func isHomebrew(_ context.Context) bool { + exe, ok := executablePathResolver() + if !ok { + return false + } + + return strings.Contains(exe, homebrewCaskroomPath) +} + +func hasLegacyHomebrewCask(_ context.Context) bool { + exe, ok := executablePathResolver() + if !ok { return false } - // Check common Homebrew prefix paths - if strings.Contains(exe, "/Cellar/") || strings.Contains(exe, "/homebrew/") { - return true + return strings.Contains(exe, legacyHomebrewCaskroomPath) +} + +// isScoop returns true if the running CLI binary appears to come from the renamed Scoop app. +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 } - // Check if brew knows about us - out, err := exec.CommandContext(ctx, "brew", "list", "basecamp").CombinedOutput() + switch { + case strings.Contains(exe, scoopAppPath): + return scoopInstallSourceRenamed + case strings.Contains(exe, legacyScoopAppPath): + return scoopInstallSourceLegacy + case isScoopShimExecutable(exe): + global := hasGlobalScoopPathPrefix(exe) + 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 { + if !strings.Contains(exe, scoopShimPath) { + return false + } + + name := strings.TrimSuffix(filepath.Base(exe), filepath.Ext(exe)) + 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: + // allowed + default: + 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 hasGlobalScoopPathPrefix(prefix) + } + + return !hasGlobalScoopPathPrefix(prefix) +} + +func isGlobalScoopInstall(_ context.Context) bool { + exe, ok := executablePathResolver() + if !ok { return false } - return strings.TrimSpace(string(out)) != "" + + return hasGlobalScoopPathPrefix(exe) +} + +func hasGlobalScoopPathPrefix(path string) bool { + prefix := strings.TrimSuffix(globalScoopRootPath, "/") + 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 { + if global { + return " -g" + } + + return "" +} + +func resolvedExecutablePath() (string, bool) { + exe, err := os.Executable() + if err != nil { + return "", false + } + + if resolved, resolveErr := filepath.EvalSymlinks(exe); resolveErr == nil { + exe = resolved + } + + return strings.ToLower(filepath.ToSlash(exe)), true } diff --git a/internal/commands/upgrade_test.go b/internal/commands/upgrade_test.go index 388bd2a3..79cda77c 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,17 +14,74 @@ import ( "github.com/basecamp/basecamp-cli/internal/version" ) -// stubUpgradeCheckers overrides versionChecker and homebrewChecker for tests. -func stubUpgradeCheckers(t *testing.T, latestVersion string, isBrew bool) { +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 }) +} + +func stubScoopPrefixResolver(t *testing.T, resolve func(context.Context, string) (string, bool)) { + t.Helper() + + orig := scoopPrefixResolver + scoopPrefixResolver = resolve + t.Cleanup(func() { scoopPrefixResolver = orig }) +} + +type upgradeCheckersStub struct { + latestVersion string + isBrew bool + hasLegacyCask bool + isScoop bool + hasLegacyScoop bool + isGlobalScoop bool + homebrewUpgrade 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. +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 stub.hasLegacyCask } + t.Cleanup(func() { legacyHomebrewCasker = origLegacy }) + + origHU := homebrewUpgrader + 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 stub.isScoop } + t.Cleanup(func() { scoopChecker = origSC }) + + origLegacyScoop := legacyScoopChecker + 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, bool, io.Writer, io.Writer) error { return nil } + } + t.Cleanup(func() { scoopUpgrader = origSU }) } // executeUpgradeCommand runs the upgrade command and returns the combined @@ -63,7 +121,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, upgradeCheckersStub{latestVersion: "1.2.3"}) cmdOut, err := executeUpgradeCommand(t, app) require.NoError(t, err) @@ -78,7 +136,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, upgradeCheckersStub{latestVersion: "1.3.0"}) cmdOut, err := executeUpgradeCommand(t, app) require.NoError(t, err) @@ -93,7 +151,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, upgradeCheckersStub{latestVersion: "0.4.0"}) cmdOut, err := executeUpgradeCommand(t, app) require.NoError(t, err) @@ -110,7 +168,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, upgradeCheckersStub{latestVersion: "1.0.0"}) cmd := NewUpgradeCmd() cmd.SetArgs(nil) @@ -135,3 +193,236 @@ 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 TestUpgradePrefersRenamedHomebrewCaskOverLegacyMigration(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", isBrew: true, hasLegacyCask: 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) + + orig := version.Version + version.Version = "1.2.3" + t.Cleanup(func() { version.Version = orig }) + + stubUpgradeCheckers(t, upgradeCheckersStub{latestVersion: "1.3.0", hasLegacyCask: 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\n") + 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, upgradeCheckersStub{latestVersion: "1.3.0", isScoop: true, hasLegacyScoop: 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, upgradeCheckersStub{latestVersion: "1.3.0", hasLegacyScoop: 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") +} + +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 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())) + + stubExecutablePathResolver(t, "/Users/alice/bin/basecamp", true) + assert.False(t, isScoop(context.Background())) +} + +func TestIsScoopDetectsRenamedShimViaPrefix(t *testing.T) { + stubExecutablePathResolver(t, "/Users/alice/scoop/shims/basecamp.exe", true) + 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())) +} + +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())) + + 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) + 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())) +} + +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())) +}