diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index dd2fdd6e..c5623176 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -19,3 +19,68 @@ jobs: cache: false - name: Run tests run: make test/unit + notification-platforms: + name: Notification compatibility (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - macos-latest + steps: + - name: Check out code + uses: actions/checkout@v7.0.1 + - uses: actions/setup-go@v7.0.0 + with: + go-version-file: go.mod + cache: false + - uses: actions/setup-python@v6.2.0 + with: + python-version: "3.13" + - name: Set up Bats + id: setup-bats + uses: bats-core/bats-action@4.0.0 + with: + bats-version: 1.13.0 + - name: Run native platform notification tests + env: + BATS_LIB_PATH: ${{ steps.setup-bats.outputs.lib-path }} + TERM: xterm + run: make test/bats/platform + notification-windows: + name: Notification compatibility (windows-latest) + runs-on: windows-latest + timeout-minutes: 10 + steps: + - name: Configure Git line endings + run: git config --global core.autocrlf input + - name: Check out code + uses: actions/checkout@v7.0.1 + - uses: actions/setup-go@v7.0.0 + with: + go-version-file: go.mod + cache: false + - uses: actions/setup-python@v6.2.0 + with: + python-version: "3.13" + - name: Install Windows PTY dependency + run: python -m pip install pywinpty==3.0.5 + - name: Set up Bats + id: setup-bats + uses: bats-core/bats-action@4.0.0 + with: + bats-version: 1.13.0 + - name: Set up MinGW + uses: msys2/setup-msys2@v2 + with: + install: make python + msystem: MINGW64 + path-type: inherit + update: true + - name: Run native platform notification tests + shell: msys2 {0} + env: + BATS_LIB_PATH: ${{ steps.setup-bats.outputs.lib-path }} + TERM: xterm + run: make test/bats/platform diff --git a/Makefile b/Makefile index 3fc4d6d0..202d6976 100644 --- a/Makefile +++ b/Makefile @@ -4,17 +4,26 @@ MAKEFLAGS += --silent --no-print-directory BIN_DIR := ./bin TEST_DIR := ./test APP_NAME := sloctl +GO_EXE := $(shell go env GOEXE) VERSION_PKG := "$(shell go list -m)/internal" +NOTIFICATIONS_PKG := "$(shell go list -m)/internal/notifications" VERSION ?= 1.0.0-test BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD) REVISION ?= $(shell git rev-parse --short=8 HEAD) +NOTIFICATIONS_RELEASE_URL ?= +NOTIFICATIONS_TEST_RELEASE_PORT ?= 38080 +NOTIFICATIONS_TEST_RELEASE_URL := http://127.0.0.1:$(NOTIFICATIONS_TEST_RELEASE_PORT)/repos/nobl9/sloctl/releases/latest LDFLAGS := -s -w \ -X $(VERSION_PKG).BuildVersion=$(VERSION) \ -X $(VERSION_PKG).BuildGitBranch=$(BRANCH) \ -X $(VERSION_PKG).BuildGitRevision=$(REVISION) +ifneq ($(strip $(NOTIFICATIONS_RELEASE_URL)),) +LDFLAGS += -X $(NOTIFICATIONS_PKG).latestReleaseURL=$(NOTIFICATIONS_RELEASE_URL) +endif + # renovate datasource=github-releases depName=golangci/golangci-lint GOLANGCI_LINT_VERSION := v2.12.2 # renovate datasource=go depName=golang.org/x/vuln/cmd/govulncheck @@ -53,9 +62,10 @@ endef # ${2} - version # ${3} - git branch # ${4} - git revision +# ${5} - notifications release URL define _build_docker docker build \ - --build-arg LDFLAGS="-X $(VERSION_PKG).BuildVersion=$(2) -X $(VERSION_PKG).BuildGitBranch=$(3) -X $(VERSION_PKG).BuildGitRevision=$(4)" \ + --build-arg LDFLAGS="-X $(VERSION_PKG).BuildVersion=$(2) -X $(VERSION_PKG).BuildGitBranch=$(3) -X $(VERSION_PKG).BuildGitRevision=$(4) $(if $(strip $(5)),-X $(NOTIFICATIONS_PKG).latestReleaseURL=$(5))" \ -t "$(1)" . endef @@ -63,7 +73,7 @@ endef ## Build sloctl binary. build: $(call _print_step,Building sloctl binary) - go build -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(APP_NAME) ./cmd/$(APP_NAME)/ + go build -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(APP_NAME)$(GO_EXE) ./cmd/$(APP_NAME)/ .PHONY: install ## Install sloctl binary. @@ -75,17 +85,17 @@ install: ## Build sloctl Docker image. docker: $(call _print_step,Building sloctl Docker image) - $(call _build_docker,sloctl,$(VERSION),$(BRANCH),$(REVISION)) + $(call _build_docker,sloctl,$(VERSION),$(BRANCH),$(REVISION),$(NOTIFICATIONS_RELEASE_URL)) .PHONY: test -## Run all tests. +## Run the standard unit and end-to-end suites (excludes native platform tests). test: test/unit test/e2e .PHONY: test/unit test/go/unit test/bats/% -## Run all unit tests. +## Run Go and containerized Bats unit tests (excludes native platform tests). test/unit: test/go/unit test/bats/unit -.PHONY: test/e2e test/bats/unit test/bats/e2e test/go/e2e-docker +.PHONY: test/e2e test/bats/unit test/bats/platform test/bats/e2e test/go/e2e-docker ## Run all e2e tests. test/e2e: test/bats/e2e test/go/e2e-docker @@ -102,15 +112,28 @@ test/go/e2e-docker: ## Run bats unit tests. test/bats/unit: $(call _print_step,Running bats unit tests) - $(call _build_docker,sloctl-unit-test-bin,v1.0.0,PC-123-test,e2602ddc) + $(call _build_docker,sloctl-unit-test-bin,v1.0.0,PC-123-test,e2602ddc,$(NOTIFICATIONS_TEST_RELEASE_URL)) docker build -t sloctl-bats-unit -f $(TEST_DIR)/docker/Dockerfile.unit . - docker run -e TERM=linux --rm \ - sloctl-bats-unit -F pretty --filter-tags unit $(TEST_DIR)/* + docker run -e RELEASE_SERVER_PORT=$(NOTIFICATIONS_TEST_RELEASE_PORT) -e TERM=linux --rm \ + sloctl-bats-unit -F pretty --filter-tags unit,!platform $(TEST_DIR)/* + +## Run native platform notification tests. +test/bats/platform: + $(MAKE) VERSION=v1.0.0 NOTIFICATIONS_RELEASE_URL=$(NOTIFICATIONS_TEST_RELEASE_URL) build + $(call _print_step,Running native platform notification tests) + @set -- --filter-tags platform:unix; \ + case "$$(uname -s)" in \ + CYGWIN*|MINGW*|MSYS*) set -- --filter-tags platform:windows ;; \ + Darwin*) set -- "$$@" --filter-tags platform:macos ;; \ + esac; \ + RELEASE_SERVER_PORT=$(NOTIFICATIONS_TEST_RELEASE_PORT) bats -F pretty \ + --setup-suite-file $(TEST_DIR)/setup_platform_suite.bash \ + "$$@" $(TEST_DIR)/notifications.bats ## Run bats e2e tests. test/bats/e2e: $(call _print_step,Running bats e2e tests) - $(call _build_docker,sloctl-e2e-test-bin,$(VERSION),$(BRANCH),$(REVISION)) + $(call _build_docker,sloctl-e2e-test-bin,$(VERSION),$(BRANCH),$(REVISION),$(NOTIFICATIONS_RELEASE_URL)) docker build -t sloctl-bats-e2e -f $(TEST_DIR)/docker/Dockerfile.e2e . ./scripts/run-e2e-tests.sh sloctl-bats-e2e $(REVISION) diff --git a/README.md b/README.md index f8969b1f..b4a02267 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,28 @@ brew trust nobl9/sloctl brew install sloctl ``` +### Update notifications + +In an interactive terminal, +sloctl checks for a newer GitHub release before running the requested command +and caches completed checks for 24 hours. +It does not check in CI, for development builds, +when standard input or standard error is not a terminal, +or when `SLOCTL_NO_NOTIFICATIONS` is set. + +On supported terminals, +Homebrew and `go install` installations offer to run +their corresponding update command. +Choosing **Update** exits after the update +instead of running the originally requested sloctl command. +Other installation methods show the [installation options](#install) +and continue without an interactive prompt. + +Choosing **Skip until next version** stores the release tag +in the operating system's user cache. +If the preference cannot be saved, +sloctl reports the error and may show the notification again. + ### Docker Sloctl official images are hosted on [hub.docker.com](https://hub.docker.com/r/nobl9/sloctl). diff --git a/cspell.json b/cspell.json index 57f42059..c0fb89b9 100644 --- a/cspell.json +++ b/cspell.json @@ -39,6 +39,7 @@ "dynatrace", "endef", "gobin", + "goexe", "gofmt", "goimports", "gojq", @@ -46,6 +47,8 @@ "gosec", "govulncheck", "ldflags", + "msys", + "msystem", "nobl", "openslo", "openslosdk", diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 0967610f..2bf891ac 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -59,10 +59,12 @@ Each test file ends with `.bats` suffix. In addition to helper test utilities which are part of the framework we also provide custom helpers which are located in `test/test_helper` directory. -Bats tests are currently divided into 2 categories, end-to-end and unit tests. -The categorization is done through Bats tags. In order to categorize a whole -file as a unit test, add this comment: `# bats file_tags=unit` anywhere in the -file, preferably just below shebang. +Bats tests are primarily divided into two categories: end-to-end and unit tests. +The categorization is done through Bats tags. +Platform compatibility tags are orthogonal to those categories and select tests +for the native `make test/bats/platform` target. +To categorize a whole file as a unit test, add +`# bats file_tags=unit` anywhere in the file, preferably just below the shebang. The end-to-end tests are only run automatically for releases, be it official version or pre-release (release candidate). @@ -78,8 +80,39 @@ SLOCTL_OKTA_AUTH_SERVER= \ # Runs against dev Okta. make test/e2e ``` -Bats tests are fully containerized, refer to Makefile for more details on -how they're executed. +Bats unit and end-to-end tests run in containers. +Platform compatibility tests run natively with `make test/bats/platform`. +Refer to the Makefile for the exact commands. + +### Bats output assertions + +Prefer exact stdout and stderr assertions for complete CLI messages. +Store input fixtures, such as request payloads and release bodies, under +[test/inputs](../test/inputs/), and store expected output fixtures under +[test/outputs](../test/outputs/). +When a test file needs a narrower fixture root, set `TEST_INPUTS` or +`TEST_OUTPUTS` in `setup_file` and compare against files from there. + +Use file-backed assertions for expected output: + +```bash +assert_output - < "$TEST_OUTPUTS/result.stdout" +assert_stderr - < "$TEST_OUTPUTS/error.stderr" +``` + +Use `--partial` only as a last resort when exact output would be unstable for +reasons unrelated to the behavior under test, such as nondeterministic fields +that cannot be normalized. +If `--partial` is necessary, keep the assertion narrow and leave nearby context +explaining why a full output fixture would be brittle. + +Interactive terminal tests should prefer deterministic plain-text fixtures. +Set `NO_COLOR=1` and use accessible form mode when the command supports it. +Keep source data, such as release bodies, in [test/inputs](../test/inputs/), +and compare complete stdout or stderr messages against files in +[test/outputs](../test/outputs/). +For notification tests, use the local release fixture server instead of +proxying GitHub, and use `refute_stderr` when stderr must be empty. ### End-to-end tests @@ -88,8 +121,6 @@ and use [test helper utility functions](../test/test_helper/load.bash). The helper functions are documented inline in that file; read them before adding a new test, especially if you need fixture generation or output assertion helpers. -Prefer asserting entire outputs with predefined _INPUTS_ and _OUTPUTS_ read -from files and NOT redirected in the test's code via _heredoc_. Input fixtures for e2e tests live under [test/inputs](../test/inputs/). The fixture directory name must match the test filename without the `.bats` diff --git a/go.mod b/go.mod index eadfa049..8dfbef85 100644 --- a/go.mod +++ b/go.mod @@ -7,9 +7,12 @@ require ( charm.land/lipgloss/v2 v2.0.5 github.com/BurntSushi/toml v1.6.0 github.com/OpenSLO/go-sdk v0.9.2 + github.com/charmbracelet/glamour v1.0.0 + github.com/charmbracelet/x/ansi v0.11.7 github.com/go-playground/validator/v10 v10.30.3 github.com/goccy/go-yaml v1.19.2 github.com/itchyny/gojq v0.12.19 + github.com/mattn/go-isatty v0.0.23 github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db github.com/nobl9/go-yaml v1.0.1 github.com/nobl9/nobl9-go v0.133.1 @@ -20,19 +23,26 @@ require ( github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 github.com/tidwall/sjson v1.2.5 + golang.org/x/mod v0.38.0 golang.org/x/sync v0.22.0 + golang.org/x/term v0.45.0 ) require ( - charm.land/bubbles/v2 v2.1.0 // indirect - charm.land/bubbletea/v2 v2.0.6 // indirect + charm.land/bubbles/v2 v2.1.1 // indirect + charm.land/bubbletea/v2 v2.0.8 // indirect + github.com/alecthomas/chroma/v2 v2.27.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymerick/douceur v0.2.0 // indirect github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect github.com/catppuccin/go v0.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect - github.com/charmbracelet/ultraviolet v0.0.0-20260511121909-c840852527f3 // indirect - github.com/charmbracelet/x/ansi v0.11.7 // indirect + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260720091822-7cc6674724ac // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20260720091843-3eef36eaaa28 // indirect github.com/charmbracelet/x/exp/strings v0.1.0 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect @@ -40,12 +50,14 @@ require ( github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dlclark/regexp2/v2 v2.5.2 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/fatih/color v1.19.0 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/gorilla/css v1.0.1 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-retryablehttp v0.7.8 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect @@ -53,13 +65,15 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect - github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.22 // indirect - github.com/mattn/go-runewidth v0.0.23 // indirect + github.com/mattn/go-colorable v0.1.15 // indirect + github.com/mattn/go-runewidth v0.0.24 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/nobl9/govy v0.26.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect @@ -71,13 +85,14 @@ require ( github.com/tidwall/match v1.2.0 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yuin/goldmark v1.8.4 // indirect + github.com/yuin/goldmark-emoji v1.0.6 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect - golang.org/x/crypto v0.52.0 // indirect - golang.org/x/mod v0.36.0 // indirect - golang.org/x/sys v0.46.0 // indirect - golang.org/x/term v0.44.0 // indirect - golang.org/x/text v0.37.0 // indirect - golang.org/x/tools v0.45.0 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/tools v0.48.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 3c91c08a..486447b3 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ -charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g= -charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY= -charm.land/bubbletea/v2 v2.0.6 h1:UHN/91OyuhaOFGSrBXQ/hMZD8IO1Uc4BvHlgHXL2WJo= -charm.land/bubbletea/v2 v2.0.6/go.mod h1:MH/D8ZLlN3op37vQvijKuU29g3rqTp+aQapURFonF9g= +charm.land/bubbles/v2 v2.1.1 h1:7r55WzBxpo/R3z98hGmY7KKPd3ET6vsf0Fb9sDHOV60= +charm.land/bubbles/v2 v2.1.1/go.mod h1:GE6M31gaWZVXzGw73OeuTTgy4lX+OtkH0E5ymnNsHxo= +charm.land/bubbletea/v2 v2.0.8 h1:SxTJMhCAI3lbPmy4SgX5LWZ24AdINr4I6UEqzZvYJuY= +charm.land/bubbletea/v2 v2.0.8/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss= charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU= charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc= charm.land/lipgloss/v2 v2.0.5 h1:kbNxgeeUOYv5J0YdpxFjfvf3dFvqH8Aci4zB6xqFtrY= @@ -12,10 +12,20 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/OpenSLO/go-sdk v0.9.2 h1:pc6b4sWImIJreEDGNPbfplMbOZL5LwOkoRq2IULShRc= github.com/OpenSLO/go-sdk v0.9.2/go.mod h1:s4PEBTqO5O2u5SeVFQZyLHE9RzCZgGNxTt43FwuqvCo= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs= +github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8= +github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= +github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 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.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= @@ -26,10 +36,16 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= -github.com/charmbracelet/ultraviolet v0.0.0-20260511121909-c840852527f3 h1:pxGjlWZFcRQMWAdtjRelpL3Gbu8iYIyuO3Eqbd037Ow= -github.com/charmbracelet/ultraviolet v0.0.0-20260511121909-c840852527f3/go.mod h1:SnKWaPaTnkTNXJgdgdquu66de12V8pW/b/qlTGaF9xg= +github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08= +github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= +github.com/charmbracelet/ultraviolet v0.0.0-20260720091822-7cc6674724ac h1:BP8qMDGjmOejoVTklEXXTHI0OwMIt8JHPSPHxscFFwA= +github.com/charmbracelet/ultraviolet v0.0.0-20260720091822-7cc6674724ac/go.mod h1:psnCZIfwwxVs6v6DhUc6NJ8AQ3ejvs2ejKwoOMeVmUk= github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs= github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= @@ -38,6 +54,8 @@ github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6g github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8= +github.com/charmbracelet/x/exp/slice v0.0.0-20260720091843-3eef36eaaa28 h1:fGQpqto9ryOmNMRHacdlSLxKg+l2E+yX0PpIxoBZAi0= +github.com/charmbracelet/x/exp/slice v0.0.0-20260720091843-3eef36eaaa28/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA= github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9nwd6aoEm7gkA= github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= @@ -61,6 +79,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2/v2 v2.5.2 h1:HAsucWRhsqcDzl6Ua9aR8JwYOTzrZyPrF0/FNxJVAI0= +github.com/dlclark/regexp2/v2 v2.5.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= @@ -84,12 +104,16 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/itchyny/gojq v0.12.19 h1:ttXA0XCLEMoaLOz5lSeFOZ6u6Q3QxmG46vfgI4O0DEs= @@ -108,12 +132,15 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= -github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= -github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= -github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= -github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= +github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ= +github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= +github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= @@ -125,6 +152,10 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= @@ -150,6 +181,8 @@ github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTU github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= @@ -181,30 +214,34 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.8.4 h1:oat/nd3U6NeQqFEL3xpEJq7d7c86NI+DbSNGAs4xnjA= +github.com/yuin/goldmark v1.8.4/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= +github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= -golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= -golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= -golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= -golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/internal/huhform/form.go b/internal/huhform/form.go index 907c9bdb..372527e3 100644 --- a/internal/huhform/form.go +++ b/internal/huhform/form.go @@ -6,16 +6,23 @@ import ( "strconv" huh "charm.land/huh/v2" - lipgloss "charm.land/lipgloss/v2" + + "github.com/nobl9/sloctl/internal/style" ) // accessibleModeEnv can be set to turn on [huh] accessible mode. // It can be useful in old terminal emulators (e.g. remote shells). const accessibleModeEnv = "SLOCTL_ACCESSIBLE_MODE" +// New returns a form configured with sloctl's shared terminal theme. func New(groups ...*huh.Group) *huh.Form { + return NewWithTheme(huh.ThemeFunc(style.HuhTheme), groups...) +} + +// NewWithTheme returns a form configured with the provided terminal theme. +func NewWithTheme(theme huh.Theme, groups ...*huh.Group) *huh.Form { return huh.NewForm(groups...). - WithTheme(huh.ThemeFunc(themeNobl9)). + WithTheme(theme). WithAccessible(getAccessibleEnvValue()) } @@ -30,59 +37,3 @@ func getAccessibleEnvValue() bool { } return accessible } - -// themeNobl9 returns a new theme based on the Nobl9 color scheme. -func themeNobl9(isDark bool) *huh.Styles { - t := huh.ThemeBase(isDark) - - var ( - black = lipgloss.Color("#383939") - green = lipgloss.Color("#0EB46E") - yellow = lipgloss.Color("#C8D655") - pink = lipgloss.Color("#DB2779") - blue = lipgloss.Color("#63D6E5") - gray = lipgloss.Color("#989999") - red = lipgloss.Color("#D42E56") - white = lipgloss.Color("#FFFFFF") - ) - - t.Focused.Base = t.Focused.Base.BorderForeground(gray) - t.Focused.Card = t.Focused.Base - t.Focused.Title = t.Focused.Title.Foreground(blue) - t.Focused.NoteTitle = t.Focused.NoteTitle.Foreground(blue) - t.Focused.Directory = t.Focused.Directory.Foreground(blue) - t.Focused.Description = t.Focused.Description.Foreground(gray) - t.Focused.ErrorIndicator = t.Focused.ErrorIndicator.Foreground(red) - t.Focused.ErrorMessage = t.Focused.ErrorMessage.Foreground(red) - t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(yellow) - t.Focused.NextIndicator = t.Focused.NextIndicator.Foreground(yellow) - t.Focused.PrevIndicator = t.Focused.PrevIndicator.Foreground(yellow) - t.Focused.Option = t.Focused.Option.Foreground(white) - t.Focused.MultiSelectSelector = t.Focused.MultiSelectSelector.Foreground(yellow) - t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(green) - t.Focused.SelectedPrefix = t.Focused.SelectedPrefix.Foreground(green) - t.Focused.UnselectedOption = t.Focused.UnselectedOption.Foreground(white) - t.Focused.FocusedButton = t.Focused.FocusedButton.Foreground(white).Background(pink) - t.Focused.BlurredButton = t.Focused.BlurredButton.Foreground(white).Background(black) - - t.Focused.TextInput.Cursor.Foreground(pink) - t.Focused.TextInput.Placeholder.Foreground(gray) - t.Focused.TextInput.Prompt.Foreground(yellow) - - t.Blurred = t.Focused - t.Blurred.Base = t.Blurred.Base.BorderStyle(lipgloss.HiddenBorder()) - t.Blurred.Card = t.Blurred.Base - t.Blurred.NoteTitle = t.Blurred.NoteTitle.Foreground(gray) - t.Blurred.Title = t.Blurred.NoteTitle.Foreground(gray) - - t.Blurred.TextInput.Prompt = t.Blurred.TextInput.Prompt.Foreground(gray) - t.Blurred.TextInput.Text = t.Blurred.TextInput.Text.Foreground(white) - - t.Blurred.NextIndicator = lipgloss.NewStyle() - t.Blurred.PrevIndicator = lipgloss.NewStyle() - - t.Group.Title = t.Focused.Title - t.Group.Description = t.Focused.Description - - return t -} diff --git a/internal/notifications/install.go b/internal/notifications/install.go new file mode 100644 index 00000000..3b38e8e6 --- /dev/null +++ b/internal/notifications/install.go @@ -0,0 +1,131 @@ +package notifications + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" +) + +const goEnvTimeout = 2 * time.Second + +type updateCommand struct { + display string + executable string + args []string +} + +func (c updateCommand) available() bool { + return c.display != "" && c.executable != "" +} + +func detectUpdateCommand() updateCommand { + executablePath, err := os.Executable() + if err != nil { + return updateCommand{} + } + resolvedPath, err := filepath.EvalSymlinks(executablePath) + if err != nil { + resolvedPath = executablePath + } + + if command := homebrewUpdateCommand(resolvedPath); command.available() { + return command + } + return goInstallUpdateCommand(resolvedPath) +} + +func (n notifier) runCommand(command updateCommand) error { + //nolint:gosec // The executable and arguments come from fixed sloctl update definitions. + cmd := exec.Command(command.executable, command.args...) + cmd.Stdin = n.stdin + cmd.Stdout = n.stdout + cmd.Stderr = n.stderr + return cmd.Run() +} + +func homebrewUpdateCommand(sloctlPath string) updateCommand { + prefix, _, found := strings.Cut(filepath.ToSlash(sloctlPath), "/Cellar/sloctl/") + if !found || prefix == "" { + return updateCommand{} + } + brewExecutable, err := exec.LookPath(filepath.Join(filepath.FromSlash(prefix), "bin", "brew")) + if err != nil { + return updateCommand{} + } + return updateCommand{ + display: "brew upgrade sloctl", + executable: brewExecutable, + args: []string{"upgrade", "sloctl"}, + } +} + +func goInstallUpdateCommand(sloctlPath string) updateCommand { + goExecutable, err := exec.LookPath("go") + if err != nil || !isGoInstallExecutable(sloctlPath, goExecutable) { + return updateCommand{} + } + return updateCommand{ + display: "go install github.com/nobl9/sloctl/cmd/sloctl@latest", + executable: goExecutable, + args: []string{"install", "github.com/nobl9/sloctl/cmd/sloctl@latest"}, + } +} + +func isGoInstallExecutable(sloctlPath, goExecutable string) bool { + binDir := goBinDir(goExecutable) + if binDir == "" { + return false + } + executableName := "sloctl" + if runtime.GOOS == "windows" { + executableName += ".exe" + } + return isSameFile(sloctlPath, filepath.Join(binDir, executableName)) +} + +func goBinDir(goExecutable string) string { + ctx, cancel := context.WithTimeout(context.Background(), goEnvTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, goExecutable, "env", "-json", "GOBIN", "GOPATH") + cmd.WaitDelay = goEnvTimeout + output, err := cmd.Output() + if err != nil { + return "" + } + var goEnv struct { + GOBIN string `json:"GOBIN"` + GOPATH string `json:"GOPATH"` + } + if err := json.Unmarshal(output, &goEnv); err != nil { + return "" + } + binDir := goEnv.GOBIN + if binDir == "" { + goPaths := filepath.SplitList(goEnv.GOPATH) + if len(goPaths) == 0 || goPaths[0] == "" { + return "" + } + binDir = filepath.Join(goPaths[0], "bin") + } + if !filepath.IsAbs(binDir) { + return "" + } + return binDir +} + +func isSameFile(firstPath, secondPath string) bool { + first, err := os.Stat(firstPath) + if err != nil { + return false + } + second, err := os.Stat(secondPath) + if err != nil { + return false + } + return os.SameFile(first, second) +} diff --git a/internal/notifications/notifications.go b/internal/notifications/notifications.go new file mode 100644 index 00000000..5d3e3482 --- /dev/null +++ b/internal/notifications/notifications.go @@ -0,0 +1,315 @@ +// Package notifications displays release notices in eligible interactive sessions. +// It offers update actions for recognized Homebrew and Go installations. +package notifications + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + huh "charm.land/huh/v2" + "github.com/mattn/go-isatty" + "golang.org/x/mod/semver" +) + +// latestReleaseURL can be replaced at link time for deterministic notification tests. +var latestReleaseURL = "https://api.github.com/repos/nobl9/sloctl/releases/latest" + +const ( + optOutEnv = "SLOCTL_NO_NOTIFICATIONS" + ciEnv = "CI" + checkInterval = 24 * time.Hour + checkTimeout = 750 * time.Millisecond + maxResponseSize = 1 << 20 +) + +// Result describes what the caller should do after running the notification flow. +type Result int + +const ( + // ResultContinue allows the requested sloctl command to run. + ResultContinue Result = iota + // ResultExitSuccess exits after a successful update. + ResultExitSuccess + // ResultExitFailure exits after a failed update. + ResultExitFailure + // ResultInterrupted exits after the user cancels the update prompt. + ResultInterrupted +) + +// Notify checks for a newer release in eligible interactive sessions and reports +// whether the caller should continue or exit. Checks are best-effort and cached; +// recognized Homebrew and Go installations may offer an interactive update action. +func Notify(currentVersion string) Result { + return newNotifier(currentVersion).notify() +} + +type notifier struct { + currentVersion string + stdin *os.File + stdout *os.File + stderr *os.File + releaseURL string + cachePath string +} + +type state struct { + LastCheckedAt time.Time `json:"lastCheckedAt"` +} + +type githubRelease struct { + TagName string `json:"tag_name"` + Body string `json:"body"` + HTMLURL string `json:"html_url"` +} + +func newNotifier(currentVersion string) notifier { + return notifier{ + currentVersion: strings.TrimSpace(currentVersion), + stdin: os.Stdin, + stdout: os.Stdout, + stderr: os.Stderr, + releaseURL: latestReleaseURL, + cachePath: defaultCachePath(), + } +} + +func (n notifier) notify() Result { + if !n.canNotify() { + return ResultContinue + } + currentState := n.readState() + now := time.Now() + lastCheckAge := now.Sub(currentState.LastCheckedAt) + if !currentState.LastCheckedAt.IsZero() && lastCheckAge >= 0 && lastCheckAge < checkInterval { + return ResultContinue + } + + release, err := n.fetchLatestReleaseWithTimeout() + currentState.LastCheckedAt = now + if err != nil { + _ = n.saveState(currentState) + return ResultContinue + } + if !isReleaseNewer(n.currentVersion, release.TagName) { + _ = n.saveState(currentState) + return ResultContinue + } + if n.hasSkippedRelease(release.TagName) { + _ = n.saveState(currentState) + return ResultContinue + } + + releaseNotesMarkdown := extractReleaseNotesMarkdown(release.Body) + updateCommand := detectUpdateCommand() + action, err := n.promptUpdate( + release, + releaseNotesMarkdown, + updateCommand, + isUpdateFormSupported( + runtime.GOOS, + os.Getenv("MSYSTEM"), + isatty.IsCygwinTerminal(n.stderr.Fd()), + ), + ) + if err != nil { + result := n.handlePromptError(err) + if result != ResultInterrupted { + _ = n.saveState(currentState) + } + return result + } + _ = n.saveState(currentState) + if action == updateActionSkipUntilNextVersion { + if err := n.saveSkippedRelease(release.TagName); err != nil { + _, _ = fmt.Fprintf( + n.stderr, + "failed to save update preference; the notification may be shown again: %v\n", + err, + ) + } + } + if action != updateActionRunUpgrade { + return ResultContinue + } + if !updateCommand.available() { + return ResultContinue + } + if err := n.runCommand(updateCommand); err != nil { + _, _ = fmt.Fprintf(n.stderr, "failed to update sloctl: %v\n", err) + return ResultExitFailure + } + return ResultExitSuccess +} + +func (n notifier) fetchLatestReleaseWithTimeout() (githubRelease, error) { + ctx, cancel := context.WithTimeout(context.Background(), checkTimeout) + defer cancel() + return n.fetchLatestRelease(ctx) +} + +func (n notifier) canNotify() bool { + return isTerminal(n.stdin) && + isTerminal(n.stderr) && + n.cachePath != "" && + os.Getenv(ciEnv) == "" && + os.Getenv(optOutEnv) == "" && + !isDevelopmentVersion(n.currentVersion) +} + +func isTerminal(file *os.File) bool { + fd := file.Fd() + return isatty.IsTerminal(fd) || isatty.IsCygwinTerminal(fd) +} + +func (n notifier) handlePromptError(err error) Result { + if errors.Is(err, huh.ErrUserAborted) { + return ResultInterrupted + } + _, _ = fmt.Fprintf( + n.stderr, + "failed to read update choice: %v; continuing with the requested command\n", + err, + ) + return ResultContinue +} + +func isUpdateFormSupported(goOS, msysEnvironment string, isCygwinTerminal bool) bool { + if goOS != "windows" { + return true + } + if !isCygwinTerminal { + return false + } + return !strings.EqualFold(strings.TrimSpace(msysEnvironment), "MSYS") +} + +func (n notifier) fetchLatestRelease(ctx context.Context) (githubRelease, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, n.releaseURL, nil) + if err != nil { + return githubRelease{}, err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", "sloctl") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return githubRelease{}, err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return githubRelease{}, fmt.Errorf("github release request returned status %d", resp.StatusCode) + } + + var release githubRelease + if err = json.NewDecoder(io.LimitReader(resp.Body, maxResponseSize)).Decode(&release); err != nil { + return githubRelease{}, err + } + if release.TagName == "" || release.HTMLURL == "" { + return githubRelease{}, fmt.Errorf("github release response is missing required fields") + } + return release, nil +} + +func (n notifier) readState() state { + data, err := os.ReadFile(n.cachePath) + if err != nil { + return state{} + } + var currentState state + if err = json.Unmarshal(data, ¤tState); err != nil { + return state{} + } + return currentState +} + +func (n notifier) saveState(currentState state) error { + if err := os.MkdirAll(filepath.Dir(n.cachePath), 0o700); err != nil { + return fmt.Errorf("create notification cache directory: %w", err) + } + data, err := json.MarshalIndent(currentState, "", " ") + if err != nil { + return fmt.Errorf("encode notification cache: %w", err) + } + if err := writeFileAtomically(n.cachePath, data); err != nil { + return fmt.Errorf("write notification cache: %w", err) + } + return nil +} + +func (n notifier) hasSkippedRelease(releaseTag string) bool { + _, err := os.Stat(n.skippedReleasePath(releaseTag)) + return err == nil +} + +func (n notifier) saveSkippedRelease(releaseTag string) error { + if err := os.MkdirAll(filepath.Dir(n.cachePath), 0o700); err != nil { + return fmt.Errorf("create notification cache directory: %w", err) + } + if err := os.WriteFile(n.skippedReleasePath(releaseTag), nil, 0o600); err != nil { + return fmt.Errorf("write update preference: %w", err) + } + return nil +} + +func (n notifier) skippedReleasePath(releaseTag string) string { + return filepath.Join(filepath.Dir(n.cachePath), "skip-"+releaseTag) +} + +func writeFileAtomically(path string, data []byte) error { + temporaryFile, err := os.CreateTemp(filepath.Dir(path), ".notifications-*") + if err != nil { + return err + } + temporaryPath := temporaryFile.Name() + defer func() { _ = os.Remove(temporaryPath) }() + + if _, err = temporaryFile.Write(data); err != nil { + _ = temporaryFile.Close() + return err + } + if err = temporaryFile.Close(); err != nil { + return err + } + return os.Rename(temporaryPath, path) +} + +func defaultCachePath() string { + cacheDir, err := os.UserCacheDir() + if err != nil { + return "" + } + return filepath.Join(cacheDir, "nobl9", "sloctl", "notifications.json") +} + +func isDevelopmentVersion(version string) bool { + version = strings.TrimSpace(version) + return version == "" || + version == "0.0.0" || + strings.HasSuffix(version, "-test") || + strings.Contains(version, "devel") +} + +func isReleaseNewer(currentVersion, releaseTag string) bool { + currentVersion = semanticVersion(currentVersion) + releaseTag = semanticVersion(releaseTag) + return semver.IsValid(currentVersion) && + semver.IsValid(releaseTag) && + semver.Compare(releaseTag, currentVersion) > 0 +} + +func semanticVersion(version string) string { + version = strings.TrimSpace(version) + if strings.HasPrefix(version, "v") { + return version + } + return "v" + version +} diff --git a/internal/notifications/notifications_test.go b/internal/notifications/notifications_test.go new file mode 100644 index 00000000..e3fc6224 --- /dev/null +++ b/internal/notifications/notifications_test.go @@ -0,0 +1,318 @@ +package notifications + +import ( + "errors" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + huh "charm.land/huh/v2" + "github.com/charmbracelet/x/ansi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNotifier_promptUpdate_WithoutForm(t *testing.T) { + t.Setenv("SLOCTL_ACCESSIBLE_MODE", "1") + const goUpdateCommand = "go install github.com/nobl9/sloctl/cmd/sloctl@latest" + tests := map[string]struct { + updateCommand updateCommand + showUpdateForm bool + expectedGuidance string + }{ + "detected updater": { + updateCommand: updateCommand{ + display: goUpdateCommand, + executable: "go", + }, + expectedGuidance: "Update with: " + goUpdateCommand, + }, + "installation guide": { + showUpdateForm: true, + expectedGuidance: "Installation options: " + installationGuideURL, + }, + "incomplete updater": { + updateCommand: updateCommand{display: goUpdateCommand}, + showUpdateForm: true, + expectedGuidance: "Installation options: " + installationGuideURL, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + stdin, err := os.CreateTemp(t.TempDir(), "stdin") + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, stdin.Close()) }) + stderr, err := os.CreateTemp(t.TempDir(), "stderr") + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, stderr.Close()) }) + + n := notifier{stdin: stdin, stderr: stderr} + action, err := n.promptUpdate( + githubRelease{ + TagName: "v1.2.3", + HTMLURL: "https://github.com/nobl9/sloctl/releases/tag/v1.2.3", + }, + "", + tt.updateCommand, + tt.showUpdateForm, + ) + require.NoError(t, err) + assert.Equal(t, updateActionSkip, action) + + _, err = stderr.Seek(0, io.SeekStart) + require.NoError(t, err) + output, err := io.ReadAll(stderr) + require.NoError(t, err) + plainOutput := ansi.Strip(string(output)) + assert.Contains(t, plainOutput, "New sloctl version v1.2.3 is available!") + assert.Contains(t, plainOutput, "https://github.com/nobl9/sloctl/releases/tag/v1.2.3") + assert.Contains(t, plainOutput, tt.expectedGuidance) + assert.NotContains(t, plainOutput, "Choose update action") + }) + } +} + +func Test_isGoInstallExecutable_ConfiguredGoEnvironment(t *testing.T) { + goExecutable, err := exec.LookPath("go") + require.NoError(t, err) + t.Setenv("GOENV", "off") + + t.Run("GOBIN", func(t *testing.T) { + goBin := t.TempDir() + t.Setenv("GOBIN", goBin) + t.Setenv("GOPATH", t.TempDir()) + + executablePath := writeTestSloctlExecutable(t, goBin) + assert.True(t, isGoInstallExecutable(executablePath, goExecutable)) + }) + + t.Run("GOPATH", func(t *testing.T) { + goPath := t.TempDir() + t.Setenv("GOBIN", "") + t.Setenv("GOPATH", goPath) + + executablePath := writeTestSloctlExecutable(t, filepath.Join(goPath, "bin")) + assert.True(t, isGoInstallExecutable(executablePath, goExecutable)) + }) +} + +func Test_isGoInstallExecutable_UsesFirstGOPATHEntry(t *testing.T) { + goExecutable, err := exec.LookPath("go") + require.NoError(t, err) + t.Setenv("GOENV", "off") + t.Setenv("GOBIN", "") + firstGoPath := t.TempDir() + secondGoPath := t.TempDir() + t.Setenv("GOPATH", strings.Join([]string{firstGoPath, secondGoPath}, string(os.PathListSeparator))) + + secondExecutable := writeTestSloctlExecutable(t, filepath.Join(secondGoPath, "bin")) + assert.False(t, isGoInstallExecutable(secondExecutable, goExecutable)) + + firstExecutable := writeTestSloctlExecutable(t, filepath.Join(firstGoPath, "bin")) + assert.True(t, isGoInstallExecutable(firstExecutable, goExecutable)) +} + +func Test_isGoInstallExecutable_UsesFileIdentity(t *testing.T) { + goExecutable, err := exec.LookPath("go") + require.NoError(t, err) + t.Setenv("GOENV", "off") + t.Setenv("GOPATH", t.TempDir()) + + if runtime.GOOS == "windows" { + goBin := t.TempDir() + t.Setenv("GOBIN", goBin) + executablePath := writeTestSloctlExecutable(t, goBin) + assert.True(t, isGoInstallExecutable(strings.ToUpper(executablePath), goExecutable)) + return + } + + realGoBin := t.TempDir() + goBin := filepath.Join(t.TempDir(), "bin") + require.NoError(t, os.Symlink(realGoBin, goBin)) + t.Setenv("GOBIN", goBin) + executablePath := writeTestSloctlExecutable(t, realGoBin) + assert.True(t, isGoInstallExecutable(executablePath, goExecutable)) +} + +func Test_isUpdateFormSupported(t *testing.T) { + t.Parallel() + tests := map[string]struct { + goOS string + msysEnvironment string + isCygwinTerminal bool + expectedIsSupported bool + }{ + "Linux": { + goOS: "linux", + expectedIsSupported: true, + }, + "Windows MinGW": { + goOS: "windows", + msysEnvironment: "MINGW64", + isCygwinTerminal: true, + expectedIsSupported: true, + }, + "Windows Cygwin": { + goOS: "windows", + isCygwinTerminal: true, + expectedIsSupported: true, + }, + "Windows MSYS": { + goOS: "windows", + msysEnvironment: "MSYS", + isCygwinTerminal: true, + }, + "Windows native shell": { + goOS: "windows", + }, + "Windows native shell launched from MinGW": { + goOS: "windows", + msysEnvironment: "MINGW64", + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + assert.Equal( + t, + tt.expectedIsSupported, + isUpdateFormSupported(tt.goOS, tt.msysEnvironment, tt.isCygwinTerminal), + ) + }) + } +} + +func Test_isReleaseNewer(t *testing.T) { + t.Parallel() + tests := map[string]struct { + currentVersion string + releaseTag string + expected bool + }{ + "newer patch": { + currentVersion: "1.2.3", + releaseTag: "v1.2.4", + expected: true, + }, + "stable after prerelease": { + currentVersion: "v1.2.3-rc.1", + releaseTag: "v1.2.3", + expected: true, + }, + "older release": { + currentVersion: "v1.2.3", + releaseTag: "v1.2.2", + }, + "same version with optional prefix": { + currentVersion: "1.2.3", + releaseTag: "v1.2.3", + }, + "equivalent build metadata": { + currentVersion: "v1.2.3+local", + releaseTag: "v1.2.3+release", + }, + "invalid current version": { + currentVersion: "unknown", + releaseTag: "v1.2.3", + }, + "invalid release tag": { + currentVersion: "v1.2.3", + releaseTag: "latest", + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.expected, isReleaseNewer(tt.currentVersion, tt.releaseTag)) + }) + } +} + +func Test_isReleaseNotesHeading(t *testing.T) { + t.Parallel() + tests := map[string]bool{ + "## 🚀 Features": true, + "## 🐞 Bug Fixes": true, + "## ⚠️ Breaking Changes": true, + "## 💻 Fixed Vulnerabilities": true, + "## Maintenance": false, + "## Prefixes": false, + "### Features": false, + } + for heading, expected := range tests { + t.Run(heading, func(t *testing.T) { + t.Parallel() + assert.Equal(t, expected, isReleaseNotesHeading(heading)) + }) + } +} + +func Test_notifier_handlePromptError(t *testing.T) { + tests := map[string]struct { + promptErr error + expectedResult Result + expectedOutput string + }{ + "user abort": { + promptErr: huh.ErrUserAborted, + expectedResult: ResultInterrupted, + }, + "wrapped user abort": { + promptErr: errors.Join(errors.New("render form"), huh.ErrUserAborted), + expectedResult: ResultInterrupted, + }, + "prompt failure": { + promptErr: errors.New("render form"), + expectedResult: ResultContinue, + expectedOutput: "failed to read update choice: render form; continuing with the requested command\n", + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + reader, writer, err := os.Pipe() + require.NoError(t, err) + t.Cleanup(func() { + _ = reader.Close() + _ = writer.Close() + }) + + n := notifier{stderr: writer} + result := n.handlePromptError(tt.promptErr) + require.NoError(t, writer.Close()) + output, err := io.ReadAll(reader) + require.NoError(t, err) + + assert.Equal(t, tt.expectedResult, result) + assert.Equal(t, tt.expectedOutput, string(output)) + }) + } +} + +func TestNotifier_StateWritesDoNotEraseSkippedRelease(t *testing.T) { + n := notifier{cachePath: filepath.Join(t.TempDir(), "notifications.json")} + + require.NoError(t, n.saveSkippedRelease("v1.2.3")) + require.NoError(t, n.saveState(state{LastCheckedAt: time.Now()})) + require.NoError(t, n.saveState(state{LastCheckedAt: time.Now().Add(time.Minute)})) + + assert.True(t, n.hasSkippedRelease("v1.2.3")) + assert.False(t, n.hasSkippedRelease("v1.2.4")) + assert.False(t, n.readState().LastCheckedAt.IsZero()) +} + +func writeTestSloctlExecutable(t *testing.T, directory string) string { + t.Helper() + require.NoError(t, os.MkdirAll(directory, 0o700)) + name := "sloctl" + if runtime.GOOS == "windows" { + name += ".exe" + } + path := filepath.Join(directory, name) + require.NoError(t, os.WriteFile(path, []byte("test"), 0o600)) + return path +} diff --git a/internal/notifications/prompt.go b/internal/notifications/prompt.go new file mode 100644 index 00000000..d22305ba --- /dev/null +++ b/internal/notifications/prompt.go @@ -0,0 +1,209 @@ +package notifications + +import ( + "fmt" + "os" + "strconv" + "strings" + + huh "charm.land/huh/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/glamour" + "golang.org/x/term" + + "github.com/nobl9/sloctl/internal/huhform" + "github.com/nobl9/sloctl/internal/style" +) + +const ( + defaultPromptWidth = 92 + minPromptWidth = 48 + installationGuideURL = "https://github.com/nobl9/sloctl#install" +) + +type updateAction string + +const ( + updateActionRunUpgrade updateAction = "run-upgrade" + updateActionSkip updateAction = "skip" + updateActionSkipUntilNextVersion updateAction = "skip-until-next-version" +) + +type terminalInfo struct { + width int + dark bool +} + +func (n notifier) promptUpdate( + release githubRelease, + releaseNotesMarkdown string, + command updateCommand, + showUpdateForm bool, +) (updateAction, error) { + terminal := n.terminalInfo() + showUpdateForm = showUpdateForm && command.available() + n.printNotification(release, releaseNotesMarkdown, command, showUpdateForm, terminal) + if !showUpdateForm { + return updateActionSkip, nil + } + + action := updateActionRunUpgrade + form := huhform.NewWithTheme( + huh.ThemeFunc(func(bool) *huh.Styles { + return style.HuhTheme(terminal.dark) + }), + huh.NewGroup( + huh.NewSelect[updateAction](). + Title("Choose update action"). + Options(updateActionOptions(command.display)...). + Value(&action), + ), + ). + WithInput(n.stdin). + WithOutput(n.stderr) + return action, form.Run() +} + +func (n notifier) printNotification( + release githubRelease, + releaseNotesMarkdown string, + command updateCommand, + showUpdateForm bool, + terminal terminalInfo, +) { + _, _ = fmt.Fprintln(n.stderr, renderNotification(release, releaseNotesMarkdown, terminal.width, terminal.dark)) + switch { + case !command.available(): + label := style.NotificationLabel(terminal.dark).Render("Installation options:") + link := style.NotificationLink(terminal.dark).Render(installationGuideURL) + _, _ = fmt.Fprintf(n.stderr, "\n%s %s\n", label, link) + case !showUpdateForm: + label := style.NotificationLabel(terminal.dark).Render("Update with:") + _, _ = fmt.Fprintf(n.stderr, "\n%s %s\n", label, command.display) + } + _, _ = fmt.Fprintln(n.stderr) + separator := style.NotificationSeparator(terminal.dark).Render(strings.Repeat("─", terminal.width)) + _, _ = fmt.Fprintln(n.stderr, separator) + _, _ = fmt.Fprintln(n.stderr) +} + +func (n notifier) terminalInfo() terminalInfo { + isDark := true + if style.ColorEnabled() { + isDark = lipgloss.HasDarkBackground(n.stdin, n.stderr) + } + return terminalInfo{ + width: notificationWidth(n.terminalWidth()), + dark: isDark, + } +} + +func (n notifier) terminalWidth() int { + //nolint:gosec // File descriptors are small non-negative integers. + fd := int(n.stderr.Fd()) + width, _, err := term.GetSize(fd) + if err != nil { + return widthFromColumnsEnv() + } + return width +} + +func updateActionOptions(updateCommand string) []huh.Option[updateAction] { + return []huh.Option[updateAction]{ + huh.NewOption(fmt.Sprintf("Update (runs %s)", updateCommand), updateActionRunUpgrade), + huh.NewOption("Skip", updateActionSkip), + huh.NewOption("Skip until next version", updateActionSkipUntilNextVersion), + } +} + +func notificationWidth(terminalWidth int) int { + if terminalWidth <= 0 { + return defaultPromptWidth + } + return min(max(terminalWidth-2, minPromptWidth), defaultPromptWidth) +} + +func widthFromColumnsEnv() int { + width, err := strconv.Atoi(os.Getenv("COLUMNS")) + if err != nil { + return defaultPromptWidth + } + return width +} + +func renderNotification(release githubRelease, releaseNotesMarkdown string, width int, isDark bool) string { + plainReleaseNotesDisplay := displayReleaseNotesMarkdown(releaseNotesMarkdown, false) + hasReleaseNotes := plainReleaseNotesDisplay != "" + rendered := styledPlainNotification(release, plainReleaseNotesDisplay, isDark) + if hasReleaseNotes { + releaseNotesDisplay := plainReleaseNotesDisplay + if style.ColorEnabled() { + releaseNotesDisplay = displayReleaseNotesMarkdown(releaseNotesMarkdown, true) + } + markdown := strings.Join([]string{ + "# " + releaseChangesTitle(release.TagName), + releaseNotesDisplay, + fmt.Sprintf("📜 %s", release.HTMLURL), + }, "\n\n") + + var err error + rendered, err = renderMarkdownWithGlamour(markdown, width, isDark) + if err != nil { + rendered = styledPlainNotification(release, plainReleaseNotesDisplay, isDark) + } + rendered = trimTrailingLineSpace(rendered) + } + return strings.TrimSpace(rendered) +} + +func renderMarkdownWithGlamour(markdown string, width int, isDark bool) (string, error) { + styleConfig := style.NotificationMarkdownStyle(isDark) + renderer, err := glamour.NewTermRenderer( + glamour.WithStyles(styleConfig), + glamour.WithWordWrap(width), + glamour.WithPreservedNewLines(), + ) + if err != nil { + return "", err + } + return renderer.Render(markdown) +} + +func styledPlainNotification(release githubRelease, releaseNotesDisplay string, isDark bool) string { + titleStyle := style.NotificationTitle(isDark) + linkStyle := style.NotificationLink(isDark) + labelStyle := style.NotificationLabel(isDark) + hasReleaseNotes := releaseNotesDisplay != "" + + parts := []string{ + titleStyle.Render(notificationTitle(hasReleaseNotes, release.TagName)), + } + if hasReleaseNotes { + parts = append(parts, releaseNotesDisplay) + } + parts = append(parts, labelStyle.Render("📜")+" "+linkStyle.Render(release.HTMLURL)) + return strings.Join(parts, "\n\n") +} + +func trimTrailingLineSpace(text string) string { + lines := strings.Split(text, "\n") + for i, line := range lines { + lines[i] = strings.TrimRight(line, " \t") + } + return strings.Join(lines, "\n") +} + +func notificationTitle(hasReleaseNotes bool, releaseTag string) string { + if !hasReleaseNotes { + return newVersionTitle(releaseTag) + } + return releaseChangesTitle(releaseTag) +} + +func newVersionTitle(releaseTag string) string { + return fmt.Sprintf("New sloctl version %s is available!", releaseTag) +} + +func releaseChangesTitle(releaseTag string) string { + return fmt.Sprintf("Changes in version %s", releaseTag) +} diff --git a/internal/notifications/release_notes.go b/internal/notifications/release_notes.go new file mode 100644 index 00000000..adbe57db --- /dev/null +++ b/internal/notifications/release_notes.go @@ -0,0 +1,137 @@ +package notifications + +import ( + "regexp" + "strings" +) + +var releaseMetadataPattern = regexp.MustCompile(`\s+\(#\d+\)(?:\s+@\S+)?$`) + +func extractReleaseNotesMarkdown(body string) string { + var sections []string + var section []string + inReleaseNotesSection := false + appendSection := func() { + markdown := strings.TrimSpace(strings.Join(section, "\n")) + if inReleaseNotesSection && hasTopLevelReleaseNote(markdown) { + sections = append(sections, markdown) + } + } + + for _, line := range strings.Split(body, "\n") { + trimmed := strings.TrimSpace(line) + if markdownHeadingLevel(trimmed) == 2 { + appendSection() + inReleaseNotesSection = isReleaseNotesHeading(trimmed) + section = nil + } + if inReleaseNotesSection { + section = append(section, line) + } + } + appendSection() + return strings.TrimSpace(strings.Join(sections, "\n\n")) +} + +func hasTopLevelReleaseNote(markdown string) bool { + inNestedSection := false + for _, line := range strings.Split(markdown, "\n") { + inNestedSection = updateNestedSectionState(line, inNestedSection) + if isTopLevelReleaseNoteLine(line, inNestedSection) { + return true + } + } + return false +} + +func updateNestedSectionState(line string, current bool) bool { + level := markdownHeadingLevel(line) + switch { + case level == 2: + return false + case level > 2: + return true + default: + return current + } +} + +func isTopLevelReleaseNoteLine(line string, inNestedSection bool) bool { + return !inNestedSection && strings.HasPrefix(line, "- ") +} + +func markdownHeadingLevel(line string) int { + line = strings.TrimSpace(line) + level := 0 + for level < len(line) && line[level] == '#' { + level++ + } + if level == 0 || level > 6 { + return 0 + } + if len(line) == level || line[level] == ' ' { + return level + } + return 0 +} + +func displayReleaseNotesMarkdown(markdown string, highlightTitles bool) string { + lines := strings.Split(markdown, "\n") + inNestedSection := false + for i, line := range lines { + inNestedSection = updateNestedSectionState(line, inNestedSection) + if !isTopLevelReleaseNoteLine(line, inNestedSection) { + continue + } + title := parseReleaseNote(line[2:]) + if title == "" { + continue + } + if highlightTitles { + title = "**" + title + "**" + } + lines[i] = "- " + title + } + return strings.TrimSpace(strings.Join(lines, "\n")) +} + +func isReleaseNotesHeading(line string) bool { + level := markdownHeadingLevel(line) + if level != 2 { + return false + } + heading := strings.ToLower(strings.TrimSpace(line[level:])) + for _, suffix := range [...]string{ + "features", + "fixes", + "breaking", + "breaking changes", + "vulnerabilities", + } { + if heading == suffix || strings.HasSuffix(heading, " "+suffix) { + return true + } + } + return false +} + +func parseReleaseNote(raw string) string { + title := trimReleaseNotePrefix(strings.TrimSpace(raw)) + title = releaseMetadataPattern.ReplaceAllString(title, "") + return strings.TrimSpace(title) +} + +func trimReleaseNotePrefix(title string) string { + lowerTitle := strings.ToLower(title) + for _, prefix := range [...]string{ + "feat:", + "fix:", + "breaking:", + "sec:", + } { + if strings.HasPrefix(lowerTitle, prefix) { + return strings.TrimSpace(title[len(prefix):]) + } + } + return title +} diff --git a/internal/root.go b/internal/root.go index d60a8ac1..4bc8678f 100644 --- a/internal/root.go +++ b/internal/root.go @@ -15,13 +15,25 @@ import ( "github.com/spf13/cobra" "github.com/nobl9/sloctl/internal/budgetadjustments" + "github.com/nobl9/sloctl/internal/notifications" ) const programName = "sloctl" -// Execute adds all child commands to the root command and sets flags appropriately. -// This is called by main.main(). It only needs to happen once to the rootCmd. +// Execute may check for updates before running the requested command. +// When the user chooses to update sloctl, it runs the update command and exits +// without running the requested command. func Execute() { + switch notifications.Notify(getBuildVersion()) { + case notifications.ResultExitSuccess: + return + case notifications.ResultExitFailure: + os.Exit(1) + case notifications.ResultInterrupted: + os.Exit(130) + case notifications.ResultContinue: + } + if err := NewRootCmd().Execute(); err != nil { os.Exit(1) } diff --git a/internal/style/theme.go b/internal/style/theme.go new file mode 100644 index 00000000..9412f5dd --- /dev/null +++ b/internal/style/theme.go @@ -0,0 +1,312 @@ +// Package style defines shared sloctl terminal styles. +package style + +import ( + "image/color" + "os" + + huh "charm.land/huh/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/glamour/ansi" + glamourstyles "github.com/charmbracelet/glamour/styles" +) + +const ( + blue900Hex = "#01465C" + blue800Hex = "#00819E" + blue600Hex = "#00BAD3" + blue200Hex = "#E5F9FD" + gray850Hex = "#676868" + mutedHex = "#BABBBB" + lightGrayHex = "#E8E9E9" + darkGrayHex = "#383939" + blackHex = "#000000" + whiteHex = "#FFFFFF" + pinkHex = "#DB2779" + redHex = "#D42E56" + noColorEnv = "NO_COLOR" +) + +var ( + darkGray = lipgloss.Color(darkGrayHex) + white = lipgloss.Color(whiteHex) + pink = lipgloss.Color(pinkHex) + red = lipgloss.Color(redHex) + darkModePalette = terminalPalette{ + accentHex: blue600Hex, + headingHex: blue600Hex, + textHex: lightGrayHex, + mutedHex: mutedHex, + strongHex: whiteHex, + optionHex: whiteHex, + selectedForegroundHex: whiteHex, + selectedBackgroundHex: blue900Hex, + codeForegroundHex: whiteHex, + codeBackgroundHex: blue900Hex, + } + lightModePalette = terminalPalette{ + accentHex: blue800Hex, + headingHex: blue800Hex, + textHex: gray850Hex, + mutedHex: gray850Hex, + strongHex: blackHex, + optionHex: blackHex, + selectedForegroundHex: whiteHex, + selectedBackgroundHex: blue800Hex, + codeForegroundHex: blue800Hex, + codeBackgroundHex: blue200Hex, + } +) + +type terminalPalette struct { + accentHex string + headingHex string + textHex string + mutedHex string + strongHex string + optionHex string + selectedForegroundHex string + selectedBackgroundHex string + codeForegroundHex string + codeBackgroundHex string +} + +func terminalPaletteFor(isDark bool) terminalPalette { + if isDark { + return darkModePalette + } + return lightModePalette +} + +func (p terminalPalette) accent() color.Color { + return lipgloss.Color(p.accentHex) +} + +func (p terminalPalette) muted() color.Color { + return lipgloss.Color(p.mutedHex) +} + +func (p terminalPalette) strong() color.Color { + return lipgloss.Color(p.strongHex) +} + +func (p terminalPalette) option() color.Color { + return lipgloss.Color(p.optionHex) +} + +func (p terminalPalette) selectedForeground() color.Color { + return lipgloss.Color(p.selectedForegroundHex) +} + +func (p terminalPalette) selectedBackground() color.Color { + return lipgloss.Color(p.selectedBackgroundHex) +} + +// HuhTheme returns the shared Nobl9 terminal theme for interactive forms. +func HuhTheme(isDark bool) *huh.Styles { + t := huh.ThemeBase(isDark) + if !ColorEnabled() { + return plainHuhTheme(t) + } + + p := terminalPaletteFor(isDark) + t.Focused.Base = t.Focused.Base.BorderForeground(p.accent()) + t.Focused.Card = t.Focused.Base + t.Focused.Title = t.Focused.Title.Foreground(p.accent()) + t.Focused.NoteTitle = t.Focused.NoteTitle.Foreground(p.accent()) + t.Focused.Directory = t.Focused.Directory.Foreground(p.accent()) + t.Focused.Description = t.Focused.Description.Foreground(p.muted()) + t.Focused.ErrorIndicator = t.Focused.ErrorIndicator.Foreground(red) + t.Focused.ErrorMessage = t.Focused.ErrorMessage.Foreground(red) + t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(p.accent()) + t.Focused.NextIndicator = t.Focused.NextIndicator.Foreground(p.accent()) + t.Focused.PrevIndicator = t.Focused.PrevIndicator.Foreground(p.accent()) + t.Focused.Option = t.Focused.Option.Foreground(p.option()).UnsetFaint() + t.Focused.MultiSelectSelector = t.Focused.MultiSelectSelector.Foreground(p.accent()) + t.Focused.SelectedOption = t.Focused.SelectedOption. + Foreground(p.selectedForeground()). + Background(p.selectedBackground()). + UnsetFaint(). + Bold(true) + t.Focused.SelectedPrefix = t.Focused.SelectedPrefix.Foreground(p.accent()) + t.Focused.UnselectedOption = t.Focused.UnselectedOption.Foreground(p.option()).UnsetFaint() + t.Focused.UnselectedPrefix = t.Focused.UnselectedPrefix.Foreground(p.option()).UnsetFaint() + t.Focused.FocusedButton = t.Focused.FocusedButton.Foreground(white).Background(pink) + t.Focused.BlurredButton = t.Focused.BlurredButton.Foreground(white).Background(darkGray) + + t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(pink) + t.Focused.TextInput.Placeholder = t.Focused.TextInput.Placeholder.Foreground(p.muted()) + t.Focused.TextInput.Prompt = t.Focused.TextInput.Prompt.Foreground(p.accent()) + + t.Help.ShortKey = t.Help.ShortKey.Foreground(p.accent()) + t.Help.FullKey = t.Help.FullKey.Foreground(p.accent()) + t.Help.ShortDesc = t.Help.ShortDesc.Foreground(p.muted()) + t.Help.FullDesc = t.Help.FullDesc.Foreground(p.muted()) + t.Help.ShortSeparator = t.Help.ShortSeparator.Foreground(p.muted()) + t.Help.FullSeparator = t.Help.FullSeparator.Foreground(p.muted()) + + t.Blurred = t.Focused + t.Blurred.Base = t.Blurred.Base.BorderStyle(lipgloss.HiddenBorder()) + t.Blurred.Card = t.Blurred.Base + t.Blurred.NoteTitle = t.Blurred.NoteTitle.Foreground(p.muted()) + t.Blurred.Title = t.Blurred.Title.Foreground(p.muted()) + + t.Blurred.TextInput.Prompt = t.Blurred.TextInput.Prompt.Foreground(p.muted()) + t.Blurred.TextInput.Text = t.Blurred.TextInput.Text.Foreground(p.strong()) + + t.Blurred.NextIndicator = lipgloss.NewStyle() + t.Blurred.PrevIndicator = lipgloss.NewStyle() + + t.Group.Title = t.Focused.Title + t.Group.Description = t.Focused.Description + + return t +} + +// NotificationTitle returns the shared style for notification titles. +func NotificationTitle(isDark bool) lipgloss.Style { + if !ColorEnabled() { + return lipgloss.NewStyle() + } + return lipgloss.NewStyle().Foreground(terminalPaletteFor(isDark).strong()).Bold(true) +} + +// NotificationLink returns the shared style for notification links. +func NotificationLink(isDark bool) lipgloss.Style { + if !ColorEnabled() { + return lipgloss.NewStyle() + } + return lipgloss.NewStyle().Foreground(terminalPaletteFor(isDark).accent()).Underline(true) +} + +// NotificationLabel returns the shared style for notification labels. +func NotificationLabel(isDark bool) lipgloss.Style { + if !ColorEnabled() { + return lipgloss.NewStyle() + } + return lipgloss.NewStyle().Foreground(terminalPaletteFor(isDark).muted()) +} + +// NotificationSeparator returns the shared style for notification dividers. +func NotificationSeparator(isDark bool) lipgloss.Style { + if !ColorEnabled() { + return lipgloss.NewStyle() + } + return lipgloss.NewStyle().Foreground(terminalPaletteFor(isDark).muted()) +} + +// ColorEnabled reports whether terminal styles should emit ANSI color. +func ColorEnabled() bool { + return os.Getenv(noColorEnv) == "" +} + +// NotificationMarkdownStyle returns the shared Glamour style for notifications. +func NotificationMarkdownStyle(isDark bool) ansi.StyleConfig { + if !ColorEnabled() { + return notificationASCIIMarkdownStyle() + } + p := terminalPaletteFor(isDark) + styleConfig := glamourstyles.LightStyleConfig + if isDark { + styleConfig = glamourstyles.DarkStyleConfig + } + styleConfig.Document.Margin = nil + styleConfig.Document.Color = new(p.textHex) + styleConfig.Paragraph.Color = new(p.textHex) + styleConfig.BlockQuote.Color = new(p.textHex) + for _, heading := range []*ansi.StyleBlock{ + &styleConfig.Heading, + &styleConfig.H2, + &styleConfig.H3, + &styleConfig.H4, + &styleConfig.H5, + &styleConfig.H6, + } { + setMarkdownHeading(heading, p.headingHex) + } + setMarkdownHeading(&styleConfig.H1, p.selectedForegroundHex) + styleConfig.H1.BackgroundColor = new(p.selectedBackgroundHex) + styleConfig.Strong.Color = new(p.strongHex) + styleConfig.Strong.Bold = new(true) + styleConfig.Item.Color = new(p.textHex) + styleConfig.Enumeration.Color = new(p.textHex) + styleConfig.Link.Color = new(p.accentHex) + styleConfig.Link.Underline = new(true) + styleConfig.LinkText.Color = new(p.accentHex) + styleConfig.LinkText.Underline = new(true) + styleConfig.HorizontalRule.Color = new(p.mutedHex) + styleConfig.Code.Color = new(p.codeForegroundHex) + styleConfig.Code.BackgroundColor = new(p.codeBackgroundHex) + return styleConfig +} + +func notificationASCIIMarkdownStyle() ansi.StyleConfig { + styleConfig := glamourstyles.ASCIIStyleConfig + styleConfig.Document.Margin = nil + return styleConfig +} + +func setMarkdownHeading(heading *ansi.StyleBlock, colorHex string) { + heading.Color = new(colorHex) + heading.Bold = new(true) +} + +func plainHuhTheme(t *huh.Styles) *huh.Styles { + t.Form.Base = plainStyle(t.Form.Base) + t.Group.Base = plainStyle(t.Group.Base) + t.Group.Title = plainStyle(t.Group.Title) + t.Group.Description = plainStyle(t.Group.Description) + t.FieldSeparator = plainStyle(t.FieldSeparator) + t.Focused = plainFieldStyles(t.Focused) + t.Blurred = plainFieldStyles(t.Blurred) + t.Help.ShortKey = plainStyle(t.Help.ShortKey) + t.Help.FullKey = plainStyle(t.Help.FullKey) + t.Help.ShortDesc = plainStyle(t.Help.ShortDesc) + t.Help.FullDesc = plainStyle(t.Help.FullDesc) + t.Help.ShortSeparator = plainStyle(t.Help.ShortSeparator) + t.Help.FullSeparator = plainStyle(t.Help.FullSeparator) + return t +} + +func plainFieldStyles(s huh.FieldStyles) huh.FieldStyles { + s.Base = plainStyle(s.Base) + s.Title = plainStyle(s.Title) + s.Description = plainStyle(s.Description) + s.ErrorIndicator = plainStyle(s.ErrorIndicator) + s.ErrorMessage = plainStyle(s.ErrorMessage) + s.SelectSelector = plainStyle(s.SelectSelector) + s.Option = plainStyle(s.Option) + s.NextIndicator = plainStyle(s.NextIndicator) + s.PrevIndicator = plainStyle(s.PrevIndicator) + s.Directory = plainStyle(s.Directory) + s.File = plainStyle(s.File) + s.MultiSelectSelector = plainStyle(s.MultiSelectSelector) + s.SelectedOption = plainStyle(s.SelectedOption) + s.SelectedPrefix = plainStyle(s.SelectedPrefix) + s.UnselectedOption = plainStyle(s.UnselectedOption) + s.UnselectedPrefix = plainStyle(s.UnselectedPrefix) + s.TextInput.Cursor = plainStyle(s.TextInput.Cursor) + s.TextInput.CursorText = plainStyle(s.TextInput.CursorText) + s.TextInput.Placeholder = plainStyle(s.TextInput.Placeholder) + s.TextInput.Prompt = plainStyle(s.TextInput.Prompt) + s.TextInput.Text = plainStyle(s.TextInput.Text) + s.FocusedButton = plainStyle(s.FocusedButton) + s.BlurredButton = plainStyle(s.BlurredButton) + s.Card = plainStyle(s.Card) + s.NoteTitle = plainStyle(s.NoteTitle) + s.Next = plainStyle(s.Next) + return s +} + +func plainStyle(s lipgloss.Style) lipgloss.Style { + return s. + UnsetForeground(). + UnsetBackground(). + UnsetBold(). + UnsetItalic(). + UnsetUnderline(). + UnsetStrikethrough(). + UnsetReverse(). + UnsetBlink(). + UnsetFaint() +} diff --git a/test/inputs/notifications/release-bodies/breaking.md b/test/inputs/notifications/release-bodies/breaking.md new file mode 100644 index 00000000..4502aea4 --- /dev/null +++ b/test/inputs/notifications/release-bodies/breaking.md @@ -0,0 +1,5 @@ +# What's Changed + +## Breaking Changes + +- Drop deprecated v1 config (#456) @octocat diff --git a/test/inputs/notifications/release-bodies/empty-features-then-bug-fixes.md b/test/inputs/notifications/release-bodies/empty-features-then-bug-fixes.md new file mode 100644 index 00000000..126783ce --- /dev/null +++ b/test/inputs/notifications/release-bodies/empty-features-then-bug-fixes.md @@ -0,0 +1,7 @@ +# Whats Changed + +## Features + +## Bug Fixes + +- Fix output formatting (#125) @octocat diff --git a/test/inputs/notifications/release-bodies/feature-without-author.md b/test/inputs/notifications/release-bodies/feature-without-author.md new file mode 100644 index 00000000..140e9c1c --- /dev/null +++ b/test/inputs/notifications/release-bodies/feature-without-author.md @@ -0,0 +1,5 @@ +# What's Changed + +## Features + +- Add direct upload (#333) diff --git a/test/inputs/notifications/release-bodies/feature.md b/test/inputs/notifications/release-bodies/feature.md new file mode 100644 index 00000000..28c4ff11 --- /dev/null +++ b/test/inputs/notifications/release-bodies/feature.md @@ -0,0 +1,5 @@ +# Whats Changed + +## Features + +- feat: Add notification tests (#321) @octocat diff --git a/test/inputs/notifications/release-bodies/features-with-details.md b/test/inputs/notifications/release-bodies/features-with-details.md new file mode 100644 index 00000000..35a90918 --- /dev/null +++ b/test/inputs/notifications/release-bodies/features-with-details.md @@ -0,0 +1,14 @@ +# What's Changed + +## 🚀 Features + +- Add workflow insights (#123) @octocat + > Extra release-note detail. + +### Details + +- Preserves nested feature details. + +## 🐞 Bug Fixes + +- Fix output formatting (#125) @octocat diff --git a/test/inputs/notifications/release-bodies/fixed-vulnerabilities.md b/test/inputs/notifications/release-bodies/fixed-vulnerabilities.md new file mode 100644 index 00000000..073df9aa --- /dev/null +++ b/test/inputs/notifications/release-bodies/fixed-vulnerabilities.md @@ -0,0 +1,5 @@ +# What's Changed + +## 💻 Fixed Vulnerabilities + +- sec: Patch vulnerable dependency (#987) @octocat diff --git a/test/inputs/notifications/release-bodies/maintenance.md b/test/inputs/notifications/release-bodies/maintenance.md new file mode 100644 index 00000000..7f253a0a --- /dev/null +++ b/test/inputs/notifications/release-bodies/maintenance.md @@ -0,0 +1,5 @@ +# Whats Changed + +## Maintenance + +- chore: Update dependencies (#124) @renovate diff --git a/test/inputs/notifications/release_server.py b/test/inputs/notifications/release_server.py new file mode 100644 index 00000000..1afc7913 --- /dev/null +++ b/test/inputs/notifications/release_server.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 + +import json +import os +import sys +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + + +RELEASE_PATH = "/repos/nobl9/sloctl/releases/latest" +DEFAULT_RELEASE_BODY_FILE = Path(__file__).with_name("release-bodies") / "feature.md" + + +class ReleaseHandler(BaseHTTPRequestHandler): + def do_GET(self): + log_request(self.command, self.path, self.headers) + if ( + self.path != RELEASE_PATH + or self.headers.get("Accept") != "application/vnd.github+json" + or self.headers.get("User-Agent") != "sloctl" + ): + self.send_response(HTTPStatus.BAD_GATEWAY) + self.end_headers() + return + + status = int(os.environ.get("RELEASE_SERVER_STATUS", "200")) + raw_body = os.environ.get("RELEASE_SERVER_RAW_RESPONSE") + if raw_body is None: + raw_body = json.dumps( + { + "tag_name": os.environ.get("RELEASE_SERVER_TAG", "v1.1.0"), + "body": release_body(), + "html_url": os.environ.get( + "RELEASE_SERVER_HTML_URL", + "https://github.com/nobl9/sloctl/releases/tag/v1.1.0", + ), + } + ) + body = raw_body.encode() + + self.send_response(status, reason_phrase(status)) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format, *args): + pass + + +class ReleaseServer(ThreadingHTTPServer): + allow_reuse_address = True + + +def release_body(): + body_file = os.environ.get("RELEASE_SERVER_BODY_FILE") + if body_file: + return Path(body_file).read_text(encoding="utf-8") + return DEFAULT_RELEASE_BODY_FILE.read_text(encoding="utf-8") + + +def log_request(method, path, headers): + log_path = os.environ.get("RELEASE_SERVER_LOG") + if not log_path: + return + with open(log_path, "a", encoding="utf-8") as log_file: + log_file.write( + json.dumps( + { + "method": method, + "path": path, + "accept": headers.get("Accept"), + "userAgent": headers.get("User-Agent"), + }, + sort_keys=True, + ) + + "\n" + ) + + +def reason_phrase(status): + try: + return HTTPStatus(status).phrase + except ValueError: + return "Status" + + +def main(): + port_file = Path(sys.argv[1]) + port = int(sys.argv[2]) + with ReleaseServer(("127.0.0.1", port), ReleaseHandler) as server: + port_file.write_text(str(server.server_address[1]), encoding="utf-8") + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/test/inputs/notifications/run_with_stderr_pty.py b/test/inputs/notifications/run_with_stderr_pty.py new file mode 100644 index 00000000..36252fb2 --- /dev/null +++ b/test/inputs/notifications/run_with_stderr_pty.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 + +import errno +import os +import pty +import selectors +import signal +import subprocess +import sys +import termios +import time +from contextlib import suppress + + +COMMAND_TIMEOUT_SECONDS = 10 + + +def main(): + if len(sys.argv) < 2: + print("usage: run_with_stderr_pty.py COMMAND [ARG...]", file=sys.stderr) + return 2 + + controller_fd = None + terminal_fd = None + process = None + process_completed = False + selector = selectors.DefaultSelector() + try: + controller_fd, terminal_fd = pty.openpty() + input_text = os.environ.get("SLOCTL_TEST_TTY_INPUT") + wait_for_raw_mode = ( + os.environ.get("SLOCTL_TEST_TTY_INPUT_WHEN_RAW") == "1" + ) + stdin = terminal_fd + if input_text is not None: + attrs = termios.tcgetattr(terminal_fd) + attrs[3] &= ~termios.ECHO + termios.tcsetattr(terminal_fd, termios.TCSANOW, attrs) + + process = subprocess.Popen( + sys.argv[1:], + stdin=stdin, + stdout=subprocess.PIPE, + stderr=terminal_fd, + close_fds=True, + start_new_session=True, + ) + deadline = time.monotonic() + COMMAND_TIMEOUT_SECONDS + if input_text is not None and not wait_for_raw_mode: + os.write(controller_fd, input_text.encode()) + if not wait_for_raw_mode: + os.close(terminal_fd) + terminal_fd = None + + selector.register(process.stdout, selectors.EVENT_READ, sys.stdout.buffer) + selector.register( + controller_fd, + selectors.EVENT_READ, + sys.stderr.buffer, + ) + while selector.get_map(): + remaining = deadline - time.monotonic() + if remaining <= 0: + print( + f"command timed out after {COMMAND_TIMEOUT_SECONDS} seconds", + file=sys.stderr, + ) + return 124 + + timeout = min(0.1, remaining) if terminal_fd is not None else remaining + for key, _ in selector.select(timeout): + try: + data = os.read(key.fd, 4096) + except OSError as err: + if err.errno != errno.EIO: + raise + data = b"" + + if not data: + selector.unregister(key.fileobj) + if isinstance(key.fileobj, int): + os.close(key.fileobj) + controller_fd = None + else: + key.fileobj.close() + continue + + key.data.write(data.replace(b"\r\n", b"\n").replace(b"\r", b"")) + key.data.flush() + if key.fileobj == controller_fd and terminal_fd is not None: + attrs = termios.tcgetattr(terminal_fd) + if not attrs[3] & termios.ICANON: + os.write(controller_fd, input_text.encode()) + os.close(terminal_fd) + terminal_fd = None + + if terminal_fd is not None and process.poll() is not None: + os.close(terminal_fd) + terminal_fd = None + + try: + status = process.wait(timeout=max(deadline - time.monotonic(), 0)) + except subprocess.TimeoutExpired: + print( + f"command timed out after {COMMAND_TIMEOUT_SECONDS} seconds", + file=sys.stderr, + ) + return 124 + process_completed = True + return status + finally: + selector.close() + if process is not None and not process_completed: + with suppress(ProcessLookupError): + os.killpg(process.pid, signal.SIGTERM) + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + pass + with suppress(ProcessLookupError): + os.killpg(process.pid, signal.SIGKILL) + with suppress(subprocess.TimeoutExpired): + process.wait(timeout=5) + if process is not None and process.stdout is not None: + process.stdout.close() + for fd in (controller_fd, terminal_fd): + if fd is not None: + with suppress(OSError): + os.close(fd) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/inputs/notifications/run_with_windows_pty.py b/test/inputs/notifications/run_with_windows_pty.py new file mode 100644 index 00000000..b074494a --- /dev/null +++ b/test/inputs/notifications/run_with_windows_pty.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 + +import os +import socket +import sys +import time + +from winpty.enums import Backend +from winpty.ptyprocess import PtyProcess + + +COMMAND_TIMEOUT_SECONDS = 10 +READ_TIMEOUT_SECONDS = 0.1 + + +def main(): + if len(sys.argv) < 2: + print("usage: run_with_windows_pty.py COMMAND [ARG...]", file=sys.stderr) + return 2 + + process = PtyProcess.spawn( + sys.argv[1:], + env=os.environ.copy(), + dimensions=(24, 80), + backend=Backend.ConPTY, + ) + process.fileobj.settimeout(READ_TIMEOUT_SECONDS) + deadline = time.monotonic() + COMMAND_TIMEOUT_SECONDS + chunks = [] + timed_out = False + while True: + if time.monotonic() >= deadline: + timed_out = True + break + try: + chunks.append(process.read()) + except socket.timeout: + continue + except EOFError: + break + + while not timed_out and process.isalive(): + remaining = deadline - time.monotonic() + if remaining <= 0: + timed_out = True + break + time.sleep(min(READ_TIMEOUT_SECONDS, remaining)) + + if timed_out: + process.terminate(force=True) + + output = "".join(chunks).replace("\r\n", "\n").replace("\r", "") + sys.stdout.buffer.write(output.encode("utf-8")) + sys.stdout.buffer.flush() + if timed_out: + print( + f"command timed out after {COMMAND_TIMEOUT_SECONDS} seconds", + file=sys.stderr, + ) + return 124 + return process.exitstatus + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/notifications.bats b/test/notifications.bats new file mode 100644 index 00000000..66a31f1e --- /dev/null +++ b/test/notifications.bats @@ -0,0 +1,662 @@ +#!/usr/bin/env bash +# bats file_tags=unit + +setup_file() { + load "test_helper/load" + + ensure_installed python3 + if [ -f "/.dockerenv" ] || [ -f "/run/.containerenv" ]; then + cp /usr/bin/sloctl /usr/local/bin/sloctl + fi + + export TEST_INPUTS="$BATS_TEST_DIRNAME/inputs/notifications" + export TEST_OUTPUTS="$BATS_TEST_DIRNAME/outputs/notifications" +} + +setup() { + load "test_helper/load" + load_lib "bats-support" + load_lib "bats-assert" + + unset \ + CI \ + ALL_PROXY \ + HTTPS_PROXY \ + HTTP_PROXY \ + NO_PROXY \ + SSL_CERT_FILE \ + all_proxy \ + https_proxy \ + http_proxy \ + no_proxy \ + GOBIN \ + GOPATH \ + SLOCTL_NO_NOTIFICATIONS \ + SLOCTL_TEST_TTY_INPUT \ + SLOCTL_TEST_TTY_INPUT_WHEN_RAW \ + SLOCTL_TEST_UPGRADE_EXIT_CODE \ + SLOCTL_TEST_UPGRADE_MARKER \ + RELEASE_SERVER_BODY_FILE \ + RELEASE_SERVER_HTML_URL \ + RELEASE_SERVER_RAW_RESPONSE \ + RELEASE_SERVER_STATUS \ + RELEASE_SERVER_TAG + + export NO_COLOR=1 + export SLOCTL_ACCESSIBLE_MODE=1 + export HOME="$BATS_TEST_TMPDIR/home" + export XDG_CACHE_HOME="$BATS_TMPDIR/cache-$BATS_TEST_NUMBER" + export LocalAppData="$BATS_TMPDIR/cache-$BATS_TEST_NUMBER" + export RELEASE_SERVER_LOG="$BATS_TMPDIR/release-server-$BATS_TEST_NUMBER.log" + export SLOCTL_TEST_TTY_INPUT=$'1\n' + local tools_dir="$BATS_TEST_TMPDIR/tools" + mkdir -p "$tools_dir" + printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'if [[ "${1:-}" == "env" ]]; then' \ + ' printf "{\"GOBIN\":\"%s\",\"GOPATH\":\"%s\"}\n" "${GOBIN:-}" "${GOPATH:-${HOME}/go}"' \ + ' exit 0' \ + 'fi' \ + 'if [[ -n "${SLOCTL_TEST_UPGRADE_MARKER:-}" ]]; then' \ + ' printf "%s\n" "$*" > "${SLOCTL_TEST_UPGRADE_MARKER}"' \ + 'fi' \ + 'exit "${SLOCTL_TEST_UPGRADE_EXIT_CODE:-0}"' \ + > "$tools_dir/go" + chmod +x "$tools_dir/go" + export PATH="$tools_dir:$PATH" + RELEASE_SERVER_START_COUNT=0 +} + +teardown() { + stop_release_server +} + +@test "sloctl shows a feature notification on TTY stderr and caches it" { + start_release_server + + run_sloctl_with_tty_stderr version + assert_success_joined_output + assert_notification_stderr feature-prompt-skip + assert_release_requests 1 + + run_sloctl_with_tty_stderr version + assert_success_joined_output + assert_stderr "" + assert_release_requests 1 +} + +@test "sloctl shows installation guidance before command validation" { + start_release_server + + run_sloctl_with_tty_stderr config rename-context old + assert_failure + assert_notification_stderr failed-command-after-skip + assert_release_requests 1 +} + +@test "sloctl skips the notification until the next version" { + local go_binary="$HOME/go/bin/sloctl" + copy_sloctl_binary "$go_binary" + select_update_action skip-until-next-version + start_release_server + + run_sloctl_binary_with_tty_stderr "$go_binary" version + assert_success_joined_output + assert_sloctl_version_output + assert_notification_stderr feature-prompt-skip-until-next-version + assert_release_requests 1 + + run_sloctl_binary_with_tty_stderr "$go_binary" version + assert_success_joined_output + assert_stderr "" + assert_release_requests 1 + + expire_notification_cache + stop_release_server + use_release_body feature-without-author + start_release_server + + run_sloctl_binary_with_tty_stderr "$go_binary" version + assert_success_joined_output + assert_stderr "" + assert_release_requests 2 + + expire_notification_cache + stop_release_server + unset RELEASE_SERVER_BODY_FILE + export RELEASE_SERVER_TAG=v1.2.0 + export RELEASE_SERVER_HTML_URL=https://github.com/nobl9/sloctl/releases/tag/v1.2.0 + start_release_server + + run_sloctl_binary_with_tty_stderr "$go_binary" version + assert_success_joined_output + assert_notification_stderr next-version-prompt-skip-until-next-version + assert_release_requests 3 +} + +@test "sloctl defaults to Go update action and exits without running the command" { + use_release_body maintenance + select_default_update_action + export SLOCTL_TEST_UPGRADE_MARKER="$BATS_TEST_TMPDIR/upgrade-ran" + local go_binary="$HOME/go/bin/sloctl" + copy_sloctl_binary "$go_binary" + start_release_server + + run_sloctl_binary_with_tty_stderr "$go_binary" version + assert_success_joined_output + assert_output "" + assert_notification_stderr version-prompt-run-upgrade + assert [ -f "$SLOCTL_TEST_UPGRADE_MARKER" ] + assert_equal \ + "$(< "$SLOCTL_TEST_UPGRADE_MARKER")" \ + "install github.com/nobl9/sloctl/cmd/sloctl@latest" + assert_release_requests 1 +} + +@test "sloctl reports a failed Go update and exits without running the command" { + use_release_body maintenance + select_default_update_action + export SLOCTL_TEST_UPGRADE_EXIT_CODE=22 + local go_binary="$HOME/go/bin/sloctl" + copy_sloctl_binary "$go_binary" + start_release_server + + run_sloctl_binary_with_tty_stderr "$go_binary" version + assert_failure 1 + assert_output "" + assert_notification_stderr version-prompt-failed-upgrade + assert_release_requests 1 +} + +@test "sloctl exits without running the command when the update prompt is interrupted" { + use_release_body maintenance + local go_binary="$HOME/go/bin/sloctl" + copy_sloctl_binary "$go_binary" + unset SLOCTL_ACCESSIBLE_MODE + export SLOCTL_TEST_TTY_INPUT=$'\x03' + export SLOCTL_TEST_TTY_INPUT_WHEN_RAW=1 + start_release_server + + run_sloctl_binary_with_tty_stderr "$go_binary" version + assert_failure 130 + assert_output "" + assert_stderr --partial "New sloctl version v1.1.0 is available!" + assert_release_requests 1 + + unset SLOCTL_TEST_TTY_INPUT_WHEN_RAW + export SLOCTL_ACCESSIBLE_MODE=1 + select_update_action skip + run_sloctl_binary_with_tty_stderr "$go_binary" version + assert_success_joined_output + assert_sloctl_version_output + assert_notification_stderr version-prompt-run-upgrade + assert_release_requests 2 +} + +@test "sloctl does not show feature notification when opted out" { + start_release_server + export SLOCTL_NO_NOTIFICATIONS=1 + + run_sloctl_with_tty_stderr version + assert_success_joined_output + assert_stderr "" + assert_release_requests 0 +} + +@test "sloctl does not show feature notification in CI" { + start_release_server + export CI=true + + run_sloctl_with_tty_stderr version + assert_success_joined_output + assert_stderr "" + assert_release_requests 0 +} + +@test "sloctl does not show feature notification without TTY stderr" { + start_release_server + + run_sloctl version + assert_success_joined_output + assert_stderr "" + assert_release_requests 0 +} + +@test "sloctl shows version notification when release has no feature notes" { + use_release_body maintenance + start_release_server + + run_sloctl_with_tty_stderr version + assert_success_joined_output + assert_notification_stderr version-prompt-skip + assert_release_requests 1 +} + +# bats test_tags=platform,platform:unix +@test "sloctl shows the new version notification and update form on supported terminals" { + use_release_body maintenance + local go_binary="$HOME/go/bin/sloctl" + copy_sloctl_binary "$go_binary" + select_update_action skip + start_release_server + + run_sloctl_binary_with_tty_stderr "$go_binary" version + assert_success_joined_output + assert_sloctl_version_output + # Exact prompt rendering is covered by unit cases; this test isolates platform form support. + assert_stderr --partial "New sloctl version v1.1.0 is available!" + assert_stderr --partial "Choose update action" + assert_release_requests 1 +} + +# bats test_tags=platform,platform:windows +@test "sloctl in a native Windows console shows the notification without the update form" { + if [[ "$(uname -s)" != MINGW* && "$(uname -s)" != CYGWIN* ]]; then + skip "Windows-specific compatibility test" + fi + + use_release_body maintenance + local go_binary="$HOME/go/bin/sloctl.exe" + local native_path="${PATH#*:}" + copy_sloctl_binary "$go_binary" + export GOBIN="$(cygpath -w "$(dirname "$go_binary")")" + start_release_server + + run_sloctl_binary_in_windows_console_with_path "$go_binary" "$native_path" version + assert_success_joined_output + assert_output --partial "New sloctl version v1.1.0 is available!" + assert_output --partial "Update with: go install github.com/nobl9/sloctl/cmd/sloctl@latest" + refute_output --partial "Choose update action" + assert_release_requests 1 +} + +@test "sloctl skips empty release notes sections" { + use_release_body empty-features-then-bug-fixes + start_release_server + + run_sloctl_with_tty_stderr version + assert_success_joined_output + assert_notification_stderr bug-fix-prompt-skip + assert_release_requests 1 +} + +@test "sloctl shows breaking change notification" { + use_release_body breaking + start_release_server + + run_sloctl_with_tty_stderr version + assert_success_joined_output + assert_notification_stderr breaking-prompt-skip + assert_release_requests 1 +} + +@test "sloctl shows fixed vulnerability notification" { + use_release_body fixed-vulnerabilities + start_release_server + + run_sloctl_with_tty_stderr version + assert_success_joined_output + assert_notification_stderr fixed-vulnerabilities-prompt-skip + assert_release_requests 1 +} + +@test "sloctl keeps nested details and additional release-note sections" { + use_release_body features-with-details + start_release_server + + run_sloctl_with_tty_stderr version + assert_success_joined_output + assert_notification_stderr features-with-details-prompt-skip + assert_release_requests 1 +} + +@test "sloctl shows release note without author metadata" { + use_release_body feature-without-author + start_release_server + + run_sloctl_with_tty_stderr version + assert_success_joined_output + assert_notification_stderr feature-without-author-prompt-skip + assert_release_requests 1 +} + +@test "sloctl does not show notification for current release" { + export RELEASE_SERVER_TAG=v1.0.0 + export RELEASE_SERVER_HTML_URL=https://github.com/nobl9/sloctl/releases/tag/v1.0.0 + start_release_server + + run_sloctl_with_tty_stderr version + assert_success_joined_output + assert_stderr "" + assert_release_requests 1 +} + +@test "sloctl does not show notification for an older release" { + export RELEASE_SERVER_TAG=v0.9.0 + export RELEASE_SERVER_HTML_URL=https://github.com/nobl9/sloctl/releases/tag/v0.9.0 + start_release_server + + run_sloctl_with_tty_stderr version + assert_success_joined_output + assert_stderr "" + assert_release_requests 1 +} + +@test "sloctl suppresses fetch failures and caches the check" { + export RELEASE_SERVER_STATUS=403 + export RELEASE_SERVER_RAW_RESPONSE="rate limited" + start_release_server + + run_sloctl_with_tty_stderr version + assert_success_joined_output + assert_stderr "" + + run_sloctl_with_tty_stderr version + assert_success_joined_output + assert_stderr "" + assert_release_requests 1 +} + +@test "sloctl suppresses malformed release responses" { + export RELEASE_SERVER_RAW_RESPONSE="{" + start_release_server + + run_sloctl_with_tty_stderr version + assert_success_joined_output + assert_stderr "" + assert_release_requests 1 +} + +@test "sloctl still shows notification when cache cannot be written" { + export XDG_CACHE_HOME="$BATS_TEST_TMPDIR/cache-file" + touch "$XDG_CACHE_HOME" + start_release_server + + run_sloctl_with_tty_stderr version + assert_success_joined_output + assert_notification_stderr feature-prompt-skip + assert_release_requests 1 +} + +@test "sloctl warns when skip until next version cannot be saved" { + use_release_body maintenance + local go_binary="$HOME/go/bin/sloctl" + copy_sloctl_binary "$go_binary" + select_update_action skip-until-next-version + export XDG_CACHE_HOME="$BATS_TEST_TMPDIR/cache-file" + touch "$XDG_CACHE_HOME" + start_release_server + + run_sloctl_binary_with_tty_stderr "$go_binary" version + assert_success_joined_output + assert_sloctl_version_output + assert_notification_stderr version-prompt-skip-until-cache-error + assert_release_requests 1 + + run_sloctl_binary_with_tty_stderr "$go_binary" version + assert_success_joined_output + assert_sloctl_version_output + assert_notification_stderr version-prompt-skip-until-cache-error + assert_release_requests 2 +} + +@test "sloctl checks again when the cached timestamp is in the future" { + start_release_server + + run_sloctl_with_tty_stderr version + assert_success_joined_output + assert_notification_stderr feature-prompt-skip + assert_release_requests 1 + + set_notification_cache_timestamp "2099-01-01T00:00:00Z" + run_sloctl_with_tty_stderr version + assert_success_joined_output + assert_notification_stderr feature-prompt-skip + assert_release_requests 2 +} + +# bats test_tags=platform,platform:macos +@test "sloctl runs Homebrew upgrade with the matching Homebrew executable" { + if [ "$(uname -s)" != "Darwin" ]; then + skip "native Homebrew compatibility is tested on macOS" + fi + + use_release_body maintenance + local cellar_binary="$BATS_TEST_TMPDIR/opt/homebrew/Cellar/sloctl/1.2.0/bin/sloctl" + local linked_binary="$BATS_TEST_TMPDIR/opt/homebrew/bin/sloctl" + local brew_binary="$BATS_TEST_TMPDIR/opt/homebrew/bin/brew" + copy_sloctl_binary "$cellar_binary" + mkdir -p "$(dirname "$linked_binary")" + ln -s "$cellar_binary" "$linked_binary" + export SLOCTL_TEST_UPGRADE_MARKER="$BATS_TEST_TMPDIR/upgrade-ran" + printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'printf "%s\n" "$*" > "${SLOCTL_TEST_UPGRADE_MARKER}"' \ + > "$brew_binary" + chmod +x "$brew_binary" + select_default_update_action + start_release_server + + run_sloctl_binary_with_tty_stderr "$linked_binary" version + assert_success_joined_output + assert_output "" + assert_notification_stderr install-homebrew-prompt + assert_equal "$(< "$SLOCTL_TEST_UPGRADE_MARKER")" "upgrade sloctl" + assert_release_requests 1 +} + +@test "sloctl suggests go install for Go bin installs" { + use_release_body maintenance + export HOME="$BATS_TEST_TMPDIR/home" + local go_binary="$HOME/go/bin/sloctl" + copy_sloctl_binary "$go_binary" + select_update_action skip + start_release_server + + run_sloctl_binary_with_tty_stderr "$go_binary" version + assert_success_joined_output + assert_sloctl_version_output + assert_notification_stderr install-go-prompt +} + +@test "sloctl shows the installation guide when Go is unavailable" { + use_release_body maintenance + local go_binary="$HOME/go/bin/sloctl" + local empty_path="$BATS_TEST_TMPDIR/empty-path" + copy_sloctl_binary "$go_binary" + mkdir -p "$empty_path" + unset SLOCTL_TEST_TTY_INPUT + start_release_server + + run_sloctl_binary_with_path "$go_binary" "$empty_path" version + assert_success_joined_output + assert_sloctl_version_output + assert_notification_stderr version-prompt-skip +} + +@test "sloctl shows the installation guide when the matching Homebrew is unavailable" { + use_release_body maintenance + local cellar_binary="$BATS_TEST_TMPDIR/opt/homebrew/Cellar/sloctl/1.2.0/bin/sloctl" + copy_sloctl_binary "$cellar_binary" + unset SLOCTL_TEST_TTY_INPUT + start_release_server + + run_sloctl_binary_with_tty_stderr "$cellar_binary" version + assert_success_joined_output + assert_sloctl_version_output + assert_notification_stderr version-prompt-skip +} + +@test "sloctl shows installation guide for unrecognized installs" { + use_release_body maintenance + local manual_binary="$BATS_TEST_TMPDIR/manual/sloctl" + copy_sloctl_binary "$manual_binary" + unset SLOCTL_TEST_TTY_INPUT + start_release_server + + run_sloctl_binary_with_tty_stderr "$manual_binary" version + assert_success_joined_output + assert_sloctl_version_output + assert_notification_stderr version-prompt-skip +} + +assert_notification_stderr() { + local name="$1" + local expected + expected="$(normalize_tty_output < "$TEST_OUTPUTS/$name.stderr")" + stderr="$(normalize_tty_output <<< "$stderr")" + assert_stderr "$expected" +} + +normalize_tty_output() { + sed \ + -e 's/\r//g' \ + -e 's/[[:blank:]]$//' \ + -e "s#${BATS_TEST_TMPDIR}##g" +} + +assert_sloctl_version_output() { + # The version prefix is fixed by the test target; suffix and build metadata vary by runner. + assert_output --partial "sloctl/v1.0.0" +} + +use_release_body() { + local name="$1" + export RELEASE_SERVER_BODY_FILE="$TEST_INPUTS/release-bodies/$name.md" +} + +select_update_action() { + case "$1" in + run-upgrade) + export SLOCTL_TEST_TTY_INPUT=$'1\n' + ;; + skip) + export SLOCTL_TEST_TTY_INPUT=$'2\n' + ;; + skip-until-next-version) + export SLOCTL_TEST_TTY_INPUT=$'3\n' + ;; + *) + fail "unknown update action: $1" + ;; + esac +} + +select_default_update_action() { + export SLOCTL_TEST_TTY_INPUT=$'\n' +} + +run_sloctl_with_tty_stderr() { + local binary="sloctl" + if has_bats_tag platform; then + binary="$(native_sloctl_binary)" + fi + run_sloctl_binary_with_tty_stderr "$binary" "$@" +} + +run_sloctl_binary_with_tty_stderr() { + local binary="$1" + shift + bats_require_minimum_version 1.5.0 + run --separate-stderr python3 "$TEST_INPUTS/run_with_stderr_pty.py" "$binary" "$@" +} + +run_sloctl_binary_in_windows_console_with_path() { + local binary="$1" + local path="$2" + shift 2 + bats_require_minimum_version 1.5.0 + + local helper python + helper="$(cygpath -w "$TEST_INPUTS/run_with_windows_pty.py")" + python="$(cygpath -u "$pythonLocation")/python.exe" + binary="$(cygpath -w "$binary")" + + run --separate-stderr env \ + PATH="$path" \ + "$python" "$helper" "$binary" "$@" +} + +run_sloctl_binary_with_path() { + local binary="$1" + local path="$2" + shift 2 + bats_require_minimum_version 1.5.0 + run --separate-stderr env PATH="$path" /usr/bin/python3 "$TEST_INPUTS/run_with_stderr_pty.py" "$binary" "$@" +} + +copy_sloctl_binary() { + local target="$1" + local source="/usr/local/bin/sloctl" + if has_bats_tag platform; then + source="$(native_sloctl_binary)" + fi + mkdir -p "$(dirname "$target")" + cp "$source" "$target" + chmod +x "$target" +} + +has_bats_tag() { + local expected="$1" + [[ " ${BATS_TEST_TAGS[*]} " == *" $expected "* ]] +} + +native_sloctl_binary() { + local binary="$BATS_TEST_DIRNAME/../bin/sloctl" + case "$(uname -s)" in + CYGWIN* | MINGW* | MSYS*) binary+=".exe" ;; + esac + printf '%s\n' "$binary" +} + +start_release_server() { + RELEASE_SERVER_START_COUNT=$((RELEASE_SERVER_START_COUNT + 1)) + local port_file="$BATS_TEST_TMPDIR/release-server-$RELEASE_SERVER_START_COUNT.port" + local error_file="$BATS_TEST_TMPDIR/release-server-$RELEASE_SERVER_START_COUNT.stderr" + python3 "$TEST_INPUTS/release_server.py" "$port_file" "$RELEASE_SERVER_PORT" 2> "$error_file" & + RELEASE_SERVER_PID="$!" + + for _ in {1..300}; do + if [[ -s "$port_file" ]]; then + return 0 + fi + if ! kill -0 "$RELEASE_SERVER_PID" 2> /dev/null; then + wait "$RELEASE_SERVER_PID" 2> /dev/null || true + unset RELEASE_SERVER_PID + local server_error + server_error="$(< "$error_file")" + fail "release server exited before startup: ${server_error:-no error output}" + fi + sleep 0.1 + done + + fail "release server did not start within 30 seconds" +} + +stop_release_server() { + if [ -n "${RELEASE_SERVER_PID:-}" ]; then + kill "$RELEASE_SERVER_PID" + wait "$RELEASE_SERVER_PID" 2> /dev/null || true + unset RELEASE_SERVER_PID + fi +} + +expire_notification_cache() { + set_notification_cache_timestamp "2000-01-01T00:00:00Z" +} + +set_notification_cache_timestamp() { + local timestamp="$1" + local cache_file="$XDG_CACHE_HOME/nobl9/sloctl/notifications.json" + sed -i 's/"lastCheckedAt": "[^"]*"/"lastCheckedAt": "'"$timestamp"'"/' "$cache_file" + assert_equal "$(jq -r '.lastCheckedAt' "$cache_file")" "$timestamp" +} + +assert_release_requests() { + local expected="$1" + local actual=0 + if [ -f "$RELEASE_SERVER_LOG" ]; then + actual="$(wc -l < "$RELEASE_SERVER_LOG" | tr -d " ")" + fi + assert_equal "$actual" "$expected" +} diff --git a/test/outputs/notifications/breaking-prompt-skip.stderr b/test/outputs/notifications/breaking-prompt-skip.stderr new file mode 100644 index 00000000..6aa8d343 --- /dev/null +++ b/test/outputs/notifications/breaking-prompt-skip.stderr @@ -0,0 +1,11 @@ +# Changes in version v1.1.0 + +## Breaking Changes + +• Drop deprecated v1 config + +📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 + +Installation options: https://github.com/nobl9/sloctl#install + +──────────────────────────────────────────────────────────────────────────────────────────── diff --git a/test/outputs/notifications/bug-fix-prompt-skip.stderr b/test/outputs/notifications/bug-fix-prompt-skip.stderr new file mode 100644 index 00000000..44271745 --- /dev/null +++ b/test/outputs/notifications/bug-fix-prompt-skip.stderr @@ -0,0 +1,11 @@ +# Changes in version v1.1.0 + +## Bug Fixes + +• Fix output formatting + +📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 + +Installation options: https://github.com/nobl9/sloctl#install + +──────────────────────────────────────────────────────────────────────────────────────────── diff --git a/test/outputs/notifications/failed-command-after-skip.stderr b/test/outputs/notifications/failed-command-after-skip.stderr new file mode 100644 index 00000000..1a2cd83f --- /dev/null +++ b/test/outputs/notifications/failed-command-after-skip.stderr @@ -0,0 +1,13 @@ +# Changes in version v1.1.0 + +## Features + +• Add notification tests + +📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 + +Installation options: https://github.com/nobl9/sloctl#install + +──────────────────────────────────────────────────────────────────────────────────────────── + +Error: either provide new and old context names or no arguments at all, received 1 arguments diff --git a/test/outputs/notifications/feature-prompt-skip-until-next-version.stderr b/test/outputs/notifications/feature-prompt-skip-until-next-version.stderr new file mode 100644 index 00000000..391c50e2 --- /dev/null +++ b/test/outputs/notifications/feature-prompt-skip-until-next-version.stderr @@ -0,0 +1,15 @@ +# Changes in version v1.1.0 + +## Features + +• Add notification tests + +📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 + +──────────────────────────────────────────────────────────────────────────────────────────── + +Choose update action +1. Update (runs go install github.com/nobl9/sloctl/cmd/sloctl@latest) +2. Skip +3. Skip until next version +Enter a number between 1 and 3: diff --git a/test/outputs/notifications/feature-prompt-skip.stderr b/test/outputs/notifications/feature-prompt-skip.stderr new file mode 100644 index 00000000..585187ab --- /dev/null +++ b/test/outputs/notifications/feature-prompt-skip.stderr @@ -0,0 +1,11 @@ +# Changes in version v1.1.0 + +## Features + +• Add notification tests + +📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 + +Installation options: https://github.com/nobl9/sloctl#install + +──────────────────────────────────────────────────────────────────────────────────────────── diff --git a/test/outputs/notifications/feature-without-author-prompt-skip.stderr b/test/outputs/notifications/feature-without-author-prompt-skip.stderr new file mode 100644 index 00000000..1af142ba --- /dev/null +++ b/test/outputs/notifications/feature-without-author-prompt-skip.stderr @@ -0,0 +1,11 @@ +# Changes in version v1.1.0 + +## Features + +• Add direct upload + +📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 + +Installation options: https://github.com/nobl9/sloctl#install + +──────────────────────────────────────────────────────────────────────────────────────────── diff --git a/test/outputs/notifications/features-with-details-prompt-skip.stderr b/test/outputs/notifications/features-with-details-prompt-skip.stderr new file mode 100644 index 00000000..1fbc9dfa --- /dev/null +++ b/test/outputs/notifications/features-with-details-prompt-skip.stderr @@ -0,0 +1,21 @@ +# Changes in version v1.1.0 + +## 🚀 Features + +• Add workflow insights +| Extra release-note detail. + + +### Details + +• Preserves nested feature details. + +## 🐞 Bug Fixes + +• Fix output formatting + +📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 + +Installation options: https://github.com/nobl9/sloctl#install + +──────────────────────────────────────────────────────────────────────────────────────────── diff --git a/test/outputs/notifications/fixed-vulnerabilities-prompt-skip.stderr b/test/outputs/notifications/fixed-vulnerabilities-prompt-skip.stderr new file mode 100644 index 00000000..7234ed8f --- /dev/null +++ b/test/outputs/notifications/fixed-vulnerabilities-prompt-skip.stderr @@ -0,0 +1,11 @@ +# Changes in version v1.1.0 + +## 💻 Fixed Vulnerabilities + +• Patch vulnerable dependency + +📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 + +Installation options: https://github.com/nobl9/sloctl#install + +──────────────────────────────────────────────────────────────────────────────────────────── diff --git a/test/outputs/notifications/install-go-prompt.stderr b/test/outputs/notifications/install-go-prompt.stderr new file mode 100644 index 00000000..1a8b8499 --- /dev/null +++ b/test/outputs/notifications/install-go-prompt.stderr @@ -0,0 +1,11 @@ +New sloctl version v1.1.0 is available! + +📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 + +──────────────────────────────────────────────────────────────────────────────────────────── + +Choose update action +1. Update (runs go install github.com/nobl9/sloctl/cmd/sloctl@latest) +2. Skip +3. Skip until next version +Enter a number between 1 and 3: diff --git a/test/outputs/notifications/install-homebrew-prompt.stderr b/test/outputs/notifications/install-homebrew-prompt.stderr new file mode 100644 index 00000000..314a73ae --- /dev/null +++ b/test/outputs/notifications/install-homebrew-prompt.stderr @@ -0,0 +1,11 @@ +New sloctl version v1.1.0 is available! + +📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 + +──────────────────────────────────────────────────────────────────────────────────────────── + +Choose update action +1. Update (runs brew upgrade sloctl) +2. Skip +3. Skip until next version +Enter a number between 1 and 3: diff --git a/test/outputs/notifications/next-version-prompt-skip-until-next-version.stderr b/test/outputs/notifications/next-version-prompt-skip-until-next-version.stderr new file mode 100644 index 00000000..477290e0 --- /dev/null +++ b/test/outputs/notifications/next-version-prompt-skip-until-next-version.stderr @@ -0,0 +1,15 @@ +# Changes in version v1.2.0 + +## Features + +• Add notification tests + +📜 https://github.com/nobl9/sloctl/releases/tag/v1.2.0 + +──────────────────────────────────────────────────────────────────────────────────────────── + +Choose update action +1. Update (runs go install github.com/nobl9/sloctl/cmd/sloctl@latest) +2. Skip +3. Skip until next version +Enter a number between 1 and 3: diff --git a/test/outputs/notifications/version-prompt-failed-upgrade.stderr b/test/outputs/notifications/version-prompt-failed-upgrade.stderr new file mode 100644 index 00000000..ea870631 --- /dev/null +++ b/test/outputs/notifications/version-prompt-failed-upgrade.stderr @@ -0,0 +1,12 @@ +New sloctl version v1.1.0 is available! + +📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 + +──────────────────────────────────────────────────────────────────────────────────────────── + +Choose update action +1. Update (runs go install github.com/nobl9/sloctl/cmd/sloctl@latest) +2. Skip +3. Skip until next version +Enter a number between 1 and 3: +failed to update sloctl: exit status 22 diff --git a/test/outputs/notifications/version-prompt-run-upgrade.stderr b/test/outputs/notifications/version-prompt-run-upgrade.stderr new file mode 100644 index 00000000..1a8b8499 --- /dev/null +++ b/test/outputs/notifications/version-prompt-run-upgrade.stderr @@ -0,0 +1,11 @@ +New sloctl version v1.1.0 is available! + +📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 + +──────────────────────────────────────────────────────────────────────────────────────────── + +Choose update action +1. Update (runs go install github.com/nobl9/sloctl/cmd/sloctl@latest) +2. Skip +3. Skip until next version +Enter a number between 1 and 3: diff --git a/test/outputs/notifications/version-prompt-skip-until-cache-error.stderr b/test/outputs/notifications/version-prompt-skip-until-cache-error.stderr new file mode 100644 index 00000000..b226cfb8 --- /dev/null +++ b/test/outputs/notifications/version-prompt-skip-until-cache-error.stderr @@ -0,0 +1,12 @@ +New sloctl version v1.1.0 is available! + +📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 + +──────────────────────────────────────────────────────────────────────────────────────────── + +Choose update action +1. Update (runs go install github.com/nobl9/sloctl/cmd/sloctl@latest) +2. Skip +3. Skip until next version +Enter a number between 1 and 3: +failed to save update preference; the notification may be shown again: create notification cache directory: mkdir /cache-file: not a directory diff --git a/test/outputs/notifications/version-prompt-skip.stderr b/test/outputs/notifications/version-prompt-skip.stderr new file mode 100644 index 00000000..7b6c4652 --- /dev/null +++ b/test/outputs/notifications/version-prompt-skip.stderr @@ -0,0 +1,7 @@ +New sloctl version v1.1.0 is available! + +📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 + +Installation options: https://github.com/nobl9/sloctl#install + +──────────────────────────────────────────────────────────────────────────────────────────── diff --git a/test/setup_platform_suite.bash b/test/setup_platform_suite.bash new file mode 100644 index 00000000..cacaef23 --- /dev/null +++ b/test/setup_platform_suite.bash @@ -0,0 +1,3 @@ +setup_suite() { + : +} diff --git a/test/test_helper/load.bash b/test/test_helper/load.bash index c5feec4c..ac623137 100644 --- a/test/test_helper/load.bash +++ b/test/test_helper/load.bash @@ -241,6 +241,10 @@ ensure_installed() { # Name of the library to load. load_lib() { local name="$1" + if [ -n "${BATS_LIB_PATH:-}" ]; then + bats_load_library "$name" + return + fi load "/usr/lib/bats/${name}/load.bash" } @@ -251,8 +255,8 @@ load_lib() { # # Usage: assert_success_joined_output # -# In case erroroneus code is detected, both stderr and stdout are conjoined. -# This is neccessary due to `run --separate-stderr` usage. +# In case an erroneous exit code is detected, both stderr and stdout are combined. +# This is necessary due to `run --separate-stderr` usage. # Otherwise, only stdout is printed which is not very useful. assert_success_joined_output() { output+=" @@ -283,12 +287,11 @@ $stderr" assert_success # 1 - otherwise # # Similarly to `assert_output`, this function verifies that a command or function produces the expected stderr. -# (It is the logical complement of `refute_stderr`.) # The stderr matching can be literal (the default), partial or by regular expression. # The expected stderr can be specified either by positional argument or read from STDIN by passing the `-`/`--stdin` flag. # # NOTE: This was copied from bats-assert, -# once a new version is avilable in the official Docker image, we can abandond this. +# once a new version is available in the official Docker image, we can abandon this. assert_stderr() { output="$stderr" assert_output "$@" @@ -340,4 +343,3 @@ generate_outputs() { export TEST_OUTPUTS } -