From fd87441e1cec4f023754553dc68f346a47964b09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Hellmann?= Date: Fri, 15 May 2026 14:57:30 +0200 Subject: [PATCH 01/11] refactor(version): tidy fetch and comparison - Trim apiDto.go to only the fields actually consumed (TagName, PublishedAt, Asset.Name, Asset.BrowserDownloadUrl). - Guard the package-level fetch cache with sync.Once. - Propagate real fetch errors instead of returning a "v0.0.0" sentinel that caused a false-positive "update available" warning whenever the version API was unreachable. - Compare versions via golang.org/x/mod/semver. Non-semver current values (notably the "local" default for dev builds) no longer trigger the warning. - Add tests for the new comparison helper. --- go.mod | 1 + go.sum | 3 + src/version/apiDto.go | 80 ++----------------------- src/version/message.go | 3 - src/version/version.go | 113 ++++++++++++++++++++---------------- src/version/version_test.go | 30 ++++++++++ 6 files changed, 103 insertions(+), 127 deletions(-) create mode 100644 src/version/version_test.go diff --git a/go.mod b/go.mod index 90790893..a41fa1c5 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( github.com/spf13/viper v1.20.0 github.com/stretchr/testify v1.10.0 golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 + golang.org/x/mod v0.26.0 golang.org/x/term v0.28.0 golang.org/x/text v0.28.0 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6 diff --git a/go.sum b/go.sum index 7fdf45f2..f5b405d0 100644 --- a/go.sum +++ b/go.sum @@ -128,6 +128,9 @@ golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ug golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY= golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0= +golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= diff --git a/src/version/apiDto.go b/src/version/apiDto.go index 8a1ab42f..11aacf0a 100644 --- a/src/version/apiDto.go +++ b/src/version/apiDto.go @@ -3,82 +3,12 @@ package version import "time" type apiResponse struct { - Url string `json:"url"` - AssetsUrl string `json:"assets_url"` - UploadUrl string `json:"upload_url"` - HtmlUrl string `json:"html_url"` - Id int `json:"id"` - Author apiAuthor `json:"author"` - NodeId string `json:"node_id"` - TagName string `json:"tag_name"` - TargetCommitish string `json:"target_commitish"` - Name string `json:"name"` - Draft bool `json:"draft"` - Prerelease bool `json:"prerelease"` - CreatedAt time.Time `json:"created_at"` - PublishedAt time.Time `json:"published_at"` - Assets []apiAsset `json:"assets"` - TarballUrl string `json:"tarball_url"` - ZipballUrl string `json:"zipball_url"` - Body string `json:"body"` -} - -type apiAuthor struct { - Login string `json:"login"` - Id int `json:"id"` - NodeId string `json:"node_id"` - AvatarUrl string `json:"avatar_url"` - GravatarId string `json:"gravatar_id"` - Url string `json:"url"` - HtmlUrl string `json:"html_url"` - FollowersUrl string `json:"followers_url"` - FollowingUrl string `json:"following_url"` - GistsUrl string `json:"gists_url"` - StarredUrl string `json:"starred_url"` - SubscriptionsUrl string `json:"subscriptions_url"` - OrganizationsUrl string `json:"organizations_url"` - ReposUrl string `json:"repos_url"` - EventsUrl string `json:"events_url"` - ReceivedEventsUrl string `json:"received_events_url"` - Type string `json:"type"` - UserViewType string `json:"user_view_type"` - SiteAdmin bool `json:"site_admin"` + TagName string `json:"tag_name"` + PublishedAt time.Time `json:"published_at"` + Assets []apiAsset `json:"assets"` } type apiAsset struct { - Url string `json:"url"` - Id int `json:"id"` - NodeId string `json:"node_id"` - Name string `json:"name"` - Label string `json:"label"` - Uploader apiUploader `json:"uploader"` - ContentType string `json:"content_type"` - State string `json:"state"` - Size int `json:"size"` - DownloadCount int `json:"download_count"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - BrowserDownloadUrl string `json:"browser_download_url"` -} - -type apiUploader struct { - Login string `json:"login"` - Id int `json:"id"` - NodeId string `json:"node_id"` - AvatarUrl string `json:"avatar_url"` - GravatarId string `json:"gravatar_id"` - Url string `json:"url"` - HtmlUrl string `json:"html_url"` - FollowersUrl string `json:"followers_url"` - FollowingUrl string `json:"following_url"` - GistsUrl string `json:"gists_url"` - StarredUrl string `json:"starred_url"` - SubscriptionsUrl string `json:"subscriptions_url"` - OrganizationsUrl string `json:"organizations_url"` - ReposUrl string `json:"repos_url"` - EventsUrl string `json:"events_url"` - ReceivedEventsUrl string `json:"received_events_url"` - Type string `json:"type"` - UserViewType string `json:"user_view_type"` - SiteAdmin bool `json:"site_admin"` + Name string `json:"name"` + BrowserDownloadUrl string `json:"browser_download_url"` } diff --git a/src/version/message.go b/src/version/message.go index 959ae50e..9218420c 100644 --- a/src/version/message.go +++ b/src/version/message.go @@ -32,9 +32,6 @@ type messageData struct { } func (d messageData) Output(out io.Writer) error { - if d.LatestVersion == "v0.0.0" { - return nil - } t, err := template.New("").Parse(messageTemplate) if err != nil { return errors.Wrap(err, "Failed to parse message template") diff --git a/src/version/version.go b/src/version/version.go index cc7e39a1..eaf83835 100644 --- a/src/version/version.go +++ b/src/version/version.go @@ -10,45 +10,64 @@ import ( "fmt" "net/http" "runtime" + "sync" "time" "github.com/pkg/errors" + "golang.org/x/mod/semver" + "github.com/zeropsio/zcli/src/httpClient" "github.com/zeropsio/zcli/src/printer" ) -const ( - apiUrl = "https://api.app-prg1.zerops.io/api/rest/public/zcli/version" -) +const apiUrl = "https://api.app-prg1.zerops.io/api/rest/public/zcli/version" var version = "local" -var latestResponse *apiResponse + +var ( + fetchOnce sync.Once + latestResponse *apiResponse + fetchErr error +) + +func GetCurrent() string { + return version +} func GetLatest(ctx context.Context) (string, error) { - if err := fetch(ctx); err != nil { + resp, err := fetch(ctx) + if err != nil { return "", err } - return latestResponse.TagName, nil + return resp.TagName, nil } -func GetCurrent() string { - return version +func GetLatestUrl(ctx context.Context) (string, error) { + resp, err := fetch(ctx) + if err != nil { + return "", err + } + assetName := fmt.Sprintf("zcli-%s-%s", runtime.GOOS, runtime.GOARCH) + for _, asset := range resp.Assets { + if asset.Name == assetName { + return asset.BrowserDownloadUrl, nil + } + } + return "", errors.Errorf("no release asset for %s/%s", runtime.GOOS, runtime.GOARCH) } func PrintVersionCheck(ctx context.Context, out printer.Printer) { latestVersion, err := GetLatest(ctx) if err != nil { - latestVersion = "unavailable" + out.Printf("zcli latest version check failed\n") + return } - latestUrl, err := GetLatestUrl(ctx) - if err != nil { - latestUrl = "unavailable" - } - - if GetCurrent() == latestVersion { + if !isUpdateAvailable(GetCurrent(), latestVersion) { out.Printf("zcli version is up to date\n") - } else { - out.Printf("zcli latest available version %s\n", latestVersion) + return + } + out.Printf("zcli latest available version %s\n", latestVersion) + if latestUrl, err := GetLatestUrl(ctx); err == nil { out.Printf("zcli latest available version download url %s\n", latestUrl) } } @@ -56,9 +75,9 @@ func PrintVersionCheck(ctx context.Context, out printer.Printer) { func IsVersionCheckMismatch(ctx context.Context) bool { latestVersion, err := GetLatest(ctx) if err != nil { - latestVersion = "unavailable" + return false } - return GetCurrent() != latestVersion + return isUpdateAvailable(GetCurrent(), latestVersion) } func GetVersionCheckMismatch(ctx context.Context) (string, error) { @@ -69,38 +88,34 @@ func GetVersionCheckMismatch(ctx context.Context) (string, error) { return b.String(), nil } -func GetLatestUrl(ctx context.Context) (string, error) { - if err := fetch(ctx); err != nil { - return "", err - } - - for _, asset := range latestResponse.Assets { - if asset.Name == fmt.Sprintf("zcli-%s-%s", runtime.GOOS, runtime.GOARCH) { - return asset.BrowserDownloadUrl, nil - } +// isUpdateAvailable reports whether latest is strictly newer than current. +// Non-semver values (notably the default "local") are treated as up to date so +// dev builds and unreleased binaries don't show a false-positive warning. +func isUpdateAvailable(current, latest string) bool { + if !semver.IsValid(current) || !semver.IsValid(latest) { + return false } - - return "", errors.Errorf("could not find latest release for %s/%s", runtime.GOOS, runtime.GOARCH) + return semver.Compare(current, latest) < 0 } -func fetch(ctx context.Context) error { - if latestResponse != nil { - return nil - } - client := httpClient.New(ctx, httpClient.Config{HttpTimeout: time.Second * 5}) - resp, err := client.Get(ctx, apiUrl) - if err != nil { - return errors.Wrapf(err, "unable to get api response %s", apiUrl) - } - if resp.StatusCode == http.StatusOK { - latestResponse = &apiResponse{} - if err := json.Unmarshal(resp.Body, &latestResponse); err != nil { - return errors.Wrap(err, "unable to read api response") +func fetch(ctx context.Context) (*apiResponse, error) { + fetchOnce.Do(func() { + client := httpClient.New(ctx, httpClient.Config{HttpTimeout: time.Second * 5}) + resp, err := client.Get(ctx, apiUrl) + if err != nil { + fetchErr = errors.Wrapf(err, "version api request to %s failed", apiUrl) + return } - return nil - } - latestResponse = &apiResponse{ - TagName: "v0.0.0", - } - return nil + if resp.StatusCode != http.StatusOK { + fetchErr = errors.Errorf("version api %s returned status %d", apiUrl, resp.StatusCode) + return + } + out := &apiResponse{} + if err := json.Unmarshal(resp.Body, out); err != nil { + fetchErr = errors.Wrap(err, "version api response could not be decoded") + return + } + latestResponse = out + }) + return latestResponse, fetchErr } diff --git a/src/version/version_test.go b/src/version/version_test.go new file mode 100644 index 00000000..a378966a --- /dev/null +++ b/src/version/version_test.go @@ -0,0 +1,30 @@ +//go:build !devel + +package version + +import "testing" + +func TestIsUpdateAvailable(t *testing.T) { + cases := []struct { + name string + current string + latest string + want bool + }{ + {"older current", "v1.0.0", "v1.0.1", true}, + {"older current minor", "v1.0.0", "v1.1.0", true}, + {"newer current", "v1.0.1", "v1.0.0", false}, + {"equal", "v1.0.0", "v1.0.0", false}, + {"local current", "local", "v1.0.0", false}, + {"empty current", "", "v1.0.0", false}, + {"empty latest", "v1.0.0", "", false}, + {"garbage latest", "v1.0.0", "not-a-version", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := isUpdateAvailable(tc.current, tc.latest); got != tc.want { + t.Fatalf("isUpdateAvailable(%q, %q) = %v, want %v", tc.current, tc.latest, got, tc.want) + } + }) + } +} From f732bd2c72558c375fec384b2d04728221b42d57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Hellmann?= Date: Fri, 15 May 2026 15:13:37 +0200 Subject: [PATCH 02/11] feat(version): disk-cached check, install-method detection, drop devel tag - 24h on-disk cache for the version API response, stored next to cli.data. fetch() now reads cache first, falls back to network, and serves a stale cache if the network is unreachable. - Install-method detection: Detect() reports nix/npm/brew/manual based on the running binary's path, with a build-time channel override via ldflags (-X .../version.channel=) for packagers whose install paths collide with manual installs (AUR, winget MSI). - Drop the devel build tag. Non-semver version values ("local") cause IsVersionCheckMismatch and PrintVersionCheck to short-circuit at runtime, replacing the no-op stubs in version_devel.go. --- src/constants/zerops.go | 1 + src/version/cache.go | 73 ++++++++++++++++++++ src/version/cache_test.go | 74 ++++++++++++++++++++ src/version/install_method.go | 104 +++++++++++++++++++++++++++++ src/version/install_method_test.go | 81 ++++++++++++++++++++++ src/version/message.go | 2 - src/version/version.go | 48 +++++++++---- src/version/version_devel.go | 26 -------- src/version/version_test.go | 2 - 9 files changed, 367 insertions(+), 44 deletions(-) create mode 100644 src/version/cache.go create mode 100644 src/version/cache_test.go create mode 100644 src/version/install_method.go create mode 100644 src/version/install_method_test.go delete mode 100644 src/version/version_devel.go diff --git a/src/constants/zerops.go b/src/constants/zerops.go index f263737a..a382d340 100644 --- a/src/constants/zerops.go +++ b/src/constants/zerops.go @@ -19,6 +19,7 @@ const ( WgConfigFile = "zerops.conf" WgInterfaceName = "zerops" CliDataFileName = "cli.data" + VersionCacheFileName = "version.cache" CliZcliYamlBaseFileName = ".zcli" CliZcliYamlFileName = CliZcliYamlBaseFileName + ".yml" CliDataFilePathEnvVar = "ZEROPS_CLI_DATA_FILE_PATH" diff --git a/src/version/cache.go b/src/version/cache.go new file mode 100644 index 00000000..af9e61dc --- /dev/null +++ b/src/version/cache.go @@ -0,0 +1,73 @@ +package version + +import ( + "encoding/json" + "os" + "path/filepath" + "time" + + "github.com/pkg/errors" + + "github.com/zeropsio/zcli/src/constants" +) + +const cacheTTL = 24 * time.Hour + +type cacheEntry struct { + FetchedAt time.Time `json:"fetched_at"` + Response *apiResponse `json:"response"` +} + +func (c *cacheEntry) Fresh() bool { + return time.Since(c.FetchedAt) < cacheTTL +} + +// cacheFilePath returns the path the version cache lives at, or "" if no +// writable location is available. A missing path is non-fatal — callers +// should fall back to the network. +func cacheFilePath() string { + dataPath, _, err := constants.CliDataFilePath() + if err != nil { + return "" + } + return filepath.Join(filepath.Dir(dataPath), constants.VersionCacheFileName) +} + +// loadCacheEntry reads the cache from disk. Returns (nil, nil) when no usable +// cache exists; an error indicates a malformed file the caller can ignore. +func loadCacheEntry() (*cacheEntry, error) { + path := cacheFilePath() + if path == "" { + return nil, nil + } + b, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, errors.Wrap(err, "read version cache") + } + entry := &cacheEntry{} + if err := json.Unmarshal(b, entry); err != nil { + return nil, errors.Wrap(err, "parse version cache") + } + if entry.Response == nil { + return nil, nil + } + return entry, nil +} + +func writeCacheEntry(resp *apiResponse) error { + path := cacheFilePath() + if path == "" { + return nil + } + b, err := json.Marshal(cacheEntry{FetchedAt: time.Now(), Response: resp}) + if err != nil { + return errors.Wrap(err, "encode version cache") + } + if err := os.WriteFile(path, b, 0o644); err != nil { + return errors.Wrap(err, "write version cache") + } + return nil +} diff --git a/src/version/cache_test.go b/src/version/cache_test.go new file mode 100644 index 00000000..ff4f6d42 --- /dev/null +++ b/src/version/cache_test.go @@ -0,0 +1,74 @@ +package version + +import ( + "path/filepath" + "testing" + "time" +) + +func TestCacheEntryFresh(t *testing.T) { + cases := []struct { + name string + age time.Duration + want bool + }{ + {"just now", 0, true}, + {"one hour ago", time.Hour, true}, + {"under ttl", cacheTTL - time.Minute, true}, + {"at ttl boundary", cacheTTL + time.Minute, false}, + {"a week ago", 7 * 24 * time.Hour, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + e := &cacheEntry{FetchedAt: time.Now().Add(-tc.age)} + if got := e.Fresh(); got != tc.want { + t.Fatalf("Fresh(age=%s) = %v, want %v", tc.age, got, tc.want) + } + }) + } +} + +func TestCacheRoundTrip(t *testing.T) { + dir := t.TempDir() + t.Setenv("ZEROPS_CLI_DATA_FILE_PATH", filepath.Join(dir, "cli.data")) + + resp := &apiResponse{ + TagName: "v1.2.3", + Assets: []apiAsset{ + {Name: "zcli-darwin-arm64", BrowserDownloadUrl: "https://example.com/zcli"}, + }, + } + if err := writeCacheEntry(resp); err != nil { + t.Fatalf("writeCacheEntry: %v", err) + } + + got, err := loadCacheEntry() + if err != nil { + t.Fatalf("loadCacheEntry: %v", err) + } + if got == nil { + t.Fatal("loadCacheEntry returned nil entry") + } + if got.Response.TagName != "v1.2.3" { + t.Errorf("TagName = %q, want v1.2.3", got.Response.TagName) + } + if len(got.Response.Assets) != 1 || got.Response.Assets[0].Name != "zcli-darwin-arm64" { + t.Errorf("Assets = %+v", got.Response.Assets) + } + if !got.Fresh() { + t.Error("freshly written cache should be Fresh()") + } +} + +func TestLoadCacheEntryMissing(t *testing.T) { + dir := t.TempDir() + t.Setenv("ZEROPS_CLI_DATA_FILE_PATH", filepath.Join(dir, "cli.data")) + + got, err := loadCacheEntry() + if err != nil { + t.Fatalf("loadCacheEntry: %v", err) + } + if got != nil { + t.Errorf("expected nil entry when cache missing, got %+v", got) + } +} diff --git a/src/version/install_method.go b/src/version/install_method.go new file mode 100644 index 00000000..17d3dc8b --- /dev/null +++ b/src/version/install_method.go @@ -0,0 +1,104 @@ +package version + +import ( + "os" + "path/filepath" + "strings" +) + +type InstallMethod int + +const ( + InstallManual InstallMethod = iota + InstallNix + InstallNpm + InstallBrew +) + +func (m InstallMethod) String() string { + switch m { + case InstallNix: + return "nix" + case InstallNpm: + return "npm" + case InstallBrew: + return "homebrew" + default: + return "manual" + } +} + +// Hint returns the command users should run to upgrade through their install +// channel. Returns "" for InstallManual — those installs use `zcli upgrade`. +func (m InstallMethod) Hint() string { + switch m { + case InstallNix: + return "rebuild your nix profile or flake" + case InstallNpm: + return "npm install -g @zerops/zcli" + case InstallBrew: + return "brew upgrade zcli" + default: + return "" + } +} + +func (m InstallMethod) IsPackageManager() bool { + return m != InstallManual +} + +// channel is stamped by packagers via `-ldflags "-X .../version.channel="`. +// Empty means "not stamped" — Detect() falls back to path-based heuristics. +// Packagers who install to paths indistinguishable from a manual install +// (notably AUR and winget MSI installers) should set this. +var channel = "" + +// Detect returns the channel the running binary was installed through. The +// build-time channel stamp takes precedence; otherwise we infer from the +// binary path. Falls back to InstallManual when neither yields a result. +func Detect() InstallMethod { + if m, ok := parseChannel(channel); ok { + return m + } + exe, err := os.Executable() + if err != nil { + return InstallManual + } + if resolved, err := filepath.EvalSymlinks(exe); err == nil { + exe = resolved + } + return detectFromPath(exe) +} + +// parseChannel maps a build-stamped channel name to an InstallMethod. +// Unknown or empty names return (_, false) so callers fall back to path +// detection rather than silently mis-reporting. +func parseChannel(c string) (InstallMethod, bool) { + switch c { + case "manual": + return InstallManual, true + case "nix": + return InstallNix, true + case "npm": + return InstallNpm, true + case "brew": + return InstallBrew, true + default: + return 0, false + } +} + +func detectFromPath(path string) InstallMethod { + p := filepath.ToSlash(path) + switch { + case strings.HasPrefix(p, "/nix/store/"): + return InstallNix + case strings.Contains(p, "/node_modules/"): + return InstallNpm + case strings.Contains(p, "/Cellar/"), + strings.HasPrefix(p, "/home/linuxbrew/.linuxbrew/"): + return InstallBrew + default: + return InstallManual + } +} diff --git a/src/version/install_method_test.go b/src/version/install_method_test.go new file mode 100644 index 00000000..49dab5dd --- /dev/null +++ b/src/version/install_method_test.go @@ -0,0 +1,81 @@ +package version + +import "testing" + +func TestDetectFromPath(t *testing.T) { + cases := []struct { + name string + path string + want InstallMethod + }{ + {"nix store", "/nix/store/abc123-zcli/bin/zcli", InstallNix}, + {"npm global on macos", "/Users/x/.npm-global/lib/node_modules/@zerops/zcli/utils/bin/zcli", InstallNpm}, + {"npm via nvm", "/Users/x/.nvm/versions/node/v22.0.0/lib/node_modules/@zerops/zcli/utils/bin/zcli", InstallNpm}, + {"homebrew apple silicon", "/opt/homebrew/Cellar/zcli/1.2.3/bin/zcli", InstallBrew}, + {"homebrew intel", "/usr/local/Cellar/zcli/1.2.3/bin/zcli", InstallBrew}, + {"linuxbrew", "/home/linuxbrew/.linuxbrew/Cellar/zcli/1.2.3/bin/zcli", InstallBrew}, + {"install.sh path", "/usr/local/bin/zcli", InstallManual}, + {"home bin", "/Users/x/.local/bin/zcli", InstallManual}, + {"windows install path", `C:\Program Files\zcli\zcli.exe`, InstallManual}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := detectFromPath(tc.path); got != tc.want { + t.Fatalf("detectFromPath(%q) = %v, want %v", tc.path, got, tc.want) + } + }) + } +} + +func TestParseChannel(t *testing.T) { + cases := []struct { + in string + want InstallMethod + wantOk bool + }{ + {"nix", InstallNix, true}, + {"npm", InstallNpm, true}, + {"brew", InstallBrew, true}, + {"manual", InstallManual, true}, + {"", 0, false}, + {"unknown", 0, false}, + {"AUR", 0, false}, // case-sensitive, no implicit fallback + } + for _, tc := range cases { + got, ok := parseChannel(tc.in) + if ok != tc.wantOk || got != tc.want { + t.Errorf("parseChannel(%q) = (%v, %v), want (%v, %v)", tc.in, got, ok, tc.want, tc.wantOk) + } + } +} + +func TestDetectChannelStamp(t *testing.T) { + saved := channel + t.Cleanup(func() { channel = saved }) + + channel = "nix" + if got := Detect(); got != InstallNix { + t.Errorf("Detect() with channel=nix = %v, want InstallNix", got) + } + channel = "brew" + if got := Detect(); got != InstallBrew { + t.Errorf("Detect() with channel=brew = %v, want InstallBrew", got) + } +} + +func TestInstallMethodPackageManager(t *testing.T) { + for _, m := range []InstallMethod{InstallNix, InstallNpm, InstallBrew} { + if !m.IsPackageManager() { + t.Errorf("%v should be a package manager", m) + } + if m.Hint() == "" { + t.Errorf("%v should have a hint", m) + } + } + if InstallManual.IsPackageManager() { + t.Error("InstallManual should not be a package manager") + } + if InstallManual.Hint() != "" { + t.Error("InstallManual should have empty hint") + } +} diff --git a/src/version/message.go b/src/version/message.go index 9218420c..0ca3d6c1 100644 --- a/src/version/message.go +++ b/src/version/message.go @@ -1,5 +1,3 @@ -//go:build !devel - package version import ( diff --git a/src/version/version.go b/src/version/version.go index eaf83835..cbae989e 100644 --- a/src/version/version.go +++ b/src/version/version.go @@ -1,5 +1,3 @@ -//go:build !devel - package version import ( @@ -57,6 +55,9 @@ func GetLatestUrl(ctx context.Context) (string, error) { } func PrintVersionCheck(ctx context.Context, out printer.Printer) { + if !semver.IsValid(GetCurrent()) { + return + } latestVersion, err := GetLatest(ctx) if err != nil { out.Printf("zcli latest version check failed\n") @@ -73,6 +74,9 @@ func PrintVersionCheck(ctx context.Context, out printer.Printer) { } func IsVersionCheckMismatch(ctx context.Context) bool { + if !semver.IsValid(GetCurrent()) { + return false + } latestVersion, err := GetLatest(ctx) if err != nil { return false @@ -100,22 +104,38 @@ func isUpdateAvailable(current, latest string) bool { func fetch(ctx context.Context) (*apiResponse, error) { fetchOnce.Do(func() { - client := httpClient.New(ctx, httpClient.Config{HttpTimeout: time.Second * 5}) - resp, err := client.Get(ctx, apiUrl) - if err != nil { - fetchErr = errors.Wrapf(err, "version api request to %s failed", apiUrl) - return - } - if resp.StatusCode != http.StatusOK { - fetchErr = errors.Errorf("version api %s returned status %d", apiUrl, resp.StatusCode) + if entry, err := loadCacheEntry(); err == nil && entry != nil && entry.Fresh() { + latestResponse = entry.Response return } - out := &apiResponse{} - if err := json.Unmarshal(resp.Body, out); err != nil { - fetchErr = errors.Wrap(err, "version api response could not be decoded") + resp, err := fetchFromNetwork(ctx) + if err != nil { + // Stale cache beats no answer at all. + if entry, cacheErr := loadCacheEntry(); cacheErr == nil && entry != nil { + latestResponse = entry.Response + return + } + fetchErr = err return } - latestResponse = out + latestResponse = resp + _ = writeCacheEntry(resp) }) return latestResponse, fetchErr } + +func fetchFromNetwork(ctx context.Context) (*apiResponse, error) { + client := httpClient.New(ctx, httpClient.Config{HttpTimeout: time.Second * 5}) + resp, err := client.Get(ctx, apiUrl) + if err != nil { + return nil, errors.Wrapf(err, "version api request to %s failed", apiUrl) + } + if resp.StatusCode != http.StatusOK { + return nil, errors.Errorf("version api %s returned status %d", apiUrl, resp.StatusCode) + } + out := &apiResponse{} + if err := json.Unmarshal(resp.Body, out); err != nil { + return nil, errors.Wrap(err, "version api response could not be decoded") + } + return out, nil +} diff --git a/src/version/version_devel.go b/src/version/version_devel.go deleted file mode 100644 index 601f5060..00000000 --- a/src/version/version_devel.go +++ /dev/null @@ -1,26 +0,0 @@ -//go:build devel - -package version - -import ( - "context" - _ "embed" - - "github.com/zeropsio/zcli/src/printer" -) - -var version = "local" - -func IsVersionCheckMismatch(context.Context) bool { - return false -} - -func GetVersionCheckMismatch(context.Context) (string, error) { - return "", nil -} - -func PrintVersionCheck(context.Context, printer.Printer) {} - -func GetCurrent() string { - return version -} diff --git a/src/version/version_test.go b/src/version/version_test.go index a378966a..a9fdd58f 100644 --- a/src/version/version_test.go +++ b/src/version/version_test.go @@ -1,5 +1,3 @@ -//go:build !devel - package version import "testing" From 7d458f85e5f4c39713a472cb9415c3d21bfb7635 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Hellmann?= Date: Fri, 15 May 2026 15:21:34 +0200 Subject: [PATCH 03/11] feat(version): non-blocking warning, background refresh, channel-aware hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MismatchWarning() reads the on-disk cache only — the synchronous check on every command no longer blocks on the network. The cache is populated by a fire-and-forget RefreshCacheIfStale goroutine spawned alongside the check, so the next invocation has fresh data. - One-line warning replaces the two-line template, and the upgrade hint is now channel-aware: npm/brew/nix users see their package manager's command, install.sh users see the github releases URL. - writeCacheEntry now writes via a tmp file + rename so a process exit during the background refresh can't leave a half-written cache. - Collapse IsVersionCheckMismatch + GetVersionCheckMismatch into a single MismatchWarning(). Drop the embedded message.txt template. --- src/cmdBuilder/createRunFunc.go | 9 ++--- src/version/assets/message.txt | 2 -- src/version/cache.go | 9 ++++- src/version/install_method.go | 12 +++---- src/version/install_method_test.go | 11 +++--- src/version/message.go | 41 ---------------------- src/version/version.go | 49 +++++++++++++++++++------- src/version/version_test.go | 56 +++++++++++++++++++++++++++++- 8 files changed, 113 insertions(+), 76 deletions(-) delete mode 100644 src/version/assets/message.txt delete mode 100644 src/version/message.go diff --git a/src/cmdBuilder/createRunFunc.go b/src/cmdBuilder/createRunFunc.go index fa63cbd6..ef746692 100644 --- a/src/cmdBuilder/createRunFunc.go +++ b/src/cmdBuilder/createRunFunc.go @@ -60,12 +60,9 @@ func createCmdRunFunc( uxBlocks.LogDebug(fmt.Sprintf("Command: %s", cobraCmd.CommandPath())) - if getVersion.IsVersionCheckMismatch(ctx) { - versionCheckMismatch, err := getVersion.GetVersionCheckMismatch(ctx) - if err != nil { - return err - } - uxBlocks.PrintWarningText(versionCheckMismatch) + go getVersion.RefreshCacheIfStale(ctx) + if warning := getVersion.MismatchWarning(); warning != "" { + uxBlocks.PrintWarningText(warning) } flagParams.Bind(cobraCmd) diff --git a/src/version/assets/message.txt b/src/version/assets/message.txt deleted file mode 100644 index a9fed5a9..00000000 --- a/src/version/assets/message.txt +++ /dev/null @@ -1,2 +0,0 @@ -Your zcli version ({{.CurrentVersion}}) is outdated. The latest available version is {{.LatestVersion}}. -Please update your zcli installation https://github.com/zeropsio/zcli#install-zcli \ No newline at end of file diff --git a/src/version/cache.go b/src/version/cache.go index af9e61dc..7a202839 100644 --- a/src/version/cache.go +++ b/src/version/cache.go @@ -66,8 +66,15 @@ func writeCacheEntry(resp *apiResponse) error { if err != nil { return errors.Wrap(err, "encode version cache") } - if err := os.WriteFile(path, b, 0o644); err != nil { + // Write to a sibling tmpfile and rename so a process exit during the + // background refresh can't leave a half-written cache file behind. + tmp := path + ".tmp" + if err := os.WriteFile(tmp, b, 0o644); err != nil { return errors.Wrap(err, "write version cache") } + if err := os.Rename(tmp, path); err != nil { + _ = os.Remove(tmp) + return errors.Wrap(err, "swap version cache") + } return nil } diff --git a/src/version/install_method.go b/src/version/install_method.go index 17d3dc8b..62cd88fe 100644 --- a/src/version/install_method.go +++ b/src/version/install_method.go @@ -28,18 +28,18 @@ func (m InstallMethod) String() string { } } -// Hint returns the command users should run to upgrade through their install -// channel. Returns "" for InstallManual — those installs use `zcli upgrade`. +// Hint returns the one-line upgrade instruction for this install channel. +// Always non-empty so warnings always tell the user what to do. func (m InstallMethod) Hint() string { switch m { case InstallNix: - return "rebuild your nix profile or flake" + return "Update via Nix: rebuild your profile or flake." case InstallNpm: - return "npm install -g @zerops/zcli" + return "Update via npm: npm install -g @zerops/zcli" case InstallBrew: - return "brew upgrade zcli" + return "Update via Homebrew: brew upgrade zcli" default: - return "" + return "Update via https://github.com/zeropsio/zcli#install-zcli" } } diff --git a/src/version/install_method_test.go b/src/version/install_method_test.go index 49dab5dd..01b85747 100644 --- a/src/version/install_method_test.go +++ b/src/version/install_method_test.go @@ -64,18 +64,17 @@ func TestDetectChannelStamp(t *testing.T) { } func TestInstallMethodPackageManager(t *testing.T) { + for _, m := range []InstallMethod{InstallNix, InstallNpm, InstallBrew, InstallManual} { + if m.Hint() == "" { + t.Errorf("%v should have a non-empty hint", m) + } + } for _, m := range []InstallMethod{InstallNix, InstallNpm, InstallBrew} { if !m.IsPackageManager() { t.Errorf("%v should be a package manager", m) } - if m.Hint() == "" { - t.Errorf("%v should have a hint", m) - } } if InstallManual.IsPackageManager() { t.Error("InstallManual should not be a package manager") } - if InstallManual.Hint() != "" { - t.Error("InstallManual should have empty hint") - } } diff --git a/src/version/message.go b/src/version/message.go deleted file mode 100644 index 0ca3d6c1..00000000 --- a/src/version/message.go +++ /dev/null @@ -1,41 +0,0 @@ -package version - -import ( - "context" - _ "embed" - "io" - "text/template" - - "github.com/pkg/errors" -) - -//go:embed assets/message.txt -var messageTemplate string - -func printMessageData(ctx context.Context, out io.Writer) error { - latest, _ := GetLatest(ctx) - latestURL, _ := GetLatestUrl(ctx) - d := messageData{ - CurrentVersion: GetCurrent(), - LatestVersion: latest, - LatestUrl: latestURL, - } - return d.Output(out) -} - -type messageData struct { - CurrentVersion string - LatestVersion string - LatestUrl string -} - -func (d messageData) Output(out io.Writer) error { - t, err := template.New("").Parse(messageTemplate) - if err != nil { - return errors.Wrap(err, "Failed to parse message template") - } - if err := t.Execute(out, d); err != nil { - return errors.Wrap(err, "Failed to execute message template") - } - return nil -} diff --git a/src/version/version.go b/src/version/version.go index cbae989e..8bf455ec 100644 --- a/src/version/version.go +++ b/src/version/version.go @@ -1,9 +1,7 @@ package version import ( - "bytes" "context" - _ "embed" "encoding/json" "fmt" "net/http" @@ -73,23 +71,48 @@ func PrintVersionCheck(ctx context.Context, out printer.Printer) { } } -func IsVersionCheckMismatch(ctx context.Context) bool { - if !semver.IsValid(GetCurrent()) { - return false +// MismatchWarning returns the formatted update warning when a newer release +// is known, or "" if there is nothing to warn about. Reads only from the +// on-disk cache — never blocks on the network. The cache is populated by +// RefreshCacheIfStale running in the background. +func MismatchWarning() string { + current := GetCurrent() + if !semver.IsValid(current) { + return "" } - latestVersion, err := GetLatest(ctx) + resp := loadCached() + if resp == nil { + return "" + } + if !isUpdateAvailable(current, resp.TagName) { + return "" + } + return fmt.Sprintf("zcli %s is available (you have %s). %s", resp.TagName, current, Detect().Hint()) +} + +// RefreshCacheIfStale updates the on-disk cache when it's missing or older +// than cacheTTL. Designed for fire-and-forget background use alongside a +// synchronous MismatchWarning() call so subsequent invocations have fresh +// data. Errors are swallowed — the next run will retry. +func RefreshCacheIfStale(ctx context.Context) { + if entry, err := loadCacheEntry(); err == nil && entry != nil && entry.Fresh() { + return + } + resp, err := fetchFromNetwork(ctx) if err != nil { - return false + return } - return isUpdateAvailable(GetCurrent(), latestVersion) + _ = writeCacheEntry(resp) } -func GetVersionCheckMismatch(ctx context.Context) (string, error) { - b := bytes.NewBuffer(nil) - if err := printMessageData(ctx, b); err != nil { - return "", err +// loadCached returns the cached API response or nil when the cache is +// missing, malformed, or unavailable. +func loadCached() *apiResponse { + entry, err := loadCacheEntry() + if err != nil || entry == nil { + return nil } - return b.String(), nil + return entry.Response } // isUpdateAvailable reports whether latest is strictly newer than current. diff --git a/src/version/version_test.go b/src/version/version_test.go index a9fdd58f..cf6b898c 100644 --- a/src/version/version_test.go +++ b/src/version/version_test.go @@ -1,6 +1,10 @@ package version -import "testing" +import ( + "path/filepath" + "strings" + "testing" +) func TestIsUpdateAvailable(t *testing.T) { cases := []struct { @@ -26,3 +30,53 @@ func TestIsUpdateAvailable(t *testing.T) { }) } } + +func TestMismatchWarning(t *testing.T) { + savedVersion := version + savedChannel := channel + t.Cleanup(func() { version = savedVersion; channel = savedChannel }) + + dir := t.TempDir() + t.Setenv("ZEROPS_CLI_DATA_FILE_PATH", filepath.Join(dir, "cli.data")) + + if err := writeCacheEntry(&apiResponse{TagName: "v1.2.0"}); err != nil { + t.Fatalf("seed cache: %v", err) + } + + t.Run("non-semver current returns empty", func(t *testing.T) { + version = "local" + if got := MismatchWarning(); got != "" { + t.Errorf("local: want empty, got %q", got) + } + }) + + t.Run("equal versions return empty", func(t *testing.T) { + version = "v1.2.0" + if got := MismatchWarning(); got != "" { + t.Errorf("equal: want empty, got %q", got) + } + }) + + t.Run("channel hint included", func(t *testing.T) { + version = "v1.0.0" + cases := []struct { + stamp string + want string + }{ + {"npm", "npm install -g @zerops/zcli"}, + {"brew", "brew upgrade zcli"}, + {"nix", "rebuild your profile or flake"}, + {"manual", "github.com/zeropsio/zcli"}, + } + for _, tc := range cases { + channel = tc.stamp + got := MismatchWarning() + if !strings.Contains(got, "v1.2.0") || !strings.Contains(got, "v1.0.0") { + t.Errorf("channel %q: %q missing version info", tc.stamp, got) + } + if !strings.Contains(got, tc.want) { + t.Errorf("channel %q: %q missing hint %q", tc.stamp, got, tc.want) + } + } + }) +} From a15f2a67b75832f2d09cad229381fc500106dac1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Hellmann?= Date: Fri, 15 May 2026 15:33:50 +0200 Subject: [PATCH 04/11] feat(version): zcli upgrade self-updater Adds an interactive self-update command for binaries that weren't installed through a package manager. zcli upgrade # interactive: prompt, download, verify, swap zcli upgrade --yes # skip the confirmation zcli upgrade --check # exit 0 = up to date, 1 = behind, 2 = error zcli upgrade --version vX.Y.Z # install a specific tag (incl. downgrade) Behavior: - Refuses package-managed installs (npm/Nix/brew) with the channel- specific upgrade command. - Downloads the release binary and checksums.txt from GitHub, verifies sha256 against the manifest, and atomically swaps via the minio/selfupdate library (handles the Windows rename-on-exit dance). - Surfaces permission errors with a re-run-as-sudo hint instead of a generic failure. Side cleanup: - Unify release asset naming into a single assetName() helper. Fixes a pre-existing bug where GetLatestUrl produced "zcli-windows-amd64" or "zcli-linux-386" instead of "zcli-win-x64.exe" / "zcli-linux-i386". - Manual-install upgrade hint in the passive warning now says "Run: zcli upgrade" now that the command exists. --- go.mod | 4 +- go.sum | 30 ++++---- src/cmd/root.go | 1 + src/cmd/upgrade.go | 73 ++++++++++++++++++ src/version/install_method.go | 2 +- src/version/upgrade.go | 137 ++++++++++++++++++++++++++++++++++ src/version/upgrade_test.go | 89 ++++++++++++++++++++++ src/version/version.go | 4 +- src/version/version_test.go | 2 +- 9 files changed, 321 insertions(+), 21 deletions(-) create mode 100644 src/cmd/upgrade.go create mode 100644 src/version/upgrade.go create mode 100644 src/version/upgrade_test.go diff --git a/go.mod b/go.mod index a41fa1c5..71bf5e72 100644 --- a/go.mod +++ b/go.mod @@ -8,10 +8,12 @@ require ( github.com/charmbracelet/bubbles v0.20.0 github.com/charmbracelet/bubbletea v1.3.5 github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/x/exp/teatest v0.0.0-20260511125431-fe5d686e0c99 github.com/golang/mock v1.6.0 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/mattn/go-isatty v0.0.20 + github.com/minio/selfupdate v0.6.0 github.com/pkg/errors v0.9.1 github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 github.com/sirupsen/logrus v1.9.3 @@ -28,6 +30,7 @@ require ( ) require ( + aead.dev/minisign v0.2.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-udiff v0.3.1 // indirect @@ -35,7 +38,6 @@ require ( github.com/charmbracelet/x/ansi v0.10.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b // indirect - github.com/charmbracelet/x/exp/teatest v0.0.0-20260511125431-fe5d686e0c99 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect diff --git a/go.sum b/go.sum index f5b405d0..db6b9758 100644 --- a/go.sum +++ b/go.sum @@ -1,25 +1,19 @@ +aead.dev/minisign v0.2.0 h1:kAWrq/hBRu4AARY6AlciO83xhNnW9UaC8YipS2uhLPk= +aead.dev/minisign v0.2.0/go.mod h1:zdq6LdSd9TbuSxchxwhpA9zEb9YXcVGoE8JakuiGaIQ= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= -github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= -github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI= -github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo= github.com/charmbracelet/bubbletea v1.3.5 h1:JAMNLTbqMOhSwoELIr0qyP4VidFq72/6E9j7HHmRKQc= github.com/charmbracelet/bubbletea v1.3.5/go.mod h1:TkCnmH+aBd4LrXhXcqrKiYwRs7qyQx5rBgH5fVY3v54= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI= github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= -github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= @@ -68,6 +62,8 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU= +github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= @@ -123,42 +119,44 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY= golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0= -golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= -golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210228012217-479acdf4ea46/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/src/cmd/root.go b/src/cmd/root.go index 875e3f46..6fbb7c20 100644 --- a/src/cmd/root.go +++ b/src/cmd/root.go @@ -26,6 +26,7 @@ func rootCmd() *cmdBuilder.Cmd { AddChildrenCmd(loginCmd()). AddChildrenCmd(logoutCmd()). AddChildrenCmd(versionCmd()). + AddChildrenCmd(upgradeCmd()). AddChildrenCmd(scopeCmd()). AddChildrenCmd(projectCmd()). AddChildrenCmd(serviceCmd()). diff --git a/src/cmd/upgrade.go b/src/cmd/upgrade.go new file mode 100644 index 00000000..2ab5caf7 --- /dev/null +++ b/src/cmd/upgrade.go @@ -0,0 +1,73 @@ +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/zeropsio/zcli/src/cmdBuilder" + "github.com/zeropsio/zcli/src/uxBlock/models/prompt" + "github.com/zeropsio/zcli/src/uxBlock/styles" + "github.com/zeropsio/zcli/src/uxHelpers" + getVersion "github.com/zeropsio/zcli/src/version" +) + +func upgradeCmd() *cmdBuilder.Cmd { + return cmdBuilder.NewCmd(). + Use("upgrade"). + Short("Upgrade zcli to the latest release."). + HelpFlag("Help for the upgrade command."). + BoolFlag("check", false, "Print current and latest version, then exit. 0 = up to date, 1 = behind, 2 = error."). + BoolFlag("yes", false, "Skip the confirmation prompt."). + StringFlag("version", "", "Install a specific release tag instead of the latest."). + GuestRunFunc(func(ctx context.Context, cmdData *cmdBuilder.GuestCmdData) error { + check := cmdData.Params.GetBool("check") + yes := cmdData.Params.GetBool("yes") + targetVersion := cmdData.Params.GetString("version") + + plan, err := getVersion.PlanUpgrade(ctx, getVersion.UpgradeOptions{TargetVersion: targetVersion}) + if err != nil { + if check { + cmdData.Stderr.Printf("error: %s\n", err) + os.Exit(2) + } + return err + } + + if check { + cmdData.Stdout.Printf("Current: %s\nLatest: %s\n", plan.Current, plan.Target) + if plan.Current == plan.Target { + return nil + } + os.Exit(1) + } + + if plan.Current == plan.Target && targetVersion == "" { + cmdData.Stdout.Printf("zcli is already on %s.\n", plan.Current) + return nil + } + + if !yes { + question := fmt.Sprintf("Current: %s\nTarget: %s\n\nUpdate?", plan.Current, plan.Target) + confirmed, err := uxHelpers.YesNoPrompt( + ctx, + question, + prompt.WithDialogBoxStyle(styles.DialogBox()), + ) + if err != nil { + return err + } + if !confirmed { + cmdData.Stdout.Printf("Aborted.\n") + return nil + } + } + + cmdData.Stdout.Printf("Downloading %s ...\n", plan.Target) + if err := getVersion.Upgrade(ctx, plan); err != nil { + return err + } + cmdData.Stdout.Printf("Updated to %s. Run `zcli version` to confirm.\n", plan.Target) + return nil + }) +} diff --git a/src/version/install_method.go b/src/version/install_method.go index 62cd88fe..a293ab5d 100644 --- a/src/version/install_method.go +++ b/src/version/install_method.go @@ -39,7 +39,7 @@ func (m InstallMethod) Hint() string { case InstallBrew: return "Update via Homebrew: brew upgrade zcli" default: - return "Update via https://github.com/zeropsio/zcli#install-zcli" + return "Run: zcli upgrade" } } diff --git a/src/version/upgrade.go b/src/version/upgrade.go new file mode 100644 index 00000000..2f1e1522 --- /dev/null +++ b/src/version/upgrade.go @@ -0,0 +1,137 @@ +package version + +import ( + "bytes" + "context" + "crypto" + _ "crypto/sha256" // register SHA-256 for selfupdate.Apply + "encoding/hex" + "fmt" + "net/http" + "runtime" + "strings" + "time" + + "github.com/minio/selfupdate" + "github.com/pkg/errors" + + "github.com/zeropsio/zcli/src/httpClient" +) + +const ( + githubAssetUrl = "https://github.com/zeropsio/zcli/releases/download/%s/%s" + checksumsName = "checksums.txt" + downloadTimeout = 2 * time.Minute +) + +type UpgradeOptions struct { + // TargetVersion is the release tag to install (e.g. "v0.9.0"). Empty + // means the latest known release. + TargetVersion string +} + +type UpgradePlan struct { + Current string + Target string +} + +// PlanUpgrade resolves the upgrade target and refuses package-managed +// installs. Used by --check and as the input to Upgrade. +func PlanUpgrade(ctx context.Context, opts UpgradeOptions) (*UpgradePlan, error) { + if method := Detect(); method.IsPackageManager() { + return nil, errors.Errorf("zcli was installed via %s; %s", method, method.Hint()) + } + target := opts.TargetVersion + if target == "" { + resp, err := fetch(ctx) + if err != nil { + return nil, errors.Wrap(err, "resolve latest version") + } + target = resp.TagName + } + return &UpgradePlan{Current: GetCurrent(), Target: target}, nil +} + +// Upgrade downloads the target binary, verifies its sha256 against the +// release's checksums.txt, and atomically swaps the running binary. +func Upgrade(ctx context.Context, plan *UpgradePlan) error { + asset := assetName() + ctx, cancel := context.WithTimeout(ctx, downloadTimeout) + defer cancel() + + checksumsBody, err := httpGet(ctx, assetUrl(plan.Target, checksumsName)) + if err != nil { + return errors.Wrap(err, "fetch checksums.txt") + } + expected, err := parseChecksum(string(checksumsBody), asset) + if err != nil { + return err + } + + binary, err := httpGet(ctx, assetUrl(plan.Target, asset)) + if err != nil { + return errors.Wrap(err, "download binary") + } + + if err := selfupdate.Apply(bytes.NewReader(binary), selfupdate.Options{ + Checksum: expected, + Hash: crypto.SHA256, + }); err != nil { + if strings.Contains(strings.ToLower(err.Error()), "permission denied") { + return errors.New("permission denied; re-run as `sudo zcli upgrade`") + } + return errors.Wrap(err, "apply update") + } + return nil +} + +func assetUrl(tag, asset string) string { + return fmt.Sprintf(githubAssetUrl, tag, asset) +} + +// assetName returns the release asset filename for the current platform. +func assetName() string { + return assetNameFor(runtime.GOOS, runtime.GOARCH) +} + +// assetNameFor matches the naming in .github/workflows/release.yml: +// linux/amd64, linux/i386 (GOARCH=386), darwin/amd64, darwin/arm64, +// windows/amd64 (named "win-x64.exe"). +func assetNameFor(goos, goarch string) string { + switch { + case goos == "windows": + return "zcli-win-x64.exe" + case goarch == "386": + return fmt.Sprintf("zcli-%s-i386", goos) + default: + return fmt.Sprintf("zcli-%s-%s", goos, goarch) + } +} + +// parseChecksum extracts the sha256 hex for asset out of a sha256sum-style +// body (lines of " " or " *"). +func parseChecksum(body, asset string) ([]byte, error) { + for _, line := range strings.Split(body, "\n") { + fields := strings.Fields(strings.TrimSpace(line)) + if len(fields) != 2 { + continue + } + name := strings.TrimPrefix(fields[1], "*") + if name == asset { + return hex.DecodeString(fields[0]) + } + } + return nil, errors.Errorf("asset %s not listed in %s", asset, checksumsName) +} + +func httpGet(ctx context.Context, url string) ([]byte, error) { + client := httpClient.New(ctx, httpClient.Config{HttpTimeout: downloadTimeout}) + resp, err := client.Get(ctx, url) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, errors.Errorf("%s: status %d", url, resp.StatusCode) + } + return resp.Body, nil +} diff --git a/src/version/upgrade_test.go b/src/version/upgrade_test.go new file mode 100644 index 00000000..67e31771 --- /dev/null +++ b/src/version/upgrade_test.go @@ -0,0 +1,89 @@ +package version + +import ( + "bytes" + "testing" +) + +func TestAssetNameFor(t *testing.T) { + cases := []struct { + goos, goarch string + want string + }{ + {"linux", "amd64", "zcli-linux-amd64"}, + {"linux", "386", "zcli-linux-i386"}, + {"darwin", "amd64", "zcli-darwin-amd64"}, + {"darwin", "arm64", "zcli-darwin-arm64"}, + {"windows", "amd64", "zcli-win-x64.exe"}, + {"windows", "386", "zcli-win-x64.exe"}, // windows always maps to the same asset + } + for _, tc := range cases { + if got := assetNameFor(tc.goos, tc.goarch); got != tc.want { + t.Errorf("assetNameFor(%q, %q) = %q, want %q", tc.goos, tc.goarch, got, tc.want) + } + } +} + +func TestParseChecksum(t *testing.T) { + body := `abc123def4567890abcdef1234567890abcdef1234567890abcdef1234567890 zcli-linux-amd64 +0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef *zcli-darwin-arm64 +fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210 checksums.txt +` + t.Run("standard format", func(t *testing.T) { + got, err := parseChecksum(body, "zcli-linux-amd64") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []byte{0xab, 0xc1, 0x23, 0xde, 0xf4, 0x56, 0x78, 0x90} + if !bytes.Equal(got[:8], want) { + t.Errorf("got first 8 bytes %x, want %x", got[:8], want) + } + }) + + t.Run("starred name", func(t *testing.T) { + got, err := parseChecksum(body, "zcli-darwin-arm64") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 32 { + t.Errorf("sha256 should be 32 bytes, got %d", len(got)) + } + }) + + t.Run("missing asset", func(t *testing.T) { + _, err := parseChecksum(body, "zcli-darwin-amd64") + if err == nil { + t.Fatal("expected error for missing asset") + } + }) + + t.Run("malformed lines skipped", func(t *testing.T) { + bad := "garbage\n\nonefield\nabc123 zcli-linux-amd64\n" + _, err := parseChecksum(bad, "zcli-linux-amd64") + if err != nil { + t.Errorf("expected to find asset past bad lines: %v", err) + } + }) + + t.Run("invalid hex", func(t *testing.T) { + bad := "zzznot-hex zcli-linux-amd64\n" + _, err := parseChecksum(bad, "zcli-linux-amd64") + if err == nil { + t.Fatal("expected error for invalid hex") + } + }) +} + +func TestPlanUpgradeRefusesPackageManager(t *testing.T) { + savedChannel := channel + t.Cleanup(func() { channel = savedChannel }) + + for _, stamp := range []string{"npm", "brew", "nix"} { + channel = stamp + plan, err := PlanUpgrade(t.Context(), UpgradeOptions{TargetVersion: "v1.0.0"}) + if err == nil { + t.Errorf("channel %q: expected refusal, got plan %+v", stamp, plan) + continue + } + } +} diff --git a/src/version/version.go b/src/version/version.go index 8bf455ec..e29f5ec5 100644 --- a/src/version/version.go +++ b/src/version/version.go @@ -43,9 +43,9 @@ func GetLatestUrl(ctx context.Context) (string, error) { if err != nil { return "", err } - assetName := fmt.Sprintf("zcli-%s-%s", runtime.GOOS, runtime.GOARCH) + want := assetName() for _, asset := range resp.Assets { - if asset.Name == assetName { + if asset.Name == want { return asset.BrowserDownloadUrl, nil } } diff --git a/src/version/version_test.go b/src/version/version_test.go index cf6b898c..15c59af5 100644 --- a/src/version/version_test.go +++ b/src/version/version_test.go @@ -66,7 +66,7 @@ func TestMismatchWarning(t *testing.T) { {"npm", "npm install -g @zerops/zcli"}, {"brew", "brew upgrade zcli"}, {"nix", "rebuild your profile or flake"}, - {"manual", "github.com/zeropsio/zcli"}, + {"manual", "zcli upgrade"}, } for _, tc := range cases { channel = tc.stamp From 9a3bb9a18962dd8b0ab13e0f76ecdedf364aa083 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Hellmann?= Date: Fri, 15 May 2026 16:29:45 +0200 Subject: [PATCH 05/11] feat(version): --check for PM installs, mockable HTTP, spinner UX - `zcli upgrade --check` no longer refuses package-managed installs. Splits PlanUpgrade (always succeeds) from RequireSelfUpdatable (the channel guard), which the cmd only calls after --check has had its chance to report status. - Make `releasesURL` and the `selfupdate.Apply` call swappable package-level vars so tests can drive the full download + verify path against an httptest server without replacing the test binary. - Add end-to-end Upgrade tests: happy path (verifies the manifest -> asset -> checksum wiring), missing asset, manifest 500, binary 404, and the permission-denied -> sudo hint. - Wrap the actual download + verify + swap call in uxHelpers.ProcessCheckWithSpinner so the interactive flow shows a running spinner with friendly success/failure messages. - go.mod: minio/selfupdate promoted to a direct dep (now referenced by upgrade.go and the new tests). --- src/cmd/upgrade.go | 22 +++-- src/version/upgrade.go | 35 +++++-- src/version/upgrade_test.go | 177 +++++++++++++++++++++++++++++++++++- 3 files changed, 217 insertions(+), 17 deletions(-) diff --git a/src/cmd/upgrade.go b/src/cmd/upgrade.go index 2ab5caf7..54a829a0 100644 --- a/src/cmd/upgrade.go +++ b/src/cmd/upgrade.go @@ -42,6 +42,10 @@ func upgradeCmd() *cmdBuilder.Cmd { os.Exit(1) } + if err := getVersion.RequireSelfUpdatable(); err != nil { + return err + } + if plan.Current == plan.Target && targetVersion == "" { cmdData.Stdout.Printf("zcli is already on %s.\n", plan.Current) return nil @@ -63,11 +67,17 @@ func upgradeCmd() *cmdBuilder.Cmd { } } - cmdData.Stdout.Printf("Downloading %s ...\n", plan.Target) - if err := getVersion.Upgrade(ctx, plan); err != nil { - return err - } - cmdData.Stdout.Printf("Updated to %s. Run `zcli version` to confirm.\n", plan.Target) - return nil + return uxHelpers.ProcessCheckWithSpinner( + ctx, + cmdData.UxBlocks, + []uxHelpers.Process{{ + F: func(ctx context.Context, _ *uxHelpers.Process) error { + return getVersion.Upgrade(ctx, plan) + }, + RunningMessage: fmt.Sprintf("Downloading and installing %s", plan.Target), + ErrorMessageMessage: fmt.Sprintf("Upgrade to %s failed", plan.Target), + SuccessMessage: fmt.Sprintf("Updated to %s. Run `zcli version` to confirm.", plan.Target), + }}, + ) }) } diff --git a/src/version/upgrade.go b/src/version/upgrade.go index 2f1e1522..82254cda 100644 --- a/src/version/upgrade.go +++ b/src/version/upgrade.go @@ -7,6 +7,7 @@ import ( _ "crypto/sha256" // register SHA-256 for selfupdate.Apply "encoding/hex" "fmt" + "io" "net/http" "runtime" "strings" @@ -19,11 +20,20 @@ import ( ) const ( - githubAssetUrl = "https://github.com/zeropsio/zcli/releases/download/%s/%s" checksumsName = "checksums.txt" downloadTimeout = 2 * time.Minute ) +// releasesURL is the printf template for release-asset URLs. Tests override +// it to point Upgrade at an httptest server. +var releasesURL = "https://github.com/zeropsio/zcli/releases/download/%s/%s" + +// applyUpdate is the binary swap implementation. Tests override it with a +// stub so the test binary isn't actually replaced. +var applyUpdate = func(r io.Reader, opts selfupdate.Options) error { + return selfupdate.Apply(r, opts) +} + type UpgradeOptions struct { // TargetVersion is the release tag to install (e.g. "v0.9.0"). Empty // means the latest known release. @@ -35,12 +45,11 @@ type UpgradePlan struct { Target string } -// PlanUpgrade resolves the upgrade target and refuses package-managed -// installs. Used by --check and as the input to Upgrade. +// PlanUpgrade resolves the upgrade target. Always succeeds for valid input, +// regardless of install method, so callers like `--check` can report status +// for package-managed installs too. Use RequireSelfUpdatable to enforce the +// channel restriction at the point where you actually intend to swap. func PlanUpgrade(ctx context.Context, opts UpgradeOptions) (*UpgradePlan, error) { - if method := Detect(); method.IsPackageManager() { - return nil, errors.Errorf("zcli was installed via %s; %s", method, method.Hint()) - } target := opts.TargetVersion if target == "" { resp, err := fetch(ctx) @@ -52,6 +61,16 @@ func PlanUpgrade(ctx context.Context, opts UpgradeOptions) (*UpgradePlan, error) return &UpgradePlan{Current: GetCurrent(), Target: target}, nil } +// RequireSelfUpdatable returns an error when the running binary was installed +// through a package manager and shouldn't be replaced in place. Callers +// should run this before calling Upgrade. +func RequireSelfUpdatable() error { + if method := Detect(); method.IsPackageManager() { + return errors.Errorf("zcli was installed via %s; %s", method, method.Hint()) + } + return nil +} + // Upgrade downloads the target binary, verifies its sha256 against the // release's checksums.txt, and atomically swaps the running binary. func Upgrade(ctx context.Context, plan *UpgradePlan) error { @@ -73,7 +92,7 @@ func Upgrade(ctx context.Context, plan *UpgradePlan) error { return errors.Wrap(err, "download binary") } - if err := selfupdate.Apply(bytes.NewReader(binary), selfupdate.Options{ + if err := applyUpdate(bytes.NewReader(binary), selfupdate.Options{ Checksum: expected, Hash: crypto.SHA256, }); err != nil { @@ -86,7 +105,7 @@ func Upgrade(ctx context.Context, plan *UpgradePlan) error { } func assetUrl(tag, asset string) string { - return fmt.Sprintf(githubAssetUrl, tag, asset) + return fmt.Sprintf(releasesURL, tag, asset) } // assetName returns the release asset filename for the current platform. diff --git a/src/version/upgrade_test.go b/src/version/upgrade_test.go index 67e31771..77f562dd 100644 --- a/src/version/upgrade_test.go +++ b/src/version/upgrade_test.go @@ -2,7 +2,16 @@ package version import ( "bytes" + "context" + "crypto/sha256" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" "testing" + + "github.com/minio/selfupdate" ) func TestAssetNameFor(t *testing.T) { @@ -74,16 +83,178 @@ fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210 checksums.txt }) } -func TestPlanUpgradeRefusesPackageManager(t *testing.T) { +func TestRequireSelfUpdatable(t *testing.T) { savedChannel := channel t.Cleanup(func() { channel = savedChannel }) for _, stamp := range []string{"npm", "brew", "nix"} { + channel = stamp + if err := RequireSelfUpdatable(); err == nil { + t.Errorf("channel %q: expected refusal, got nil", stamp) + } + } + + channel = "manual" + if err := RequireSelfUpdatable(); err != nil { + t.Errorf("manual channel: expected no refusal, got %v", err) + } +} + +func TestPlanUpgradeAlwaysSucceeds(t *testing.T) { + savedChannel := channel + t.Cleanup(func() { channel = savedChannel }) + + for _, stamp := range []string{"manual", "npm", "brew", "nix"} { channel = stamp plan, err := PlanUpgrade(t.Context(), UpgradeOptions{TargetVersion: "v1.0.0"}) - if err == nil { - t.Errorf("channel %q: expected refusal, got plan %+v", stamp, plan) + if err != nil { + t.Errorf("channel %q: expected plan, got error %v", stamp, err) continue } + if plan.Target != "v1.0.0" { + t.Errorf("channel %q: target = %q, want v1.0.0", stamp, plan.Target) + } + } +} + +// upgradeFixture wires a fake release server and a recording applyUpdate +// stub. The returned cleanup restores the package-level overrides. +type upgradeFixture struct { + server *httptest.Server + binary []byte + checksum [32]byte + applied []byte + applyOpt selfupdate.Options + applyErr error +} + +func newUpgradeFixture(t *testing.T, handler http.HandlerFunc) *upgradeFixture { + t.Helper() + fix := &upgradeFixture{ + binary: []byte("pretend this is a zcli binary"), + } + fix.checksum = sha256.Sum256(fix.binary) + + if handler == nil { + handler = fix.defaultHandler + } + fix.server = httptest.NewServer(handler) + + savedURL := releasesURL + savedApply := applyUpdate + releasesURL = fix.server.URL + "/%s/%s" + applyUpdate = func(r io.Reader, opts selfupdate.Options) error { + b, err := io.ReadAll(r) + if err != nil { + return err + } + fix.applied = b + fix.applyOpt = opts + return fix.applyErr + } + + t.Cleanup(func() { + fix.server.Close() + releasesURL = savedURL + applyUpdate = savedApply + }) + return fix +} + +func (f *upgradeFixture) defaultHandler(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/"+checksumsName): + fmt.Fprintf(w, "%x %s\n", f.checksum, assetName()) + case strings.HasSuffix(r.URL.Path, "/"+assetName()): + _, _ = w.Write(f.binary) + default: + http.NotFound(w, r) + } +} + +func TestUpgradeHappyPath(t *testing.T) { + fix := newUpgradeFixture(t, nil) + + plan := &UpgradePlan{Current: "v0.9.0", Target: "v1.0.0"} + if err := Upgrade(context.Background(), plan); err != nil { + t.Fatalf("Upgrade: %v", err) + } + if !bytes.Equal(fix.applied, fix.binary) { + t.Errorf("applied binary mismatch: got %q, want %q", fix.applied, fix.binary) + } + if !bytes.Equal(fix.applyOpt.Checksum, fix.checksum[:]) { + t.Errorf("checksum passed to selfupdate = %x, want %x", fix.applyOpt.Checksum, fix.checksum[:]) + } +} + +func TestUpgradeAssetNotListed(t *testing.T) { + fix := newUpgradeFixture(t, nil) + // Override the handler to omit the platform asset from checksums.txt. + fix.server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/"+checksumsName): + fmt.Fprintln(w, "deadbeef some-other-asset") + default: + http.NotFound(w, r) + } + }) + + plan := &UpgradePlan{Current: "v0.9.0", Target: "v1.0.0"} + err := Upgrade(context.Background(), plan) + if err == nil { + t.Fatal("expected error when asset is missing from checksums.txt") + } + if !strings.Contains(err.Error(), "not listed") { + t.Errorf("error message %q should mention the missing asset", err) + } +} + +func TestUpgradeChecksumsUnreachable(t *testing.T) { + newUpgradeFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + })) + + plan := &UpgradePlan{Current: "v0.9.0", Target: "v1.0.0"} + err := Upgrade(context.Background(), plan) + if err == nil { + t.Fatal("expected error when checksums.txt fetch fails") + } + if !strings.Contains(err.Error(), "checksums.txt") { + t.Errorf("error %q should mention checksums.txt", err) + } +} + +func TestUpgradeBinaryNotFound(t *testing.T) { + fix := newUpgradeFixture(t, nil) + // Serve checksums.txt but 404 the binary. + fix.server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/"+checksumsName) { + fmt.Fprintf(w, "%x %s\n", fix.checksum, assetName()) + return + } + http.NotFound(w, r) + }) + + plan := &UpgradePlan{Current: "v0.9.0", Target: "v1.0.0"} + err := Upgrade(context.Background(), plan) + if err == nil { + t.Fatal("expected error when binary fetch fails") + } + if !strings.Contains(err.Error(), "download binary") { + t.Errorf("error %q should mention binary download", err) + } +} + +func TestUpgradeApplyError(t *testing.T) { + fix := newUpgradeFixture(t, nil) + fix.applyErr = fmt.Errorf("permission denied: cannot replace binary") + + plan := &UpgradePlan{Current: "v0.9.0", Target: "v1.0.0"} + err := Upgrade(context.Background(), plan) + if err == nil { + t.Fatal("expected error when apply fails") + } + if !strings.Contains(err.Error(), "sudo zcli upgrade") { + t.Errorf("permission errors should suggest sudo, got %q", err) } } From 8a63d213c53c8f254e8fd75a9d29e7b060e06806 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Hellmann?= Date: Wed, 20 May 2026 22:08:13 +0200 Subject: [PATCH 06/11] ci(release): name goreleaser checksum file checksums.txt The self-updater (zcli upgrade) fetches the release asset literally named checksums.txt to verify downloaded binaries. goreleaser defaults to zcli__checksums.txt, so pin the name_template to match. Replaces the manual checksums job that targeted the pre-goreleaser release pipeline. --- .goreleaser.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 708ea859..3d2fcf83 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -117,6 +117,9 @@ nfpms: file_name_template: >- zcli_{{ .Tag }}_{{ if eq .Arch "386" }}i386{{ else }}{{ .Arch }}{{ end }} +checksum: + name_template: "checksums.txt" + release: mode: append prerelease: auto From 557c83c8634619ba8924759fbdc129dea2ee3296 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Hellmann?= Date: Wed, 20 May 2026 22:18:14 +0200 Subject: [PATCH 07/11] fix(version): satisfy main's golangci config after rebase main's lint config is stricter than the branch was developed against: - rename the cached fetch error var fetchErr -> errFetch (errname) - list InstallManual explicitly in the String/Hint switches (exhaustive) - collapse the applyUpdate lambda to a direct selfupdate.Apply reference and drop the now-unused io import (gocritic unlambda) --- src/version/install_method.go | 4 ++++ src/version/upgrade.go | 5 +---- src/version/version.go | 6 +++--- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/version/install_method.go b/src/version/install_method.go index a293ab5d..04e6f366 100644 --- a/src/version/install_method.go +++ b/src/version/install_method.go @@ -17,6 +17,8 @@ const ( func (m InstallMethod) String() string { switch m { + case InstallManual: + return "manual" case InstallNix: return "nix" case InstallNpm: @@ -32,6 +34,8 @@ func (m InstallMethod) String() string { // Always non-empty so warnings always tell the user what to do. func (m InstallMethod) Hint() string { switch m { + case InstallManual: + return "Run: zcli upgrade" case InstallNix: return "Update via Nix: rebuild your profile or flake." case InstallNpm: diff --git a/src/version/upgrade.go b/src/version/upgrade.go index 82254cda..f7ff0624 100644 --- a/src/version/upgrade.go +++ b/src/version/upgrade.go @@ -7,7 +7,6 @@ import ( _ "crypto/sha256" // register SHA-256 for selfupdate.Apply "encoding/hex" "fmt" - "io" "net/http" "runtime" "strings" @@ -30,9 +29,7 @@ var releasesURL = "https://github.com/zeropsio/zcli/releases/download/%s/%s" // applyUpdate is the binary swap implementation. Tests override it with a // stub so the test binary isn't actually replaced. -var applyUpdate = func(r io.Reader, opts selfupdate.Options) error { - return selfupdate.Apply(r, opts) -} +var applyUpdate = selfupdate.Apply type UpgradeOptions struct { // TargetVersion is the release tag to install (e.g. "v0.9.0"). Empty diff --git a/src/version/version.go b/src/version/version.go index e29f5ec5..ebdcfaef 100644 --- a/src/version/version.go +++ b/src/version/version.go @@ -23,7 +23,7 @@ var version = "local" var ( fetchOnce sync.Once latestResponse *apiResponse - fetchErr error + errFetch error ) func GetCurrent() string { @@ -138,13 +138,13 @@ func fetch(ctx context.Context) (*apiResponse, error) { latestResponse = entry.Response return } - fetchErr = err + errFetch = err return } latestResponse = resp _ = writeCacheEntry(resp) }) - return latestResponse, fetchErr + return latestResponse, errFetch } func fetchFromNetwork(ctx context.Context) (*apiResponse, error) { From 2083fe4120148bb6ad2c1c05eb75ebfb02d964a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Hellmann?= Date: Wed, 20 May 2026 23:31:31 +0200 Subject: [PATCH 08/11] feat(version): stamp install channel per goreleaser artifact Add an InstallDeb method and stamp version.channel via ldflags for every goreleaser-built artifact: raw=manual (self-updatable), npm=npm, deb=deb. This requires splitting the previously-shared raw/npm builds since they ship the same code under different channels. Path detection in src/version is now only a fallback for builds that aren't stamped (plain go build, brew, nix). A dpkg-installed zcli now refuses self-update and points at the .deb instead. --- .goreleaser.yaml | 99 +++++++++++++++++++++++++----- src/version/install_method.go | 16 +++-- src/version/install_method_test.go | 5 +- src/version/upgrade_test.go | 4 +- 4 files changed, 99 insertions(+), 25 deletions(-) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 3d2fcf83..a81a5c06 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -2,8 +2,16 @@ version: 2 project_name: zcli +# The channel ldflag stamps version.InstallMethod so `zcli upgrade` knows how +# the running binary was installed. raw (GitHub release / install.sh) is the +# only self-updatable channel; npm and deb refuse self-update and point at +# their package manager. Path detection in src/version is only a fallback. +# +# raw and npm ship the same code but with different channel stamps, so they +# need separate build entries even though they target the same platforms. builds: - - id: linux-amd64 + # --- raw: direct download / install.sh, self-updatable (channel=manual) --- + - id: raw-linux-amd64 main: ./cmd/zcli/main.go binary: zcli-linux-amd64 goos: [linux] @@ -12,9 +20,9 @@ builds: - CGO_ENABLED=0 flags: [-trimpath] ldflags: - - -s -w -X github.com/zeropsio/zcli/src/version.version={{ .Tag }} + - -s -w -X github.com/zeropsio/zcli/src/version.version={{ .Tag }} -X github.com/zeropsio/zcli/src/version.channel=manual - - id: linux-386 + - id: raw-linux-386 main: ./cmd/zcli/main.go binary: zcli-linux-i386 goos: [linux] @@ -23,9 +31,9 @@ builds: - CGO_ENABLED=0 flags: [-trimpath] ldflags: - - -s -w -X github.com/zeropsio/zcli/src/version.version={{ .Tag }} + - -s -w -X github.com/zeropsio/zcli/src/version.version={{ .Tag }} -X github.com/zeropsio/zcli/src/version.channel=manual - - id: darwin-amd64 + - id: raw-darwin-amd64 main: ./cmd/zcli/main.go binary: zcli-darwin-amd64 goos: [darwin] @@ -34,9 +42,9 @@ builds: - CGO_ENABLED=0 flags: [-trimpath] ldflags: - - -s -w -X github.com/zeropsio/zcli/src/version.version={{ .Tag }} + - -s -w -X github.com/zeropsio/zcli/src/version.version={{ .Tag }} -X github.com/zeropsio/zcli/src/version.channel=manual - - id: darwin-arm64 + - id: raw-darwin-arm64 main: ./cmd/zcli/main.go binary: zcli-darwin-arm64 goos: [darwin] @@ -45,9 +53,9 @@ builds: - CGO_ENABLED=0 flags: [-trimpath] ldflags: - - -s -w -X github.com/zeropsio/zcli/src/version.version={{ .Tag }} + - -s -w -X github.com/zeropsio/zcli/src/version.version={{ .Tag }} -X github.com/zeropsio/zcli/src/version.channel=manual - - id: windows-amd64 + - id: raw-windows-amd64 main: ./cmd/zcli/main.go binary: zcli-win-x64 goos: [windows] @@ -56,11 +64,68 @@ builds: - CGO_ENABLED=0 flags: [-trimpath] ldflags: - - -s -w -X github.com/zeropsio/zcli/src/version.version={{ .Tag }} + - -s -w -X github.com/zeropsio/zcli/src/version.version={{ .Tag }} -X github.com/zeropsio/zcli/src/version.channel=manual - # Deb-only builds: binary is named "zcli" so the package installs - # /usr/local/bin/zcli rather than the arch-suffixed name the archives - # and npm wrapper rely on. + # --- npm: published as @zerops/zcli, refuses self-update (channel=npm) --- + - id: npm-linux-amd64 + main: ./cmd/zcli/main.go + binary: zcli-linux-amd64 + goos: [linux] + goarch: [amd64] + env: + - CGO_ENABLED=0 + flags: [-trimpath] + ldflags: + - -s -w -X github.com/zeropsio/zcli/src/version.version={{ .Tag }} -X github.com/zeropsio/zcli/src/version.channel=npm + + - id: npm-linux-386 + main: ./cmd/zcli/main.go + binary: zcli-linux-i386 + goos: [linux] + goarch: ["386"] + env: + - CGO_ENABLED=0 + flags: [-trimpath] + ldflags: + - -s -w -X github.com/zeropsio/zcli/src/version.version={{ .Tag }} -X github.com/zeropsio/zcli/src/version.channel=npm + + - id: npm-darwin-amd64 + main: ./cmd/zcli/main.go + binary: zcli-darwin-amd64 + goos: [darwin] + goarch: [amd64] + env: + - CGO_ENABLED=0 + flags: [-trimpath] + ldflags: + - -s -w -X github.com/zeropsio/zcli/src/version.version={{ .Tag }} -X github.com/zeropsio/zcli/src/version.channel=npm + + - id: npm-darwin-arm64 + main: ./cmd/zcli/main.go + binary: zcli-darwin-arm64 + goos: [darwin] + goarch: [arm64] + env: + - CGO_ENABLED=0 + flags: [-trimpath] + ldflags: + - -s -w -X github.com/zeropsio/zcli/src/version.version={{ .Tag }} -X github.com/zeropsio/zcli/src/version.channel=npm + + - id: npm-windows-amd64 + main: ./cmd/zcli/main.go + binary: zcli-win-x64 + goos: [windows] + goarch: [amd64] + env: + - CGO_ENABLED=0 + flags: [-trimpath] + ldflags: + - -s -w -X github.com/zeropsio/zcli/src/version.version={{ .Tag }} -X github.com/zeropsio/zcli/src/version.channel=npm + + # --- deb: dpkg-installed to /usr/local/bin/zcli, refuses self-update. + # binary is named "zcli" (not arch-suffixed) so the package installs a plain + # /usr/local/bin/zcli. channel=deb because that path is otherwise + # indistinguishable from a manual install. - id: deb-amd64 main: ./cmd/zcli/main.go binary: zcli @@ -70,7 +135,7 @@ builds: - CGO_ENABLED=0 flags: [-trimpath] ldflags: - - -s -w -X github.com/zeropsio/zcli/src/version.version={{ .Tag }} + - -s -w -X github.com/zeropsio/zcli/src/version.version={{ .Tag }} -X github.com/zeropsio/zcli/src/version.channel=deb - id: deb-386 main: ./cmd/zcli/main.go @@ -81,11 +146,11 @@ builds: - CGO_ENABLED=0 flags: [-trimpath] ldflags: - - -s -w -X github.com/zeropsio/zcli/src/version.version={{ .Tag }} + - -s -w -X github.com/zeropsio/zcli/src/version.version={{ .Tag }} -X github.com/zeropsio/zcli/src/version.channel=deb archives: - id: raw - ids: [linux-amd64, linux-386, darwin-amd64, darwin-arm64, windows-amd64] + ids: [raw-linux-amd64, raw-linux-386, raw-darwin-amd64, raw-darwin-arm64, raw-windows-amd64] formats: [binary] name_template: >- {{- if eq .Os "windows" -}}zcli-win-x64 @@ -94,7 +159,7 @@ archives: {{- end -}} - id: npm - ids: [linux-amd64, linux-386, darwin-amd64, darwin-arm64, windows-amd64] + ids: [npm-linux-amd64, npm-linux-386, npm-darwin-amd64, npm-darwin-arm64, npm-windows-amd64] formats: [tar.gz] wrap_in_directory: builds files: diff --git a/src/version/install_method.go b/src/version/install_method.go index 04e6f366..90b3c720 100644 --- a/src/version/install_method.go +++ b/src/version/install_method.go @@ -13,6 +13,7 @@ const ( InstallNix InstallNpm InstallBrew + InstallDeb ) func (m InstallMethod) String() string { @@ -25,6 +26,8 @@ func (m InstallMethod) String() string { return "npm" case InstallBrew: return "homebrew" + case InstallDeb: + return "deb" default: return "manual" } @@ -42,6 +45,8 @@ func (m InstallMethod) Hint() string { return "Update via npm: npm install -g @zerops/zcli" case InstallBrew: return "Update via Homebrew: brew upgrade zcli" + case InstallDeb: + return "Update by installing the latest .deb from https://github.com/zeropsio/zcli/releases" default: return "Run: zcli upgrade" } @@ -51,10 +56,11 @@ func (m InstallMethod) IsPackageManager() bool { return m != InstallManual } -// channel is stamped by packagers via `-ldflags "-X .../version.channel="`. -// Empty means "not stamped" — Detect() falls back to path-based heuristics. -// Packagers who install to paths indistinguishable from a manual install -// (notably AUR and winget MSI installers) should set this. +// channel is stamped at build time via `-ldflags "-X .../version.channel="`. +// Official builds set it for every distribution (manual/npm/deb in goreleaser, +// brew/nix in their own packaging); Detect() only falls back to path-based +// heuristics when it's empty (e.g. a plain `go build` or a packager that +// forgot to stamp). Empty means "not stamped". var channel = "" // Detect returns the channel the running binary was installed through. The @@ -87,6 +93,8 @@ func parseChannel(c string) (InstallMethod, bool) { return InstallNpm, true case "brew": return InstallBrew, true + case "deb": + return InstallDeb, true default: return 0, false } diff --git a/src/version/install_method_test.go b/src/version/install_method_test.go index 01b85747..5e78d6dc 100644 --- a/src/version/install_method_test.go +++ b/src/version/install_method_test.go @@ -36,6 +36,7 @@ func TestParseChannel(t *testing.T) { {"nix", InstallNix, true}, {"npm", InstallNpm, true}, {"brew", InstallBrew, true}, + {"deb", InstallDeb, true}, {"manual", InstallManual, true}, {"", 0, false}, {"unknown", 0, false}, @@ -64,12 +65,12 @@ func TestDetectChannelStamp(t *testing.T) { } func TestInstallMethodPackageManager(t *testing.T) { - for _, m := range []InstallMethod{InstallNix, InstallNpm, InstallBrew, InstallManual} { + for _, m := range []InstallMethod{InstallNix, InstallNpm, InstallBrew, InstallDeb, InstallManual} { if m.Hint() == "" { t.Errorf("%v should have a non-empty hint", m) } } - for _, m := range []InstallMethod{InstallNix, InstallNpm, InstallBrew} { + for _, m := range []InstallMethod{InstallNix, InstallNpm, InstallBrew, InstallDeb} { if !m.IsPackageManager() { t.Errorf("%v should be a package manager", m) } diff --git a/src/version/upgrade_test.go b/src/version/upgrade_test.go index 77f562dd..ef98ea84 100644 --- a/src/version/upgrade_test.go +++ b/src/version/upgrade_test.go @@ -87,7 +87,7 @@ func TestRequireSelfUpdatable(t *testing.T) { savedChannel := channel t.Cleanup(func() { channel = savedChannel }) - for _, stamp := range []string{"npm", "brew", "nix"} { + for _, stamp := range []string{"npm", "brew", "nix", "deb"} { channel = stamp if err := RequireSelfUpdatable(); err == nil { t.Errorf("channel %q: expected refusal, got nil", stamp) @@ -104,7 +104,7 @@ func TestPlanUpgradeAlwaysSucceeds(t *testing.T) { savedChannel := channel t.Cleanup(func() { channel = savedChannel }) - for _, stamp := range []string{"manual", "npm", "brew", "nix"} { + for _, stamp := range []string{"manual", "npm", "brew", "nix", "deb"} { channel = stamp plan, err := PlanUpgrade(t.Context(), UpgradeOptions{TargetVersion: "v1.0.0"}) if err != nil { From 9bb4f256f3f8e1584d30fea1a94ac236177d5a8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Hellmann?= Date: Wed, 20 May 2026 23:31:42 +0200 Subject: [PATCH 09/11] feat(cmd): exit-code-carrying errors for upgrade --check Add errorsx.ExitError, recognized by RunRootCmd's error handler, so a command can signal a specific process exit code by returning instead of calling os.Exit. upgrade --check now returns ExitError(1)/ExitError(2) rather than os.Exit, preserving the documented 0/1/2 contract while staying testable in-process (os.Exit would kill the test runner). --- src/cmd/upgrade.go | 6 +++--- src/cmdBuilder/executeRootCmd.go | 7 +++++++ src/errorsx/exitError.go | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 3 deletions(-) create mode 100644 src/errorsx/exitError.go diff --git a/src/cmd/upgrade.go b/src/cmd/upgrade.go index 54a829a0..d3dbeee6 100644 --- a/src/cmd/upgrade.go +++ b/src/cmd/upgrade.go @@ -3,9 +3,9 @@ package cmd import ( "context" "fmt" - "os" "github.com/zeropsio/zcli/src/cmdBuilder" + "github.com/zeropsio/zcli/src/errorsx" "github.com/zeropsio/zcli/src/uxBlock/models/prompt" "github.com/zeropsio/zcli/src/uxBlock/styles" "github.com/zeropsio/zcli/src/uxHelpers" @@ -29,7 +29,7 @@ func upgradeCmd() *cmdBuilder.Cmd { if err != nil { if check { cmdData.Stderr.Printf("error: %s\n", err) - os.Exit(2) + return errorsx.NewExitError(2) } return err } @@ -39,7 +39,7 @@ func upgradeCmd() *cmdBuilder.Cmd { if plan.Current == plan.Target { return nil } - os.Exit(1) + return errorsx.NewExitError(1) } if err := getVersion.RequireSelfUpdatable(); err != nil { diff --git a/src/cmdBuilder/executeRootCmd.go b/src/cmdBuilder/executeRootCmd.go index 95c00f3a..dd08bb4c 100644 --- a/src/cmdBuilder/executeRootCmd.go +++ b/src/cmdBuilder/executeRootCmd.go @@ -132,6 +132,13 @@ func errorExitCode(err error, uxBlocks uxBlock.UxBlocks) int { } uxBlocks.LogDebug(fmt.Sprintf("error: %+v", err)) + // A command that manages its own output and needs a specific exit status + // (e.g. `upgrade --check`) signals it with an ExitError; return the code + // as-is without printing anything further. + if exitErr := errorsx.AsExitError(err); exitErr != nil { + return exitErr.Code + } + if userErr := errorsx.AsUserError(err); userErr != nil { uxBlocks.PrintErrorText(err.Error()) return 1 diff --git a/src/errorsx/exitError.go b/src/errorsx/exitError.go new file mode 100644 index 00000000..179f4365 --- /dev/null +++ b/src/errorsx/exitError.go @@ -0,0 +1,32 @@ +package errorsx + +import ( + "fmt" + + "github.com/pkg/errors" +) + +// ExitError carries a specific process exit code. RunRootCmd's error handler +// returns the code as-is without printing anything, so a command that needs a +// non-standard exit status (e.g. `upgrade --check`, which distinguishes +// up-to-date / behind / error as 0 / 1 / 2) can do its own output and signal +// the code by returning an ExitError. +type ExitError struct { + Code int +} + +func NewExitError(code int) *ExitError { + return &ExitError{Code: code} +} + +func AsExitError(err error) *ExitError { + var exitError *ExitError + if errors.As(err, &exitError) { + return exitError + } + return nil +} + +func (e *ExitError) Error() string { + return fmt.Sprintf("exit code %d", e.Code) +} From 82beb9ebab4970ceb5ff4feeeaa734bc22194287 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Hellmann?= Date: Wed, 20 May 2026 23:31:47 +0200 Subject: [PATCH 10/11] refactor(version): env-overridable API URL, stateless fetch Resolve the version API endpoint at call time, honoring ZEROPS_VERSION_API_URL so tests (and mirrors) can point it elsewhere without rebuilding. Drop the package-level sync.Once/latestResponse memoization: fetch() now relies on the on-disk cache for within-invocation dedup, which removes the global mutable state and the test-only reset hook it required. --- src/constants/zerops.go | 1 + src/version/version.go | 61 +++++++++++++++++++++-------------------- 2 files changed, 33 insertions(+), 29 deletions(-) diff --git a/src/constants/zerops.go b/src/constants/zerops.go index a382d340..51de56b7 100644 --- a/src/constants/zerops.go +++ b/src/constants/zerops.go @@ -28,6 +28,7 @@ const ( CliZcliYamlFilePathEnvVar = "ZEROPS_CLI_YAML_FILE_PATH" CliTerminalMode = "ZEROPS_CLI_TERMINAL_MODE" CliTokenEnvVar = "ZEROPS_TOKEN" + VersionApiUrlEnvVar = "ZEROPS_VERSION_API_URL" ) type pathReceiver func(fileMode os.FileMode) (path string, err error) diff --git a/src/version/version.go b/src/version/version.go index ebdcfaef..e500733a 100644 --- a/src/version/version.go +++ b/src/version/version.go @@ -5,26 +5,30 @@ import ( "encoding/json" "fmt" "net/http" + "os" "runtime" - "sync" "time" "github.com/pkg/errors" "golang.org/x/mod/semver" + "github.com/zeropsio/zcli/src/constants" "github.com/zeropsio/zcli/src/httpClient" "github.com/zeropsio/zcli/src/printer" ) -const apiUrl = "https://api.app-prg1.zerops.io/api/rest/public/zcli/version" +const defaultApiUrl = "https://api.app-prg1.zerops.io/api/rest/public/zcli/version" -var version = "local" +// apiURL returns the version API endpoint, honoring an env override so tests +// (and mirrors) can point it elsewhere without rebuilding. +func apiURL() string { + if u := os.Getenv(constants.VersionApiUrlEnvVar); u != "" { + return u + } + return defaultApiUrl +} -var ( - fetchOnce sync.Once - latestResponse *apiResponse - errFetch error -) +var version = "local" func GetCurrent() string { return version @@ -125,36 +129,35 @@ func isUpdateAvailable(current, latest string) bool { return semver.Compare(current, latest) < 0 } +// fetch returns the latest-release response, preferring the on-disk cache. +// The cache also dedupes within a single invocation: the first call writes it +// and any later call (e.g. GetLatestUrl after GetLatest) reads it back, so +// there's no need for in-process memoization. func fetch(ctx context.Context) (*apiResponse, error) { - fetchOnce.Do(func() { - if entry, err := loadCacheEntry(); err == nil && entry != nil && entry.Fresh() { - latestResponse = entry.Response - return - } - resp, err := fetchFromNetwork(ctx) - if err != nil { - // Stale cache beats no answer at all. - if entry, cacheErr := loadCacheEntry(); cacheErr == nil && entry != nil { - latestResponse = entry.Response - return - } - errFetch = err - return + if entry, err := loadCacheEntry(); err == nil && entry != nil && entry.Fresh() { + return entry.Response, nil + } + resp, err := fetchFromNetwork(ctx) + if err != nil { + // Stale cache beats no answer at all. + if entry, cacheErr := loadCacheEntry(); cacheErr == nil && entry != nil { + return entry.Response, nil } - latestResponse = resp - _ = writeCacheEntry(resp) - }) - return latestResponse, errFetch + return nil, err + } + _ = writeCacheEntry(resp) + return resp, nil } func fetchFromNetwork(ctx context.Context) (*apiResponse, error) { + url := apiURL() client := httpClient.New(ctx, httpClient.Config{HttpTimeout: time.Second * 5}) - resp, err := client.Get(ctx, apiUrl) + resp, err := client.Get(ctx, url) if err != nil { - return nil, errors.Wrapf(err, "version api request to %s failed", apiUrl) + return nil, errors.Wrapf(err, "version api request to %s failed", url) } if resp.StatusCode != http.StatusOK { - return nil, errors.Errorf("version api %s returned status %d", apiUrl, resp.StatusCode) + return nil, errors.Errorf("version api %s returned status %d", url, resp.StatusCode) } out := &apiResponse{} if err := json.Unmarshal(resp.Body, out); err != nil { From cd44d908f08897353f412c3c9eda982dd226da87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Hellmann?= Date: Wed, 20 May 2026 23:32:00 +0200 Subject: [PATCH 11/11] test(cmd): drop devel build tag, add upgrade integration tests The devel tag no longer changed any build (version_devel.go was removed earlier), it only gated the integration tests out of the normal suite. Drop it entirely: remove //go:build devel from the harness and integration tests, the -tags devel flags from the Makefile and CI, and the related doc mentions. Add in-process integration tests for `zcli upgrade` covering --check (0/1/2), explicit --version, and the already-up-to-date path, driven through the harness with the version API stubbed via ZEROPS_VERSION_API_URL. Un-tagging exposed pre-existing lint debt in the test files (these were never linted before): drop the unused ctx param from the harness Run (clearing the nil-context hits), use exec.CommandContext, and exclude musttag for _test.go (tests marshal internal persisted structs that rely on default JSON keys). --- .github/workflows/main.yml | 2 +- .golangci.yaml | 5 ++ CLAUDE.md | 4 +- Makefile | 10 +-- README.md | 2 +- src/cmd/integration_harness_test.go | 35 +++++----- src/cmd/login_integration_test.go | 4 +- src/cmd/projectList_integration_test.go | 4 +- src/cmd/pushDeploy_bugs_test.go | 4 -- src/cmd/pushDeploy_helpers_test.go | 2 - src/cmd/serviceDeploy_integration_test.go | 3 - src/cmd/servicePushGit_integration_test.go | 25 +++---- src/cmd/servicePush_integration_test.go | 17 ----- src/cmd/upgrade_integration_test.go | 78 ++++++++++++++++++++++ src/cmd/version_integration_test.go | 4 +- 15 files changed, 122 insertions(+), 77 deletions(-) create mode 100644 src/cmd/upgrade_integration_test.go diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index a5346d5c..aee43a0d 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -39,7 +39,7 @@ jobs: go-version-file: "go.mod" - name: test-integration - run: go test -v -tags devel ./src/cmd/... + run: go test -v ./src/cmd/... lint: name: lint diff --git a/.golangci.yaml b/.golangci.yaml index be005a1e..7b832d0d 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -95,6 +95,11 @@ linters: - linters: [staticcheck] path: src/cmdRunner/run.go text: "ST1005:" + # Integration tests marshal internal persisted structs (e.g. + # cliStorage.Data) to seed on-disk state. Those types intentionally rely + # on Go's default field-name JSON keys, so don't require explicit tags. + - linters: [musttag] + path: _test\.go$ issues: max-same-issues: 0 diff --git a/CLAUDE.md b/CLAUDE.md index 4a7d72e6..e8c02f15 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,8 +10,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - `make test` — runs `go test -v ./cmd/... ./src/...`. Run a single test with `go test -v -run TestName ./src//...`. - `make lint` — runs the pinned `./bin/golangci-lint` for darwin/arm64, linux/amd64, and windows/amd64. Config: `.golangci.yaml`. -- `make build-dev` — build a dev binary for the host into `./bin/zcli` (build tag `devel`, debug-friendly via `-gcflags='all=-l -N'`, version injected from git). -- `make all` — cross-builds dev binaries for windows-amd, linux-amd, darwin-amd, darwin-arm. Single-target builds: `make linux-amd` etc. All build targets share the `DEV_BUILD` recipe in the Makefile (version metadata, devel tag, gcflags). +- `make build-dev` — build a dev binary for the host into `./bin/zcli` (debug-friendly via `-gcflags='all=-l -N'`, version injected from git). +- `make all` — cross-builds dev binaries for windows-amd, linux-amd, darwin-amd, darwin-arm. Single-target builds: `make linux-amd` etc. All build targets share the `DEV_BUILD` recipe in the Makefile (version metadata, gcflags). - `make install` — production install into `~/.local/bin/zcli` (same path as the public `install.sh` script). Stripped/optimized, version from `git describe`, same flag set as `.goreleaser.yaml`. - `make install-dev` — installs to `$GOBIN` (or `$GOPATH/bin`) as `zcli-dev` so it sits with other Go dev tools and coexists on PATH with a production `zcli`. - `make goreleaser-check` / `make goreleaser-snapshot` — validate `.goreleaser.yaml` and dry-run a release build to `./dist`. diff --git a/Makefile b/Makefile index ad74197f..3ce3b268 100644 --- a/Makefile +++ b/Makefile @@ -22,7 +22,7 @@ DEV_INSTALL_DIR := $(or $(shell go env GOBIN),$(shell go env GOPATH)/bin) # Dev-build version metadata (matches what tools/build.sh used to compose). DEV_VERSION := $(shell git rev-parse --abbrev-ref HEAD):$(shell git describe --tags 2>/dev/null)-($(shell git config --get user.name):<$(shell git config --get user.email)>) # -gcflags disables inlining and optimizations so the binary is dlv-friendly. -DEV_BUILD := go build -tags devel \ +DEV_BUILD := go build \ -gcflags='all=-l -N' \ -ldflags='-X "github.com/zeropsio/zcli/src/version.version=$(DEV_VERSION)"' @@ -44,8 +44,8 @@ help: ## Show this help. test: ## Run the full Go test suite. go test -v ./cmd/... ./src/... -test-integration: ## Run the integration test suite (devel build tag). - go test -v -tags devel ./src/cmd/... +test-integration: ## Run just the in-process integration tests (src/cmd). + go test -v ./src/cmd/... # Lint each target GOOS in turn so platform-specific build tags get covered. lint: $(BIN)/.golangci-lint-$(GOLANGCI_LINT_VERSION) ## Run golangci-lint for darwin/arm64, linux/amd64, windows/amd64. @@ -55,7 +55,7 @@ lint: $(BIN)/.golangci-lint-$(GOLANGCI_LINT_VERSION) ## Run golangci-lint for da ##@ Build -build-dev: ## Build a dev binary for the host into ./bin/zcli (devel tag, no optimizations). +build-dev: ## Build a dev binary for the host into ./bin/zcli (no optimizations, dlv-friendly). $(DEV_BUILD) -o $(BIN)/zcli ./cmd/zcli all: windows-amd linux-amd darwin-amd darwin-arm ## Cross-build all dev targets. @@ -79,7 +79,7 @@ install: ## Build a production zcli (stripped, optimized) and install it into ~/ $(PROD_BUILD) -o $(PROD_INSTALL_DIR)/zcli ./cmd/zcli @echo "installed $(PROD_INSTALL_DIR)/zcli ($(PROD_VERSION))" -install-dev: ## Build a dev zcli-dev (devel tag, debug-friendly) and install it into $GOBIN. +install-dev: ## Build a dev zcli-dev (debug-friendly) and install it into $GOBIN. $(DEV_BUILD) -o $(DEV_INSTALL_DIR)/zcli-dev ./cmd/zcli @echo "installed $(DEV_INSTALL_DIR)/zcli-dev" diff --git a/README.md b/README.md index e9460fe3..932f086e 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,7 @@ Common targets (run `make help` for the full list): | Target | What it does | |---|---| -| `make build-dev` | Build `./bin/zcli` with the `devel` tag, no optimizations (dlv-friendly). | +| `make build-dev` | Build `./bin/zcli` with no optimizations (dlv-friendly). | | `make install` | Build a production `zcli` and install to `~/.local/bin` (same path as `install.sh`). | | `make install-dev` | Install a `zcli-dev` binary into `$GOBIN` (or `$GOPATH/bin`). | | `make test` | Run the full Go test suite. | diff --git a/src/cmd/integration_harness_test.go b/src/cmd/integration_harness_test.go index f8553c85..089b8602 100644 --- a/src/cmd/integration_harness_test.go +++ b/src/cmd/integration_harness_test.go @@ -1,15 +1,12 @@ -//go:build devel - // Shared scaffolding for the package's integration tests. // -// All integration tests build under the `devel` build tag (so the -// production-version-check HTTP call from src/version is stubbed to a no-op) -// and run via `make test-integration`. They drive the CLI in-process through -// cmdBuilder.RunRootCmd, point the REST client at an httptest.Server, and -// isolate per-test state by setting ZEROPS_CLI_DATA_FILE_PATH, -// ZEROPS_CLI_LOG_FILE_PATH, and ZEROPS_CLI_YAML_FILE_PATH to per-test temp -// files. yamlReader's package-level cache is reset before and after each test. -// See pushDeploy_helpers_test.go for push/deploy-specific helpers. +// Integration tests drive the CLI in-process through cmdBuilder.RunRootCmd, +// point the REST client at an httptest.Server, and isolate per-test state by +// setting ZEROPS_CLI_DATA_FILE_PATH, ZEROPS_CLI_LOG_FILE_PATH, and +// ZEROPS_CLI_YAML_FILE_PATH to per-test temp files. ZEROPS_VERSION_API_URL is +// pointed at the test server so the background version check never reaches the +// real API. yamlReader's package-level cache is reset before and after each +// test. See pushDeploy_helpers_test.go for push/deploy-specific helpers. package cmd @@ -63,6 +60,9 @@ func newFixture(t *testing.T) *fixture { t.Setenv(constants.CliLogFilePathEnvVar, logPath) t.Setenv(constants.CliZcliYamlFilePathEnvVar, yamlPath) t.Setenv(constants.CliTokenEnvVar, "") + // Keep the background version check off the real network. Tests that + // exercise the version API re-point this at a registered handler. + t.Setenv(constants.VersionApiUrlEnvVar, server.URL+"/__version_check__") return &fixture{ t: t, @@ -117,13 +117,16 @@ type result struct { ExitCode int } -// Run executes the CLI in-process with the given args. ctx defaults to -// context.Background() when nil. -func (f *fixture) Run(ctx context.Context, args ...string) result { +// Run executes the CLI in-process with the given args, using the test's +// context. Use RunCtx when a specific (e.g. canceled) context is needed. +func (f *fixture) Run(args ...string) result { + f.t.Helper() + return f.RunCtx(f.t.Context(), args...) +} + +// RunCtx is Run with an explicit context. +func (f *fixture) RunCtx(ctx context.Context, args ...string) result { f.t.Helper() - if ctx == nil { - ctx = context.Background() - } var stdout, stderr bytes.Buffer code := cmdBuilder.RunRootCmd( ctx, diff --git a/src/cmd/login_integration_test.go b/src/cmd/login_integration_test.go index 670ce81c..d527c1ad 100644 --- a/src/cmd/login_integration_test.go +++ b/src/cmd/login_integration_test.go @@ -1,5 +1,3 @@ -//go:build devel - package cmd import ( @@ -33,7 +31,7 @@ func TestLoginCommand_PersistsTokenAndRegion(t *testing.T) { "fullName": "Test User", }) - res := f.Run(nil, "login", "secret-token", "--region-url", f.Server.URL+"/regions") + res := f.Run("login", "secret-token", "--region-url", f.Server.URL+"/regions") require.Equalf(t, 0, res.ExitCode, "stderr=%q", res.Stderr) assert.Contains(t, res.Stderr, "Test User", "success message should name the user") diff --git a/src/cmd/projectList_integration_test.go b/src/cmd/projectList_integration_test.go index d0c04252..333c95a1 100644 --- a/src/cmd/projectList_integration_test.go +++ b/src/cmd/projectList_integration_test.go @@ -1,5 +1,3 @@ -//go:build devel - package cmd import ( @@ -51,7 +49,7 @@ func TestProjectListCommand(t *testing.T) { }}, }) - res := f.Run(nil, "project", "list") + res := f.Run("project", "list") require.Equalf(t, 0, res.ExitCode, "stderr=%q", res.Stderr) for _, want := range []string{"demo-project", "Acme Org", "00000000-0000-0000-0000-0000000000bb"} { diff --git a/src/cmd/pushDeploy_bugs_test.go b/src/cmd/pushDeploy_bugs_test.go index 4dd27e88..8ba8afc8 100644 --- a/src/cmd/pushDeploy_bugs_test.go +++ b/src/cmd/pushDeploy_bugs_test.go @@ -1,5 +1,3 @@ -//go:build devel - package cmd // This file collects integration regression tests that pin previously @@ -34,7 +32,6 @@ func TestServicePushCommand_SetupFlagOverridesAutoMatch(t *testing.T) { s := registerPushStubs(t, f, "backend") res := f.Run( - nil, "service", "push", "--service-id", pushServiceID, "--working-dir", workDir, @@ -166,7 +163,6 @@ func TestServicePushCommand_RunningProcessWithNullAppVersionNoCrash(t *testing.T // the null AppVersion and the second poll's FINISHED status completes // the push. res := f.Run( - nil, "service", "push", "--service-id", pushServiceID, "--working-dir", workDir, diff --git a/src/cmd/pushDeploy_helpers_test.go b/src/cmd/pushDeploy_helpers_test.go index 9150425f..07eb6038 100644 --- a/src/cmd/pushDeploy_helpers_test.go +++ b/src/cmd/pushDeploy_helpers_test.go @@ -1,5 +1,3 @@ -//go:build devel - package cmd import ( diff --git a/src/cmd/serviceDeploy_integration_test.go b/src/cmd/serviceDeploy_integration_test.go index 958fac00..444d53e4 100644 --- a/src/cmd/serviceDeploy_integration_test.go +++ b/src/cmd/serviceDeploy_integration_test.go @@ -1,5 +1,3 @@ -//go:build devel - package cmd import ( @@ -26,7 +24,6 @@ func TestServiceDeployCommand_HappyPath(t *testing.T) { s := registerDeployStubs(t, f, "demo") res := f.Run( - nil, "service", "deploy", "--service-id", pushServiceID, "--working-dir", workDir, diff --git a/src/cmd/servicePushGit_integration_test.go b/src/cmd/servicePushGit_integration_test.go index 74c91e0f..b5663035 100644 --- a/src/cmd/servicePushGit_integration_test.go +++ b/src/cmd/servicePushGit_integration_test.go @@ -1,5 +1,3 @@ -//go:build devel - package cmd import ( @@ -24,7 +22,7 @@ import ( // not pick up the developer's ~/.gitconfig. func runGit(t *testing.T, dir string, args ...string) { t.Helper() - cmd := exec.Command("git", args...) + cmd := exec.CommandContext(t.Context(), "git", args...) cmd.Dir = dir cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", @@ -45,10 +43,10 @@ func gitInit(t *testing.T, dir string) { } // gitAddCommit stages everything in dir and records a commit. -func gitAddCommit(t *testing.T, dir, msg string) { +func gitAddCommit(t *testing.T, dir string) { t.Helper() runGit(t, dir, "add", "-A") - runGit(t, dir, "commit", "-q", "-m", msg) + runGit(t, dir, "commit", "-q", "-m", "initial") } // archiveEntries unpacks a (gzipped) tar produced by the push pipeline into a @@ -108,7 +106,6 @@ func TestServicePushCommand_GitNotInitializedErrors(t *testing.T) { registerPushStubs(t, f, "demo") res := f.Run( - nil, "service", "push", "--service-id", pushServiceID, "--working-dir", workDir, @@ -134,7 +131,6 @@ func TestServicePushCommand_GitZeroCommitsErrors(t *testing.T) { registerPushStubs(t, f, "demo") res := f.Run( - nil, "service", "push", "--service-id", pushServiceID, "--working-dir", workDir, @@ -159,12 +155,11 @@ func TestServicePushCommand_GitArchive_CommittedFilesUploaded(t *testing.T) { writeZeropsYaml(t, workDir, "demo") require.NoError(t, os.WriteFile(filepath.Join(workDir, "main.go"), []byte("package main\n"), 0o600)) gitInit(t, workDir) - gitAddCommit(t, workDir, "initial") + gitAddCommit(t, workDir) s := registerPushStubs(t, f, "demo") res := f.Run( - nil, "service", "push", "--service-id", pushServiceID, "--working-dir", workDir, @@ -192,14 +187,13 @@ func TestServicePushCommand_GitArchive_WorkspaceCleanIgnoresUncommitted(t *testi writeZeropsYaml(t, workDir, "demo") require.NoError(t, os.WriteFile(filepath.Join(workDir, "main.go"), []byte("package main\n"), 0o600)) gitInit(t, workDir) - gitAddCommit(t, workDir, "initial") + gitAddCommit(t, workDir) // Add a file after the commit — it must NOT appear in the archive. require.NoError(t, os.WriteFile(filepath.Join(workDir, "uncommitted.txt"), []byte("local-only"), 0o600)) s := registerPushStubs(t, f, "demo") res := f.Run( - nil, "service", "push", "--service-id", pushServiceID, "--working-dir", workDir, @@ -228,12 +222,11 @@ func TestServicePushCommand_GitArchive_DeployGitFolderIncludesGitDir(t *testing. writeZeropsYaml(t, workDir, "demo") require.NoError(t, os.WriteFile(filepath.Join(workDir, "main.go"), []byte("package main\n"), 0o600)) gitInit(t, workDir) - gitAddCommit(t, workDir, "initial") + gitAddCommit(t, workDir) s := registerPushStubs(t, f, "demo") res := f.Run( - nil, "service", "push", "--service-id", pushServiceID, "--working-dir", workDir, @@ -272,7 +265,7 @@ func TestServicePushCommand_GitArchive_WorkspaceStagedKeepsStagedDropsUnstaged(t writeZeropsYaml(t, workDir, "demo") require.NoError(t, os.WriteFile(filepath.Join(workDir, "main.go"), []byte("package main\n"), 0o600)) gitInit(t, workDir) - gitAddCommit(t, workDir, "initial") + gitAddCommit(t, workDir) // Stage one new file (in the index but not committed) and leave another // unstaged in the working tree. @@ -283,7 +276,6 @@ func TestServicePushCommand_GitArchive_WorkspaceStagedKeepsStagedDropsUnstaged(t s := registerPushStubs(t, f, "demo") res := f.Run( - nil, "service", "push", "--service-id", pushServiceID, "--working-dir", workDir, @@ -313,7 +305,7 @@ func TestServicePushCommand_GitArchive_WorkspaceAllIncludesUncommitted(t *testin writeZeropsYaml(t, workDir, "demo") require.NoError(t, os.WriteFile(filepath.Join(workDir, "main.go"), []byte("package main\n"), 0o600)) gitInit(t, workDir) - gitAddCommit(t, workDir, "initial") + gitAddCommit(t, workDir) require.NoError(t, os.WriteFile(filepath.Join(workDir, "staged.txt"), []byte("staged"), 0o600)) runGit(t, workDir, "add", "staged.txt") @@ -323,7 +315,6 @@ func TestServicePushCommand_GitArchive_WorkspaceAllIncludesUncommitted(t *testin // Omitting --workspace-state defaults to "all". res := f.Run( - nil, "service", "push", "--service-id", pushServiceID, "--working-dir", workDir, diff --git a/src/cmd/servicePush_integration_test.go b/src/cmd/servicePush_integration_test.go index 601b807e..6021ff3d 100644 --- a/src/cmd/servicePush_integration_test.go +++ b/src/cmd/servicePush_integration_test.go @@ -1,5 +1,3 @@ -//go:build devel - package cmd import ( @@ -29,7 +27,6 @@ func TestServicePushCommand_SetupAutoMatchesServiceName(t *testing.T) { s := registerPushStubs(t, f, "demo") res := f.Run( - nil, "service", "push", "--service-id", pushServiceID, "--working-dir", workDir, @@ -51,7 +48,6 @@ func TestServicePushCommand_SetupSelectedByFlag(t *testing.T) { s := registerPushStubs(t, f, "api") // service is "api", no exact match res := f.Run( - nil, "service", "push", "--service-id", pushServiceID, "--working-dir", workDir, @@ -74,7 +70,6 @@ func TestServicePushCommand_VersionNameForwarded(t *testing.T) { s := registerPushStubs(t, f, "demo") res := f.Run( - nil, "service", "push", "--service-id", pushServiceID, "--working-dir", workDir, @@ -101,7 +96,6 @@ func TestServicePushCommand_MissingZeropsYaml(t *testing.T) { registerPushStubs(t, f, "demo") res := f.Run( - nil, "service", "push", "--service-id", pushServiceID, "--working-dir", workDir, @@ -127,7 +121,6 @@ func TestServicePushCommand_EmptyZeropsYaml(t *testing.T) { registerPushStubs(t, f, "demo") res := f.Run( - nil, "service", "push", "--service-id", pushServiceID, "--working-dir", workDir, @@ -149,7 +142,6 @@ func TestServicePushCommand_InvalidWorkspaceState(t *testing.T) { registerPushStubs(t, f, "demo") res := f.Run( - nil, "service", "push", "--service-id", pushServiceID, "--working-dir", workDir, @@ -173,7 +165,6 @@ func TestServicePushCommand_NoSetupMatchNoFlagFailsInNonTTY(t *testing.T) { registerPushStubs(t, f, "api") // no setup named "api" res := f.Run( - nil, "service", "push", "--service-id", pushServiceID, "--working-dir", workDir, @@ -197,7 +188,6 @@ func TestServicePushCommand_ProcessFails(t *testing.T) { s.processStatus.Store("FAILED") res := f.Run( - nil, "service", "push", "--service-id", pushServiceID, "--working-dir", workDir, @@ -221,7 +211,6 @@ func TestServicePushCommand_SetupNotFoundInYamlRejectedLocally(t *testing.T) { s := registerPushStubs(t, f, "other") // no auto-match res := f.Run( - nil, "service", "push", "--service-id", pushServiceID, "--working-dir", workDir, @@ -253,7 +242,6 @@ func TestServicePushCommand_PendingThenRunningThenFinished(t *testing.T) { s.processStatusSeq.Store([]string{"PENDING", "RUNNING", "FINISHED"}) res := f.Run( - nil, "service", "push", "--service-id", pushServiceID, "--working-dir", workDir, @@ -288,7 +276,6 @@ func TestServicePushCommand_InvalidServiceIdErrors(t *testing.T) { }) res := f.Run( - nil, "service", "push", "--service-id", pushServiceID, "--working-dir", workDir, @@ -315,7 +302,6 @@ func TestServicePushCommand_ArchiveFilePathTeesToFile(t *testing.T) { s := registerPushStubs(t, f, "demo") res := f.Run( - nil, "service", "push", "--service-id", pushServiceID, "--working-dir", workDir, @@ -382,7 +368,6 @@ func TestServicePushCommand_ProjectFlagAndServiceByName(t *testing.T) { }) res := f.Run( - nil, "service", "push", "demo", // positional service-id-or-name "--project-id", pushProjectID, @@ -435,7 +420,6 @@ func TestServicePushCommand_ScopeFromSavedProjectId(t *testing.T) { }) res := f.Run( - nil, "service", "push", "demo", // positional service name "--working-dir", workDir, @@ -460,7 +444,6 @@ func TestServicePushCommand_ArchiveFilePathAlreadyExistsErrors(t *testing.T) { s := registerPushStubs(t, f, "demo") res := f.Run( - nil, "service", "push", "--service-id", pushServiceID, "--working-dir", workDir, diff --git a/src/cmd/upgrade_integration_test.go b/src/cmd/upgrade_integration_test.go new file mode 100644 index 00000000..0d43c558 --- /dev/null +++ b/src/cmd/upgrade_integration_test.go @@ -0,0 +1,78 @@ +package cmd + +import ( + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zeropsio/zcli/src/constants" +) + +// stubVersionAPI points ZEROPS_VERSION_API_URL at a handler on the fixture +// server that returns the given status and, when tagName is non-empty, a +// GitHub-release-style body. The current binary reports version "local" in +// tests (the version var is only stamped at release build time). +func (f *fixture) stubVersionAPI(status int, tagName string) { + const path = "/release/latest" + f.Mux.HandleFunc(path, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(status) + if tagName != "" { + _, _ = fmt.Fprintf(w, `{"tag_name":%q}`, tagName) + } + }) + f.t.Setenv(constants.VersionApiUrlEnvVar, f.Server.URL+path) +} + +func TestUpgradeCheckUpToDate(t *testing.T) { + f := newFixture(t) + f.stubVersionAPI(http.StatusOK, "local") // latest == current + + res := f.Run("upgrade", "--check") + + require.Equalf(t, 0, res.ExitCode, "stderr=%q", res.Stderr) + assert.Contains(t, res.Stdout, "Current: local") + assert.Contains(t, res.Stdout, "Latest: local") +} + +func TestUpgradeCheckBehind(t *testing.T) { + f := newFixture(t) + f.stubVersionAPI(http.StatusOK, "v2.0.0") + + res := f.Run("upgrade", "--check") + + require.Equalf(t, 1, res.ExitCode, "stderr=%q", res.Stderr) + assert.Contains(t, res.Stdout, "Latest: v2.0.0") +} + +func TestUpgradeCheckExplicitVersion(t *testing.T) { + f := newFixture(t) + // No version-API stub: --version is resolved without contacting the API. + res := f.Run("upgrade", "--check", "--version", "v1.2.3") + + require.Equalf(t, 1, res.ExitCode, "stderr=%q", res.Stderr) + assert.Contains(t, res.Stdout, "Current: local") + assert.Contains(t, res.Stdout, "Latest: v1.2.3") +} + +func TestUpgradeCheckError(t *testing.T) { + f := newFixture(t) + f.stubVersionAPI(http.StatusInternalServerError, "") + + res := f.Run("upgrade", "--check") + + require.Equalf(t, 2, res.ExitCode, "stdout=%q stderr=%q", res.Stdout, res.Stderr) + assert.Contains(t, res.Stderr, "error") +} + +func TestUpgradeAlreadyOnLatest(t *testing.T) { + f := newFixture(t) + f.stubVersionAPI(http.StatusOK, "local") // latest == current, no --version given + + res := f.Run("upgrade", "--yes") + + require.Equalf(t, 0, res.ExitCode, "stderr=%q", res.Stderr) + assert.Contains(t, res.Stdout, "already on local") +} diff --git a/src/cmd/version_integration_test.go b/src/cmd/version_integration_test.go index 11b74659..ccec4333 100644 --- a/src/cmd/version_integration_test.go +++ b/src/cmd/version_integration_test.go @@ -1,5 +1,3 @@ -//go:build devel - package cmd import ( @@ -13,7 +11,7 @@ import ( func TestVersionCommand(t *testing.T) { f := newFixture(t) - res := f.Run(nil, "version") + res := f.Run("version") require.Equalf(t, 0, res.ExitCode, "stderr=%q", res.Stderr) assert.Truef(t, strings.HasPrefix(res.Stdout, "zcli version "), "unexpected stdout: %q", res.Stdout)