Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,14 @@ nfpms:
dst: /etc/bash_completion.d/platform
- src: completion/zsh/_platform
dst: /usr/local/share/zsh/site-functions/_platform
# Marks the install as managed by a system package manager, so the CLI
# suppresses its update message. See internal/install.go. The dst must stay
# at <prefix>/share/<slug>/install-source, where <prefix> is the parent of
# the binary's dir (default bindir /usr/bin -> prefix /usr); changing the
# nfpm bindir would silently break suppression.
- src: packaging/install-source
dst: /usr/share/platformsh-cli/install-source
file_info: { mode: 0644 }
apk:
signature:
key_file: "{{ with .Env.RSA_SIGNING_KEY_FILE }}{{ . }}{{ end }}"
Expand Down Expand Up @@ -309,6 +317,14 @@ nfpms:
dst: /etc/bash_completion.d/upsun
- src: completion/zsh/_upsun
dst: /usr/local/share/zsh/site-functions/_upsun
# Marks the install as managed by a system package manager, so the CLI
# suppresses its update message. See internal/install.go. The dst must stay
# at <prefix>/share/<slug>/install-source, where <prefix> is the parent of
# the binary's dir (default bindir /usr/bin -> prefix /usr); changing the
# nfpm bindir would silently break suppression.
- src: packaging/install-source
dst: /usr/share/upsun/install-source
file_info: { mode: 0644 }
apk:
signature:
key_file: "{{ with .Env.RSA_SIGNING_KEY_FILE }}{{ . }}{{ end }}"
Expand Down
10 changes: 9 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,4 +163,12 @@ PHP version is still injected via ldflags:

### Update Checks

The CLI checks for updates from GitHub releases (when Wrapper.GitHubRepo is set in config). This runs in a background goroutine and prints a message after command execution.
The CLI checks for updates from GitHub releases (when Wrapper.GitHubRepo is set in config). The network check runs in a background goroutine and caches the latest known version in `state.json`; the notice is shown before the command on a later run (see `internal/update.go`).

Install-method detection (`internal/install.go`) tailors or suppresses the notice:
- System package managers (apt, yum/dnf, apk) are detected via a marker file installed by the nfpm packages (`packaging/install-source` → `/usr/share/<slug>/install-source`). The notice is suppressed because the OS handles updates.
- Homebrew, Scoop, npm, and the bash installer get a tailored upgrade command, built from config fields (`Wrapper.HomebrewTap`, `Wrapper.NpmPackage`, `Wrapper.InstallerURL`, `Application.Executable`).
- The notice is throttled to once a week (`LastNotified` in state) and only shown in an interactive terminal.
- `<PREFIX>INSTALL_METHOD` forces the method; `<PREFIX>UPDATES_CHECK=0` disables checks entirely.

