diff --git a/cmd/root.go b/cmd/root.go index 144d4b9c..e3f7de23 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -3,6 +3,8 @@ package cmd import ( "context" "errors" + "fmt" + "net/http" "os" "os/signal" "path" @@ -23,6 +25,7 @@ import ( "github.com/shopware/shopware-cli/internal/system" "github.com/shopware/shopware-cli/internal/tracking" "github.com/shopware/shopware-cli/internal/tui" + "github.com/shopware/shopware-cli/internal/update" "github.com/shopware/shopware-cli/logging" ) @@ -55,6 +58,19 @@ func run(ctx context.Context) int { accountApi.SetUserAgent("shopware-cli/" + version) rootCmd.SetArgs(args) + // Check for update in the background + updateCtx, updateCancel := context.WithTimeout(ctx, 300*time.Millisecond) + defer updateCancel() + updateChan := make(chan *update.ReleaseInfo, 1) + + go func() { + releaseInfo, err := checkForUpdate(updateCtx, args) + if err != nil && !errors.Is(err, update.ErrNoUpdateAvailable) { + logging.FromContext(ctx).Debugf("checking for shopware cli update failed: %v", err) + } + updateChan <- releaseInfo + }() + start := time.Now() err := rootCmd.ExecuteContext(ctx) @@ -93,6 +109,18 @@ func run(ctx context.Context) int { return 1 } + // Wait for the update check to finish and print a message to stderr if an update is available + newRelease := <-updateChan + if newRelease != nil { + binaryPath, err := os.Executable() + if err != nil { + logging.FromContext(ctx).Debugf("could not determine binary path: %v", err) + } else if update.InstallationContext(binaryPath) == "brew" && newRelease.IsRecent() { + return 0 // do not notify Homebrew users before the version bump had a chance to get merged into homebrew-core + } + fmt.Fprintln(os.Stderr, update.RenderUpdateNotification(newRelease.Version, version)) + } + return 0 } @@ -158,6 +186,14 @@ func commandNameFromBinaryPath(binaryPath string) string { return binaryName } +// checkForUpdate returns the latest release info if an update is available. +func checkForUpdate(ctx context.Context, args []string) (*update.ReleaseInfo, error) { + if !update.ShouldCheckForUpdate(version, args) { + return nil, update.ErrNoUpdateAvailable + } + return update.CheckForUpdate(ctx, version, &http.Client{Timeout: 5 * time.Second}) +} + func init() { rootCmd.SilenceErrors = true @@ -167,6 +203,7 @@ func init() { rootCmd.PersistentFlags().Bool("verbose", false, "show debug output") rootCmd.PersistentFlags().BoolP("no-interaction", "n", false, "do not ask any interactive questions") + rootCmd.PersistentFlags().Bool("no-update-hint", false, "do not show update notifications") project.Register(rootCmd) extension.Register(rootCmd) diff --git a/internal/update/update.go b/internal/update/update.go new file mode 100644 index 00000000..02635ccc --- /dev/null +++ b/internal/update/update.go @@ -0,0 +1,307 @@ +package update + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strings" + "time" + + "charm.land/lipgloss/v2" + "github.com/shyim/go-version" + + "github.com/shopware/shopware-cli/internal/system" + "github.com/shopware/shopware-cli/internal/tui" +) + +// This regex matches git describe suffixes like "-123-gabcdef12". +var gitDescribeSuffixRE = regexp.MustCompile(`-\d+-g[a-f0-9]{7,40}$`) + +const ( + updateCheckInterval = 24 * time.Hour + + // This is the primary source of truth for the latest release information, as it is maintained and updated with each new release. + latestReleaseURL = "https://shopware.github.io/shopware-cli/version.json" + noUpdateNotificationEnv = "SHOPWARE_CLI_NO_UPDATE_NOTIFICATION" +) + +var ErrNoUpdateAvailable = errors.New("no update available") +var ErrNoCacheFile = errors.New("no update cache file") + +type ReleaseInfo struct { + Version string `json:"version"` + PublishedAt time.Time `json:"published_at"` + FetchedAt time.Time `json:"fetched_at"` +} + +// IsRecent checks if the release was published within the last 24 hours. +func (r ReleaseInfo) IsRecent() bool { + return !r.PublishedAt.IsZero() && time.Since(r.PublishedAt) < 24*time.Hour +} + +// CheckForUpdate checks if a newer version is available, if the last check is more than 24 hours ago, and returns the fetched release information if so. +func CheckForUpdate(ctx context.Context, buildVersion string, client *http.Client) (*ReleaseInfo, error) { + // Load cached release info. + cachedReleaseInfo, err := LoadReleaseInfoFromCache() + if err != nil && !errors.Is(err, ErrNoCacheFile) { + return nil, err + } + // Early return if latest release info was fetched within the given releaseFetchInterval. + if cachedReleaseInfo != nil { + lastCheck := cachedReleaseInfo.FetchedAt + if lastCheck.IsZero() { + // Backward compatibility for old cache entries. + lastCheck = cachedReleaseInfo.PublishedAt + } + + if !lastCheck.IsZero() && time.Since(lastCheck) < updateCheckInterval { + return nil, ErrNoUpdateAvailable + } + } + + // Fetch latest release info. + latestReleaseInfo, err := fetchLatestReleaseInfoFromGitHubPages(ctx, client) + if latestReleaseInfo == nil || err != nil { + return nil, err + } + + // Save timestamp + fetched release info to cache. + latestReleaseInfo.FetchedAt = time.Now() + err = SaveReleaseInfoToCache(latestReleaseInfo) + if err != nil { + return nil, err + } + + // Compare latest version with the build version; return the release info if the installed version is older. + if versionGreaterThan(latestReleaseInfo.Version, buildVersion) { + return latestReleaseInfo, nil + } + + return nil, ErrNoUpdateAvailable +} + +func RenderUpdateNotification(latestVersion string, buildVersion string) string { + warnBoldStyle := lipgloss.NewStyle().Bold(true).Foreground(tui.WarnColor) + boldStyle := lipgloss.NewStyle().Bold(true).Foreground(tui.TextColor) + + firstLine := strings.Join([]string{ + warnBoldStyle.Render("⁺₊⋆"), + boldStyle.Render("Update available!"), + boldStyle.Render(buildVersion), + boldStyle.Render("→"), + boldStyle.Render(latestVersion), + warnBoldStyle.Render("⋆₊⁺"), + }, " ") + + secondLine := lipgloss.NewStyle().Foreground(tui.TextColor).Render(getUpdateMethod()) + notificationContent := firstLine + "\n " + secondLine + + renderedUpdateNotification := lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(tui.BorderColor). + Padding(0, 1). + Render(notificationContent) + + return renderedUpdateNotification +} + +// ShouldCheckForUpdate decides whether the CLI checks for updates based on user preferences and execution context. +func ShouldCheckForUpdate(version string, args []string) bool { + if len(args) > 0 { + for _, arg := range args { + if arg == "--no-update-hint" || arg == "-n" { + return false + } + } + } + + if os.Getenv(noUpdateNotificationEnv) == "1" || os.Getenv(noUpdateNotificationEnv) == "true" { + return false + } + + if version == "dev" { + return false + } + + if IsCI() { + return false + } + + if IsGitHubActions() { + return false + } + + return true +} + +func LoadReleaseInfoFromCache() (*ReleaseInfo, error) { + cacheFilePath := getUpdateCheckCacheFilePath() + + if _, err := os.Stat(cacheFilePath); os.IsNotExist(err) { + return nil, ErrNoCacheFile + } + + content, err := os.ReadFile(cacheFilePath) + if err != nil { + return nil, err + } + + var info ReleaseInfo + err = json.Unmarshal(content, &info) + if err != nil { + return nil, err + } + + return &info, nil +} + +func SaveReleaseInfoToCache(info *ReleaseInfo) error { + cacheFilePath := getUpdateCheckCacheFilePath() + + content, err := json.Marshal(info) + if err != nil { + return err + } + + cacheDir := filepath.Dir(cacheFilePath) + if err := os.MkdirAll(cacheDir, 0o750); err != nil { + return err + } + + err = os.WriteFile(cacheFilePath, content, 0o644) + if err != nil { + return err + } + + return nil +} + +// IsCI determines if the current execution context is within a known CI/CD system. +// This is based on https://github.com/watson/ci-info/blob/HEAD/index.js. +func IsCI() bool { + return os.Getenv("CI") != "" || // GitHub Actions, Travis CI, CircleCI, Cirrus CI, GitLab CI, AppVeyor, CodeShip, dsari + os.Getenv("BUILD_NUMBER") != "" || // Jenkins, TeamCity + os.Getenv("RUN_ID") != "" // TaskCluster, dsari +} + +// IsGitHubActions determines if the current execution context is within GitHub Actions. +// GitHub Actions sets the GITHUB_ACTIONS environment variable to "true" for all steps. +// See https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables. +func IsGitHubActions() bool { + return os.Getenv("GITHUB_ACTIONS") == "true" +} + +func getUpdateMethod() string { + binaryPath, err := os.Executable() + if err != nil { + return "Download the latest version from https://github.com/shopware/shopware-cli/releases" + } + + switch InstallationContext(binaryPath) { + case "brew": + return "Update via `brew update && brew upgrade shopware-cli`" + case "apt": + return "Update via `sudo apt update && sudo apt upgrade shopware-cli`" + default: + return "Download the latest version from https://github.com/shopware/shopware-cli/releases" + } +} + +// fetchLatestReleaseInfoFromGitHubPages fetches the latest release information from the version.json file hosted on GitHub Pages. +func fetchLatestReleaseInfoFromGitHubPages(ctx context.Context, client *http.Client) (*ReleaseInfo, error) { + req, err := http.NewRequestWithContext(ctx, "GET", latestReleaseURL, nil) + if err != nil { + return nil, err + } + res, err := client.Do(req) + if err != nil { + return nil, err + } + + defer func() { + _, _ = io.Copy(io.Discard, res.Body) + _ = res.Body.Close() + }() + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected HTTP %d", res.StatusCode) + } + dec := json.NewDecoder(res.Body) + + var latestRelease ReleaseInfo + if err := dec.Decode(&latestRelease); err != nil { + return nil, err + } + + latestRelease.FetchedAt = time.Now() + + return &latestRelease, nil +} + +func versionGreaterThan(v, w string) bool { + w = gitDescribeSuffixRE.ReplaceAllString(w, "") + + vv, ve := version.NewVersion(v) + vw, we := version.NewVersion(w) + + return ve == nil && we == nil && vv.GreaterThan(vw) +} + +func getUpdateCheckCacheFilePath() string { + return filepath.Join(system.GetShopwareCliCacheDir(), "update-check-info.json") +} + +// InstallationContext reports the install/update channel for a binary path. +func InstallationContext(binaryPath string) string { + if binaryPath != "" && IsUnderHomebrew(binaryPath) { + return "brew" + } + + if runtime.GOOS == "linux" && isUnderApt() { + return "apt" + } + + return "other" +} + +// IsUnderHomebrew reports whether the binary resides in the active Homebrew prefix. +func IsUnderHomebrew(binaryPath string) bool { + brewExe, err := lookPath("brew") + if err != nil { + return false + } + + brewPrefixBytes, err := exec.CommandContext(context.Background(), brewExe, "--prefix").Output() + if err != nil { + return false + } + + brewBinPrefix := filepath.Join(strings.TrimSpace(string(brewPrefixBytes)), "bin") + string(filepath.Separator) + + return strings.HasPrefix(binaryPath, brewBinPrefix) +} + +func isUnderApt() bool { + if _, err := os.Stat("/etc/debian_version"); err == nil { + return true + } + + _, err := lookPath("apt") + return err == nil +} + +// lookPath allows safe execution of the LookPath function, handling the ErrDot case. +func lookPath(file string) (string, error) { + path, err := exec.LookPath(file) + if errors.Is(err, exec.ErrDot) { + return path, nil + } + return path, err +} diff --git a/internal/update/update_test.go b/internal/update/update_test.go new file mode 100644 index 00000000..ecc816f4 --- /dev/null +++ b/internal/update/update_test.go @@ -0,0 +1,354 @@ +package update + +import ( + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type roundTripperFunc func(*http.Request) (*http.Response, error) + +func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func newVersionResponseClient(latestVersion string, requestCount *int) *http.Client { + return &http.Client{ + Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + *requestCount++ + + body := fmt.Sprintf(`{"version":"%s"}`, latestVersion) + + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + }, nil + }), + } +} + +func TestCheckForUpdate(t *testing.T) { + scenarios := []struct { + name string + currentVersion string + latestVersion string + expectsResult bool + }{ + { + name: "latest is newer", + currentVersion: "v0.0.1", + latestVersion: "v1.0.0", + expectsResult: true, + }, + { + name: "current is prerelease", + currentVersion: "v1.0.0-rc.1", + latestVersion: "v1.0.0", + expectsResult: true, + }, + { + name: "current is built from source", + currentVersion: "v1.2.3-123-gdeadbeef", + latestVersion: "v1.2.3", + expectsResult: false, + }, + { + name: "current is built from source after a prerelease", + currentVersion: "v1.2.3-rc.1-123-gdeadbeef", + latestVersion: "v1.2.3", + expectsResult: true, + }, + { + name: "latest is newer than source build", + currentVersion: "v1.2.3-123-gdeadbeef", + latestVersion: "v1.2.4", + expectsResult: true, + }, + { + name: "latest is current", + currentVersion: "v1.0.0", + latestVersion: "v1.0.0", + expectsResult: false, + }, + { + name: "latest is older", + currentVersion: "v0.10.0-rc.1", + latestVersion: "v0.9.0", + expectsResult: false, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + t.Setenv("SHOPWARE_CLI_CACHE_DIR", t.TempDir()) + + requestCount := 0 + client := newVersionResponseClient(s.latestVersion, &requestCount) + + rel, err := CheckForUpdate(t.Context(), s.currentVersion, client) + assert.Equal(t, 1, requestCount) + + if !s.expectsResult { + require.ErrorIs(t, err, ErrNoUpdateAvailable) + assert.Nil(t, rel) + return + } + + require.NoError(t, err) + require.NotNil(t, rel) + assert.Equal(t, s.latestVersion, rel.Version) + }) + } +} + +func TestCheckForUpdateSkipsNetworkWhenCacheIsRecent(t *testing.T) { + t.Setenv("SHOPWARE_CLI_CACHE_DIR", t.TempDir()) + + err := SaveReleaseInfoToCache(&ReleaseInfo{ + Version: "v9.9.9", + FetchedAt: time.Now().Add(-(updateCheckInterval / 2)), + }) + require.NoError(t, err) + + requestCount := 0 + client := newVersionResponseClient("v9.9.9", &requestCount) + + rel, checkErr := CheckForUpdate(t.Context(), "v1.0.0", client) + require.ErrorIs(t, checkErr, ErrNoUpdateAvailable) + assert.Nil(t, rel) + assert.Equal(t, 0, requestCount) +} + +func TestSaveAndLoadUpdateCheckFromCache(t *testing.T) { + t.Setenv("SHOPWARE_CLI_CACHE_DIR", t.TempDir()) + + expected := &ReleaseInfo{ + Version: "v1.2.3", + PublishedAt: time.Date(2026, 7, 22, 12, 0, 0, 0, time.UTC), + FetchedAt: time.Date(2026, 7, 23, 12, 0, 0, 0, time.UTC), + } + + err := SaveReleaseInfoToCache(expected) + require.NoError(t, err) + + cacheFilePath := filepath.Join(os.Getenv("SHOPWARE_CLI_CACHE_DIR"), "update-check-info.json") + _, statErr := os.Stat(cacheFilePath) + require.NoError(t, statErr) + + actual, err := LoadReleaseInfoFromCache() + require.NoError(t, err) + require.NotNil(t, actual) + assert.Equal(t, expected.Version, actual.Version) + assert.True(t, expected.PublishedAt.Equal(actual.PublishedAt)) + assert.True(t, expected.FetchedAt.Equal(actual.FetchedAt)) +} + +func TestLoadUpdateCheckFromCacheWhenMissing(t *testing.T) { + t.Setenv("SHOPWARE_CLI_CACHE_DIR", t.TempDir()) + + actual, err := LoadReleaseInfoFromCache() + require.ErrorIs(t, err, ErrNoCacheFile) + assert.Nil(t, actual) +} + +func TestShouldCheckForUpdate(t *testing.T) { + tests := []struct { + name string + version string + env map[string]string + expected bool + }{ + { + name: "disabled via legacy env var", + version: "v1.0.0", + env: map[string]string{ + "SHOPWARE_CLI_NO_UPDATE_NOTIFICATION": "1", + }, + expected: false, + }, + { + name: "disabled via true env value", + version: "v1.0.0", + env: map[string]string{ + "SHOPWARE_CLI_NO_UPDATE_NOTIFICATION": "true", + }, + expected: false, + }, + { + name: "disabled on dev version", + version: "dev", + expected: false, + }, + { + name: "disabled in generic ci", + version: "v1.0.0", + env: map[string]string{ + "CI": "1", + }, + expected: false, + }, + { + name: "disabled in build-number ci", + version: "v1.0.0", + env: map[string]string{ + "BUILD_NUMBER": "123", + }, + expected: false, + }, + { + name: "disabled in run-id ci", + version: "v1.0.0", + env: map[string]string{ + "RUN_ID": "123", + }, + expected: false, + }, + { + name: "disabled in github actions", + version: "v1.0.0", + env: map[string]string{ + "GITHUB_ACTIONS": "true", + }, + expected: false, + }, + { + name: "enabled on regular local run", + version: "v1.0.0", + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("SHOPWARE_CLI_CACHE_DIR", t.TempDir()) + t.Setenv("CI", "") + t.Setenv("BUILD_NUMBER", "") + t.Setenv("RUN_ID", "") + t.Setenv("GITHUB_ACTIONS", "") + t.Setenv("SHOPWARE_CLI_NO_UPDATE_NOTIFICATION", "") + t.Setenv("SHOPWARE_CLI_DISABLE_VERSION_CHECK", "") + + for k, v := range tt.env { + t.Setenv(k, v) + } + + assert.Equal(t, tt.expected, ShouldCheckForUpdate(tt.version, []string{})) + }) + } +} + +func TestInstallationContextDetectsHomebrew(t *testing.T) { + workspace := t.TempDir() + brewPrefix := filepath.Join(workspace, "homebrew") + brewBinDir := filepath.Join(brewPrefix, "bin") + require.NoError(t, os.MkdirAll(brewBinDir, 0o755)) + + brewScriptDir := filepath.Join(workspace, "tools") + require.NoError(t, os.MkdirAll(brewScriptDir, 0o755)) + brewScriptPath := filepath.Join(brewScriptDir, "brew") + require.NoError(t, os.WriteFile(brewScriptPath, []byte("#!/bin/sh\nprintf '%s\\n' \""+brewPrefix+"\"\n"), 0o755)) + + t.Setenv("PATH", brewScriptDir) + + assert.Equal(t, "brew", InstallationContext(filepath.Join(brewBinDir, "shopware-cli"))) +} + +func TestInstallationContextDetectsApt(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("apt detection only applies on Linux") + } + + workspace := t.TempDir() + aptScriptDir := filepath.Join(workspace, "tools") + require.NoError(t, os.MkdirAll(aptScriptDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(aptScriptDir, "apt"), []byte("#!/bin/sh\nexit 0\n"), 0o755)) + + t.Setenv("PATH", aptScriptDir) + + assert.Equal(t, "apt", InstallationContext(filepath.Join(workspace, "bin", "shopware-cli"))) +} + +func TestVersionGreaterThan(t *testing.T) { + tests := []struct { + name string + latest string + current string + expected bool + }{ + { + name: "newer release", + latest: "v1.0.0", + current: "v0.9.0", + expected: true, + }, + { + name: "same release", + latest: "v1.0.0", + current: "v1.0.0", + expected: false, + }, + { + name: "older release", + latest: "v0.9.0", + current: "v1.0.0", + expected: false, + }, + { + name: "source build treated as ahead of release", + latest: "v1.2.3", + current: "v1.2.3-123-gdeadbeef", + expected: false, + }, + { + name: "source build after prerelease still needs stable", + latest: "v1.2.3", + current: "v1.2.3-rc.1-123-gdeadbeef", + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, versionGreaterThan(tt.latest, tt.current)) + }) + } +} + +func TestUpdateHintRespectsConfiguredInterval(t *testing.T) { + t.Setenv("SHOPWARE_CLI_CACHE_DIR", t.TempDir()) + + requestCount := 0 + client := newVersionResponseClient("v9.9.9", &requestCount) + + first, err := CheckForUpdate(t.Context(), "v0.1.0", client) + require.NoError(t, err) + require.NotNil(t, first) + assert.Equal(t, "v9.9.9", first.Version) + + second, err := CheckForUpdate(t.Context(), "v0.1.0", client) + require.ErrorIs(t, err, ErrNoUpdateAvailable) + assert.Nil(t, second) + + err = SaveReleaseInfoToCache(&ReleaseInfo{ + Version: "v9.9.9", + FetchedAt: time.Now().Add(-(updateCheckInterval + time.Second)), + }) + require.NoError(t, err) + + third, err := CheckForUpdate(t.Context(), "v0.1.0", client) + require.NoError(t, err) + require.NotNil(t, third) + assert.Equal(t, "v9.9.9", third.Version) + + assert.Equal(t, 2, requestCount) +}