Skip to content
8 changes: 4 additions & 4 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ release:

**macOS:**
```sh
brew install --cask basecamp/tap/basecamp
brew install --cask basecamp/tap/basecamp-cli
basecamp auth login
```

Expand Down Expand Up @@ -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
Expand All @@ -184,7 +184,7 @@ homebrew_casks:
skip_upload: auto

scoops:
- name: basecamp
- name: basecamp-cli
repository:
owner: basecamp
name: homebrew-tap
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):**
Expand All @@ -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:**
Expand Down
8 changes: 4 additions & 4 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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`) |
Expand Down
4 changes: 2 additions & 2 deletions install.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
249 changes: 231 additions & 18 deletions internal/commands/upgrade.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ package commands
import (
"context"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"

"github.com/spf13/cobra"
Expand All @@ -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.
Expand Down Expand Up @@ -68,18 +92,62 @@ 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},
output.WithSummary(fmt.Sprintf("Upgraded %s → %s", current, latest)),
)
}

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")
Expand All @@ -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
}
Loading
Loading