See `docs/design/update-message-install-detection.md` for the full design, including the planned Phase 2 (opt-in self-update).
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,24 @@ sudo apt-get update && sudo apt-get upgrade upsun-cli
sudo dnf upgrade -y upsun-cli
```

### Update notifications

When a newer release is available, the CLI prints a short notice with the
upgrade command for the tool you installed it with. It detects the install
method automatically and, for system package managers (apt, yum/dnf, apk), stays
silent because those update the CLI through the OS.

The notice is shown at most once a week and only in an interactive terminal. To
control it:

- `UPSUN_CLI_UPDATES_CHECK=0` disables update checks and notices entirely.
- `UPSUN_CLI_INSTALL_METHOD=<method>` forces the detected install method, where
`<method>` is one of `homebrew`, `scoop`, `npm`, `package`, or `script`. Use
`package` to silence the notice when the OS manages updates.

(For the `platform` command, the prefix is `PLATFORMSH_CLI_` instead of
`UPSUN_CLI_`.)

## Building

Build a single binary:
Expand Down
66 changes: 36 additions & 30 deletions commands/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,7 @@ func Execute(cnf *config.Config) error {
}

func newRootCommand(cnf *config.Config, assets *vendorization.VendorAssets) *cobra.Command {
var (
updateMessageChan = make(chan *internal.ReleaseInfo, 1)
versionCommand = newVersionCommand(cnf)
)
versionCommand := newVersionCommand(cnf)
cmd := &cobra.Command{
Use: cnf.Application.Executable,
Short: cnf.Application.Name,
Expand Down Expand Up @@ -73,9 +70,16 @@ func newRootCommand(cnf *config.Config, assets *vendorization.VendorAssets) *cob
os.Exit(0)
}
if cnf.Wrapper.GitHubRepo != "" {
// Show any update found by a previous run, before the command's
// output. The check itself runs in the background (below) and
// caches its result for the next invocation.
if rel := internal.PendingNotification(cnf, config.Version); rel != nil {
printUpdateMessage(cmd.ErrOrStderr(), rel, cnf)
internal.MarkNotified(cnf)
}
go func() {
rel, _ := internal.CheckForUpdate(cnf, config.Version)
updateMessageChan <- rel
//nolint:errcheck // a failed update check should not affect the command
internal.CheckForUpdate(cnf, config.Version)
}()
}
if alt.ShouldUpdate(cnf) {
Expand All @@ -94,11 +98,6 @@ func newRootCommand(cnf *config.Config, assets *vendorization.VendorAssets) *cob
},
PersistentPostRun: func(cmd *cobra.Command, _ []string) {
checkShellConfigLeftovers(cmd.ErrOrStderr(), cnf)
select {
case rel := <-updateMessageChan:
printUpdateMessage(cmd.ErrOrStderr(), rel, cnf)
default:
}
},
}

Expand Down Expand Up @@ -204,19 +203,14 @@ func printUpdateMessage(w io.Writer, newRelease *internal.ReleaseInfo, cnf *conf
return
}

fmt.Fprintf(w, "\n\n%s %s → %s\n",
fmt.Fprintf(w, "\n%s %s → %s\n",
color.YellowString(fmt.Sprintf("A new release of the %s is available:", cnf.Application.Name)),
color.CyanString(config.Version),
color.CyanString(newRelease.Version),
)
Comment on lines +206 to 210

executable, err := os.Executable()
if err == nil && cnf.Wrapper.HomebrewTap != "" && isUnderHomebrew(executable) {
fmt.Fprintf(
w,
"To upgrade, run: brew update && brew upgrade %s\n",
color.YellowString(cnf.Wrapper.HomebrewTap),
)
if cmd := upgradeCommand(cnf); cmd != "" {
fmt.Fprintf(w, "To upgrade, run: %s\n", color.YellowString(cmd))
} else if cnf.Wrapper.GitHubRepo != "" {
fmt.Fprintf(
w,
Expand All @@ -228,19 +222,31 @@ func printUpdateMessage(w io.Writer, newRelease *internal.ReleaseInfo, cnf *conf
fmt.Fprintf(w, "%s\n\n", color.YellowString(newRelease.URL))
}

func isUnderHomebrew(binary string) bool {
brewExe, err := exec.LookPath("brew")
if err != nil {
return false
}
// upgradeCommand returns the upgrade command for the detected install method, or
// an empty string when there is no tailored command (the caller then falls back
// to a generic link).
func upgradeCommand(cnf *config.Config) string {
return upgradeCommandFor(cnf, internal.DetectInstallMethod(cnf))
}

brewPrefixBytes, err := exec.Command(brewExe, "--prefix").Output()
if err != nil {
return false
func upgradeCommandFor(cnf *config.Config, method internal.InstallMethod) string {
switch method {
case internal.InstallHomebrew:
if cnf.Wrapper.HomebrewTap != "" {
return "brew update && brew upgrade " + cnf.Wrapper.HomebrewTap
}
case internal.InstallScoop:
return "scoop update " + cnf.Application.Executable
case internal.InstallNpm:
if cnf.Wrapper.NpmPackage != "" {
return "npm install -g " + cnf.Wrapper.NpmPackage + "@latest"
}
case internal.InstallScript:
if cnf.Wrapper.InstallerURL != "" {
return "curl -fsSL " + cnf.Wrapper.InstallerURL + " | bash"
}
Comment on lines +244 to +247
}

brewBinPrefix := filepath.Join(strings.TrimSpace(string(brewPrefixBytes)), "bin") + string(filepath.Separator)
return strings.HasPrefix(binary, brewBinPrefix)
return ""
}

func debugLogf(format string, v ...any) {
Expand Down
42 changes: 42 additions & 0 deletions commands/root_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package commands

import (
"testing"

"github.com/stretchr/testify/assert"

"github.com/upsun/cli/internal"
)

func TestUpgradeCommandFor(t *testing.T) {
cnf := testConfig()
cnf.Wrapper.HomebrewTap = "upsun/tap/upsun-cli"
cnf.Wrapper.NpmPackage = "upsun"
cnf.Wrapper.InstallerURL = "https://example.com/installer.sh"
cnf.Application.Executable = "upsun"

cases := []struct {
method internal.InstallMethod
want string
}{
{internal.InstallHomebrew, "brew update && brew upgrade upsun/tap/upsun-cli"},
{internal.InstallScoop, "scoop update upsun"},
{internal.InstallNpm, "npm install -g upsun@latest"},
{internal.InstallScript, "curl -fsSL https://example.com/installer.sh | bash"},
{internal.InstallPackage, ""}, // suppressed; no tailored command
{internal.InstallUnknown, ""}, // falls back to the generic link
}
for _, c := range cases {
t.Run(string(c.method), func(t *testing.T) {
assert.Equal(t, c.want, upgradeCommandFor(cnf, c.method))
})
}
}

func TestUpgradeCommandForMissingConfigFallsBack(t *testing.T) {
cnf := testConfig() // no Wrapper.* fields set
// Homebrew/npm/script require their config field; without it, fall back (empty).
assert.Empty(t, upgradeCommandFor(cnf, internal.InstallHomebrew))
assert.Empty(t, upgradeCommandFor(cnf, internal.InstallNpm))
assert.Empty(t, upgradeCommandFor(cnf, internal.InstallScript))
}
Loading