diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index a5346d5..aee43a0 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 be005a1..7b832d0 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/.goreleaser.yaml b/.goreleaser.yaml index 708ea85..a81a5c0 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: @@ -117,6 +182,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 diff --git a/CLAUDE.md b/CLAUDE.md index 4a7d72e..e8c02f1 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 ad74197..3ce3b26 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 e9460fe..932f086 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/go.mod b/go.mod index 9079089..71bf5e7 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 @@ -20,6 +22,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 @@ -27,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 @@ -34,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 7fdf45f..db6b975 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,39 +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.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/integration_harness_test.go b/src/cmd/integration_harness_test.go index f8553c8..089b860 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 670ce81..d527c1a 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 d0c0425..333c95a 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 4dd27e8..8ba8afc 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 9150425..07eb603 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/root.go b/src/cmd/root.go index 875e3f4..6fbb7c2 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/serviceDeploy_integration_test.go b/src/cmd/serviceDeploy_integration_test.go index 958fac0..444d53e 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 74c91e0..b566303 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 601b807..6021ff3 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.go b/src/cmd/upgrade.go new file mode 100644 index 0000000..d3dbeee --- /dev/null +++ b/src/cmd/upgrade.go @@ -0,0 +1,83 @@ +package cmd + +import ( + "context" + "fmt" + + "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" + 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) + return errorsx.NewExitError(2) + } + return err + } + + if check { + cmdData.Stdout.Printf("Current: %s\nLatest: %s\n", plan.Current, plan.Target) + if plan.Current == plan.Target { + return nil + } + return errorsx.NewExitError(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 + } + + 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 + } + } + + 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/cmd/upgrade_integration_test.go b/src/cmd/upgrade_integration_test.go new file mode 100644 index 0000000..0d43c55 --- /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 11b7465..ccec433 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) diff --git a/src/cmdBuilder/createRunFunc.go b/src/cmdBuilder/createRunFunc.go index fa63cbd..ef74669 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/cmdBuilder/executeRootCmd.go b/src/cmdBuilder/executeRootCmd.go index 95c00f3..dd08bb4 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/constants/zerops.go b/src/constants/zerops.go index f263737..51de56b 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" @@ -27,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/errorsx/exitError.go b/src/errorsx/exitError.go new file mode 100644 index 0000000..179f436 --- /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) +} diff --git a/src/version/apiDto.go b/src/version/apiDto.go index 8a1ab42..11aacf0 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/assets/message.txt b/src/version/assets/message.txt deleted file mode 100644 index a9fed5a..0000000 --- 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 new file mode 100644 index 0000000..7a20283 --- /dev/null +++ b/src/version/cache.go @@ -0,0 +1,80 @@ +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") + } + // 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/cache_test.go b/src/version/cache_test.go new file mode 100644 index 0000000..ff4f6d4 --- /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 0000000..90b3c72 --- /dev/null +++ b/src/version/install_method.go @@ -0,0 +1,116 @@ +package version + +import ( + "os" + "path/filepath" + "strings" +) + +type InstallMethod int + +const ( + InstallManual InstallMethod = iota + InstallNix + InstallNpm + InstallBrew + InstallDeb +) + +func (m InstallMethod) String() string { + switch m { + case InstallManual: + return "manual" + case InstallNix: + return "nix" + case InstallNpm: + return "npm" + case InstallBrew: + return "homebrew" + case InstallDeb: + return "deb" + default: + return "manual" + } +} + +// 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 InstallManual: + return "Run: zcli upgrade" + case InstallNix: + return "Update via Nix: rebuild your profile or flake." + case InstallNpm: + 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" + } +} + +func (m InstallMethod) IsPackageManager() bool { + return m != InstallManual +} + +// 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 +// 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 + case "deb": + return InstallDeb, 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 0000000..5e78d6d --- /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}, + {"deb", InstallDeb, 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, InstallDeb, InstallManual} { + if m.Hint() == "" { + t.Errorf("%v should have a non-empty hint", m) + } + } + for _, m := range []InstallMethod{InstallNix, InstallNpm, InstallBrew, InstallDeb} { + if !m.IsPackageManager() { + t.Errorf("%v should be a package manager", m) + } + } + if InstallManual.IsPackageManager() { + t.Error("InstallManual should not be a package manager") + } +} diff --git a/src/version/message.go b/src/version/message.go deleted file mode 100644 index 959ae50..0000000 --- a/src/version/message.go +++ /dev/null @@ -1,46 +0,0 @@ -//go:build !devel - -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 { - 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") - } - if err := t.Execute(out, d); err != nil { - return errors.Wrap(err, "Failed to execute message template") - } - return nil -} diff --git a/src/version/upgrade.go b/src/version/upgrade.go new file mode 100644 index 0000000..f7ff062 --- /dev/null +++ b/src/version/upgrade.go @@ -0,0 +1,153 @@ +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 ( + 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 = selfupdate.Apply + +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. 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) { + 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 +} + +// 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 { + 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 := applyUpdate(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(releasesURL, 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 0000000..ef98ea8 --- /dev/null +++ b/src/version/upgrade_test.go @@ -0,0 +1,260 @@ +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) { + 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 TestRequireSelfUpdatable(t *testing.T) { + savedChannel := channel + t.Cleanup(func() { channel = savedChannel }) + + for _, stamp := range []string{"npm", "brew", "nix", "deb"} { + 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", "deb"} { + channel = stamp + plan, err := PlanUpgrade(t.Context(), UpgradeOptions{TargetVersion: "v1.0.0"}) + 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) + } +} diff --git a/src/version/version.go b/src/version/version.go index cc7e39a..e500733 100644 --- a/src/version/version.go +++ b/src/version/version.go @@ -1,106 +1,167 @@ -//go:build !devel - package version import ( - "bytes" "context" - _ "embed" "encoding/json" "fmt" "net/http" + "os" "runtime" "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" + +// 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 version = "local" -var latestResponse *apiResponse + +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 + } + want := assetName() + for _, asset := range resp.Assets { + if asset.Name == want { + 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" + if !semver.IsValid(GetCurrent()) { + return } - latestUrl, err := GetLatestUrl(ctx) + latestVersion, err := GetLatest(ctx) if err != nil { - latestUrl = "unavailable" + out.Printf("zcli latest version check failed\n") + return } - - 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) } } -func IsVersionCheckMismatch(ctx context.Context) bool { - latestVersion, err := GetLatest(ctx) +// 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 "" + } + 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 { - latestVersion = "unavailable" + return } - return 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 } -func GetLatestUrl(ctx context.Context) (string, error) { - if err := fetch(ctx); err != nil { - return "", err +// 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 semver.Compare(current, latest) < 0 +} - for _, asset := range latestResponse.Assets { - if asset.Name == fmt.Sprintf("zcli-%s-%s", runtime.GOOS, runtime.GOARCH) { - return asset.BrowserDownloadUrl, nil +// 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) { + 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 } + return nil, err } - - return "", errors.Errorf("could not find latest release for %s/%s", runtime.GOOS, runtime.GOARCH) + _ = writeCacheEntry(resp) + return resp, nil } -func fetch(ctx context.Context) error { - if latestResponse != nil { - return 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 errors.Wrapf(err, "unable to get api response %s", apiUrl) + return nil, errors.Wrapf(err, "version api request to %s failed", url) } - 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") - } - return nil + if resp.StatusCode != http.StatusOK { + return nil, errors.Errorf("version api %s returned status %d", url, resp.StatusCode) } - latestResponse = &apiResponse{ - TagName: "v0.0.0", + out := &apiResponse{} + if err := json.Unmarshal(resp.Body, out); err != nil { + return nil, errors.Wrap(err, "version api response could not be decoded") } - return nil + return out, nil } diff --git a/src/version/version_devel.go b/src/version/version_devel.go deleted file mode 100644 index 601f506..0000000 --- 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 new file mode 100644 index 0000000..15c59af --- /dev/null +++ b/src/version/version_test.go @@ -0,0 +1,82 @@ +package version + +import ( + "path/filepath" + "strings" + "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) + } + }) + } +} + +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", "zcli upgrade"}, + } + 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) + } + } + }) +}