From d50505ddfb8f3df8588fb1634260590cd26034a9 Mon Sep 17 00:00:00 2001 From: Mateusz Hawrus Date: Thu, 21 May 2026 20:21:48 +0200 Subject: [PATCH 01/20] feat: add release feature notifications Show a cached TTY notification after successful commands when a newer sloctl release includes feature updates. Add coverage for notifier behavior and CLI execution flow, including end-to-end notification tests. --- go.mod | 27 +- go.sum | 62 ++- internal/execute_test.go | 43 ++ internal/notifications/notifications.go | 415 ++++++++++++++++++ internal/notifications/notifications_test.go | 398 +++++++++++++++++ internal/root.go | 16 +- test/inputs/notifications/release_proxy.py | 102 +++++ .../notifications/run_with_stderr_pty.py | 55 +++ test/notifications.bats | 124 ++++++ 9 files changed, 1223 insertions(+), 19 deletions(-) create mode 100644 internal/execute_test.go create mode 100644 internal/notifications/notifications.go create mode 100644 internal/notifications/notifications_test.go create mode 100644 test/inputs/notifications/release_proxy.py create mode 100644 test/inputs/notifications/run_with_stderr_pty.py create mode 100644 test/notifications.bats diff --git a/go.mod b/go.mod index bd64db87..f43648c8 100644 --- a/go.mod +++ b/go.mod @@ -7,9 +7,11 @@ require ( charm.land/lipgloss/v2 v2.0.3 github.com/BurntSushi/toml v1.6.0 github.com/OpenSLO/go-sdk v0.9.2 + github.com/charmbracelet/glamour v1.0.0 github.com/go-playground/validator/v10 v10.30.2 github.com/goccy/go-yaml v1.19.2 github.com/itchyny/gojq v0.12.19 + github.com/mattn/go-isatty v0.0.20 github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db github.com/nobl9/go-yaml v1.0.1 github.com/nobl9/nobl9-go v0.129.1 @@ -21,31 +23,40 @@ require ( github.com/stretchr/testify v1.11.1 github.com/tidwall/sjson v1.2.5 golang.org/x/sync v0.20.0 + golang.org/x/term v0.43.0 ) require ( - charm.land/bubbles/v2 v2.1.0 // indirect - charm.land/bubbletea/v2 v2.0.6 // indirect + charm.land/bubbles/v2 v2.0.0 // indirect + charm.land/bubbletea/v2 v2.0.2 // indirect + github.com/alecthomas/chroma/v2 v2.20.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/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect github.com/charmbracelet/x/ansi v0.11.7 // 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/strings v0.1.0 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect 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 v1.11.5 // 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,10 +64,12 @@ require ( 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/microcosm-cc/bluemonday v1.0.27 // indirect github.com/mitchellh/hashstructure/v2 v2.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/rivo/uniseg v0.4.7 // indirect @@ -65,11 +78,13 @@ 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.7.13 // indirect + github.com/yuin/goldmark-emoji v1.0.6 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect golang.org/x/crypto v0.51.0 // indirect golang.org/x/mod v0.36.0 // indirect + golang.org/x/net v0.54.0 // indirect golang.org/x/sys v0.44.0 // indirect - golang.org/x/term v0.43.0 // indirect golang.org/x/text v0.37.0 // indirect golang.org/x/tools v0.45.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect diff --git a/go.sum b/go.sum index 7d7fab37..0e9da66e 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.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s= +charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI= +charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= +charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= 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.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU= @@ -12,20 +12,36 @@ 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.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= +github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= +github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg= +github.com/alecthomas/repr v0.5.1/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/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= 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-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= +github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= 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= @@ -34,8 +50,10 @@ 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/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9nwd6aoEm7gkA= -github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= @@ -55,6 +73,8 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= 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 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 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= @@ -75,12 +95,16 @@ github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63Y github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +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= @@ -93,22 +117,27 @@ github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW 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-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 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/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= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= 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/nobl9/go-yaml v1.0.1 h1:Aj1kSaYdRQTKlvS6ihvXzQJhCpoHhtf9nfA95zqWH4Q= github.com/nobl9/go-yaml v1.0.1/go.mod h1:t7vCO8ctYdBweZxU5lUgxzAw31+ZcqJYeqRtrv+5RHI= github.com/nobl9/govy v0.26.0 h1:pjHXreO+3Rl+Uz6/rFX7L54zH4LW/SaTjb6YX3GQGs4= github.com/nobl9/govy v0.26.0/go.mod h1:fExiIzXORe0ktwg2bWasOAmCZtEFMQkR4PpgzhHcZSA= -github.com/nobl9/nobl9-go v0.129.0 h1:HkYMk+bnnPQ93k39qkE0hvTH8RiLmAydOGw0FJ3ZErw= -github.com/nobl9/nobl9-go v0.129.0/go.mod h1:UoFmEtBEI7tQL2Fnh+vXKtG5o0XMs3YJL41SlRxfuSY= github.com/nobl9/nobl9-go v0.129.1 h1:qJSbFwEuKBwOM+fALWQDOuUkWuLuCGA41w+vg6MXxVY= github.com/nobl9/nobl9-go v0.129.1/go.mod h1:UoFmEtBEI7tQL2Fnh+vXKtG5o0XMs3YJL41SlRxfuSY= github.com/nobl9/nobl9-openslo v0.1.6 h1:0VJscyj0va+DMYKhzKskI1w5Q9ZvqnowlvaHqjtCJP0= @@ -117,6 +146,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -144,6 +175,10 @@ 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.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= +github.com/yuin/goldmark v1.7.13/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= @@ -154,8 +189,11 @@ golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM 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/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= diff --git a/internal/execute_test.go b/internal/execute_test.go new file mode 100644 index 00000000..0bd1b5b9 --- /dev/null +++ b/internal/execute_test.go @@ -0,0 +1,43 @@ +package internal + +import ( + "errors" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +func TestExecuteRunsNotifierAfterSuccessfulCommand(t *testing.T) { + events := make([]string, 0, 2) + cmd := &cobra.Command{ + Use: "test", + Run: func(*cobra.Command, []string) { + events = append(events, "command") + }, + } + + exitCode := execute(cmd, func() { + events = append(events, "notification") + }) + + assert.Zero(t, exitCode) + assert.Equal(t, []string{"command", "notification"}, events) +} + +func TestExecuteSkipsNotifierAfterFailedCommand(t *testing.T) { + notificationCalled := false + cmd := &cobra.Command{ + Use: "test", + RunE: func(*cobra.Command, []string) error { + return errors.New("command failed") + }, + } + + exitCode := execute(cmd, func() { + notificationCalled = true + }) + + assert.Equal(t, 1, exitCode) + assert.False(t, notificationCalled) +} diff --git a/internal/notifications/notifications.go b/internal/notifications/notifications.go new file mode 100644 index 00000000..546bd843 --- /dev/null +++ b/internal/notifications/notifications.go @@ -0,0 +1,415 @@ +// Package notifications handles unobtrusive CLI notifications. +package notifications + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/glamour" + "github.com/mattn/go-isatty" + "golang.org/x/term" +) + +const ( + defaultReleaseURL = "https://api.github.com/repos/nobl9/sloctl/releases/latest" + optOutEnv = "SLOCTL_NO_NOTIFICATIONS" + ciEnv = "CI" + checkInterval = 24 * time.Hour + checkTimeout = 750 * time.Millisecond + maxResponseSize = 1 << 20 + defaultBoxWidth = 88 + minBoxWidth = 48 + boxPadding = 2 +) + +var ( + releaseMetadataPattern = regexp.MustCompile(`\s+\(#\d+\)(?:\s+@\S+)?$`) + releasePRPattern = regexp.MustCompile(`#(\d+)`) +) + +// Config defines notification runtime dependencies. +type Config struct { + CurrentVersion string + Stderr io.Writer + + ReleaseURL string + HTTPClient *http.Client + CachePath string + + Now func() time.Time + Getenv func(string) string + IsTTY func() bool + + TerminalWidth func() int + RenderMarkdown func(string, int) (string, error) +} + +// Notify checks and displays an unobtrusive notification when configured to do so. +func Notify(config Config) { + ctx, cancel := context.WithTimeout(context.Background(), checkTimeout) + defer cancel() + newNotifier(config).notify(ctx) +} + +type notifier struct { + currentVersion string + stderr io.Writer + releaseURL string + httpClient *http.Client + cachePath string + now func() time.Time + getenv func(string) string + isTTY func() bool + terminalWidth func() int + renderMarkdown func(string, int) (string, error) +} + +type state struct { + LastCheckedAt time.Time `json:"lastCheckedAt"` + LastShownReleaseTag string `json:"lastShownReleaseTag"` + LastShownFeatureID string `json:"lastShownFeatureID"` +} + +type githubRelease struct { + TagName string `json:"tag_name"` + Body string `json:"body"` + HTMLURL string `json:"html_url"` +} + +type feature struct { + ID string + Title string +} + +func newNotifier(config Config) notifier { + stderr := io.Writer(os.Stderr) + if config.Stderr != nil { + stderr = config.Stderr + } + releaseURL := config.ReleaseURL + if releaseURL == "" { + releaseURL = defaultReleaseURL + } + httpClient := config.HTTPClient + if httpClient == nil { + httpClient = &http.Client{Timeout: checkTimeout} + } + cachePath := config.CachePath + if cachePath == "" { + cachePath = defaultCachePath() + } + now := config.Now + if now == nil { + now = time.Now + } + getenv := config.Getenv + if getenv == nil { + getenv = os.Getenv + } + isTTY := config.IsTTY + if isTTY == nil { + isTTY = func() bool { + stderrFile, ok := stderr.(*os.File) + if !ok { + return false + } + return isatty.IsTerminal(stderrFile.Fd()) + } + } + terminalWidth := config.TerminalWidth + if terminalWidth == nil { + terminalWidth = func() int { + stderrFile, ok := stderr.(*os.File) + if !ok { + return defaultBoxWidth + } + fd, err := strconv.Atoi(strconv.FormatUint(uint64(stderrFile.Fd()), 10)) + if err != nil { + return widthFromColumnsEnv(getenv) + } + width, _, err := term.GetSize(fd) + if err != nil { + return widthFromColumnsEnv(getenv) + } + return width + } + } + renderMarkdown := config.RenderMarkdown + if renderMarkdown == nil { + renderMarkdown = renderMarkdownWithGlamour + } + return notifier{ + currentVersion: strings.TrimSpace(config.CurrentVersion), + stderr: stderr, + releaseURL: releaseURL, + httpClient: httpClient, + cachePath: cachePath, + now: now, + getenv: getenv, + isTTY: isTTY, + terminalWidth: terminalWidth, + renderMarkdown: renderMarkdown, + } +} + +func (n notifier) notify(ctx context.Context) { + if !n.canNotify() { + return + } + currentState := n.readState() + now := n.now() + if !currentState.LastCheckedAt.IsZero() && now.Sub(currentState.LastCheckedAt) < checkInterval { + return + } + + release, err := n.fetchLatestRelease(ctx) + currentState.LastCheckedAt = now + if err != nil { + n.saveState(currentState) + return + } + if isCurrentRelease(n.currentVersion, release.TagName) { + n.saveState(currentState) + return + } + featuresMarkdown, ok := featuresSection(release.Body) + if !ok { + n.saveState(currentState) + return + } + nextFeature, ok := firstFeature(featuresMarkdown) + if !ok { + n.saveState(currentState) + return + } + if alreadyShown(currentState, release.TagName, nextFeature.ID) { + n.saveState(currentState) + return + } + + _, _ = fmt.Fprintln(n.stderr, n.renderNotification(release, featuresMarkdown)) + currentState.LastShownReleaseTag = release.TagName + currentState.LastShownFeatureID = nextFeature.ID + n.saveState(currentState) +} + +func (n notifier) canNotify() bool { + return n.isTTY() && + n.cachePath != "" && + n.getenv(ciEnv) == "" && + n.getenv(optOutEnv) == "" && + !isDevelopmentVersion(n.currentVersion) +} + +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 := n.httpClient.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) { + if err := os.MkdirAll(filepath.Dir(n.cachePath), 0o700); err != nil { + return + } + data, err := json.MarshalIndent(currentState, "", " ") + if err != nil { + return + } + _ = os.WriteFile(n.cachePath, data, 0o600) +} + +func defaultCachePath() string { + cacheDir, err := os.UserCacheDir() + if err != nil { + return "" + } + return filepath.Join(cacheDir, "nobl9", "sloctl", "notifications.json") +} + +func (n notifier) renderNotification(release githubRelease, featuresMarkdown string) string { + width := notificationWidth(n.terminalWidth()) + border := lipgloss.RoundedBorder() + contentWidth := width - boxPadding - border.GetLeftSize() - border.GetRightSize() + markdown := strings.Join([]string{ + fmt.Sprintf("### New sloctl features in %s", release.TagName), + strings.TrimSpace(featuresMarkdown), + fmt.Sprintf("[View release](%s)", release.HTMLURL), + }, "\n\n") + + rendered, err := n.renderMarkdown(markdown, contentWidth) + if err != nil { + rendered = plainNotification(release, featuresMarkdown) + } + rendered = strings.TrimSpace(rendered) + + return lipgloss.NewStyle(). + BorderStyle(border). + BorderForeground(lipgloss.Color("#0EB46E")). + Padding(0, 1). + Width(contentWidth). + Render(rendered) +} + +func renderMarkdownWithGlamour(markdown string, width int) (string, error) { + renderer, err := glamour.NewTermRenderer( + glamour.WithStandardStyle("dark"), + glamour.WithWordWrap(width), + glamour.WithPreservedNewLines(), + ) + if err != nil { + return "", err + } + return renderer.Render(markdown) +} + +func plainNotification(release githubRelease, featuresMarkdown string) string { + return strings.Join([]string{ + fmt.Sprintf("New sloctl features in %s", release.TagName), + stripMarkdownHeading(featuresMarkdown), + release.HTMLURL, + }, "\n\n") +} + +func notificationWidth(terminalWidth int) int { + if terminalWidth <= 0 { + return defaultBoxWidth + } + return min(max(terminalWidth-2, minBoxWidth), defaultBoxWidth) +} + +func widthFromColumnsEnv(getenv func(string) string) int { + width, err := strconv.Atoi(getenv("COLUMNS")) + if err != nil { + return defaultBoxWidth + } + return width +} + +func featuresSection(body string) (string, bool) { + var lines []string + inFeaturesSection := false + for _, line := range strings.Split(body, "\n") { + trimmed := strings.TrimSpace(line) + if isLevel2Heading(trimmed) { + if inFeaturesSection { + break + } + inFeaturesSection = strings.Contains(trimmed, "Features") + } + if inFeaturesSection { + lines = append(lines, line) + } + } + section := strings.TrimSpace(strings.Join(lines, "\n")) + return section, section != "" +} + +func firstFeature(body string) (feature, bool) { + inFeaturesSection := false + for _, line := range strings.Split(body, "\n") { + trimmed := strings.TrimSpace(line) + if isLevel2Heading(trimmed) { + inFeaturesSection = strings.Contains(trimmed, "Features") + continue + } + if !inFeaturesSection || !strings.HasPrefix(trimmed, "- ") { + continue + } + return parseFeature(trimmed[2:]), true + } + return feature{}, false +} + +func stripMarkdownHeading(markdown string) string { + lines := strings.Split(markdown, "\n") + if len(lines) == 0 { + return markdown + } + if strings.HasPrefix(strings.TrimSpace(lines[0]), "##") { + return strings.TrimSpace(strings.Join(lines[1:], "\n")) + } + return strings.TrimSpace(markdown) +} + +func isLevel2Heading(line string) bool { + return strings.HasPrefix(line, "## ") || line == "##" +} + +func parseFeature(raw string) feature { + title := strings.TrimSpace(raw) + title = strings.TrimPrefix(title, "feat:") + title = strings.TrimPrefix(title, "Feat:") + title = strings.TrimSpace(title) + id := title + if matches := releasePRPattern.FindStringSubmatch(raw); len(matches) == 2 { + id = matches[1] + } + title = releaseMetadataPattern.ReplaceAllString(title, "") + return feature{ + ID: id, + Title: strings.TrimSpace(title), + } +} + +func alreadyShown(currentState state, releaseTag, featureID string) bool { + return currentState.LastShownReleaseTag == releaseTag && currentState.LastShownFeatureID == featureID +} + +func isDevelopmentVersion(version string) bool { + version = strings.TrimSpace(version) + return version == "" || + version == "0.0.0" || + strings.HasSuffix(version, "-test") || + strings.Contains(version, "devel") +} + +func isCurrentRelease(currentVersion, releaseTag string) bool { + return normalizeVersion(currentVersion) == normalizeVersion(releaseTag) +} + +func normalizeVersion(version string) string { + return strings.TrimPrefix(strings.TrimSpace(version), "v") +} diff --git a/internal/notifications/notifications_test.go b/internal/notifications/notifications_test.go new file mode 100644 index 00000000..1e3a18df --- /dev/null +++ b/internal/notifications/notifications_test.go @@ -0,0 +1,398 @@ +package notifications + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFirstFeature(t *testing.T) { + tests := map[string]struct { + body string + expected feature + ok bool + }{ + "release drafter features section": { + body: `# What's Changed + +## 🚀 Features + +- feat: Add workflow insights (#123) @octocat +- Improve import behavior (#124) @hubot + +## 🐞 Bug Fixes + +- Fix output formatting (#125) @octocat +`, + expected: feature{ID: "123", Title: "Add workflow insights"}, + ok: true, + }, + "feature with release notes blockquote": { + body: `# What's Changed + +## 🚀 Features + +- Add replay controls (#222) @octocat + > Release note text is not part of the notification title. + +## 🧰 Maintenance + +- Update dependencies (#223) @renovate +`, + expected: feature{ID: "222", Title: "Add replay controls"}, + ok: true, + }, + "feature without author": { + body: `## Features + +- Add direct upload (#333) +`, + expected: feature{ID: "333", Title: "Add direct upload"}, + ok: true, + }, + "missing features section": { + body: `## 🐞 Bug Fixes + +- Fix output formatting (#125) @octocat +`, + }, + "empty features section": { + body: `## 🚀 Features + +## 🐞 Bug Fixes + +- Fix output formatting (#125) @octocat +`, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + got, ok := firstFeature(tt.body) + + assert.Equal(t, tt.ok, ok) + assert.Equal(t, tt.expected, got) + }) + } +} + +func TestFeaturesSection(t *testing.T) { + body := `# 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 +` + + got, ok := featuresSection(body) + + require.True(t, ok) + assert.Equal(t, `## 🚀 Features + +- Add workflow insights (#123) @octocat + > Extra release-note detail. + +### Details + +- Preserves nested feature details.`, got) +} + +func TestNotifier_notifyDisplaysFeatureAndCaches(t *testing.T) { + now := time.Date(2026, 4, 28, 12, 0, 0, 0, time.UTC) + requests := 0 + client := testHTTPClient(func(r *http.Request) (int, string) { + requests++ + assert.Equal(t, "application/vnd.github+json", r.Header.Get("Accept")) + assert.Equal(t, "sloctl", r.Header.Get("User-Agent")) + return http.StatusOK, releaseJSON(t, githubRelease{ + TagName: "v1.2.0", + Body: `## 🚀 Features + +- feat: Add workflow insights (#123) @octocat +`, + HTMLURL: "https://github.com/nobl9/sloctl/releases/tag/v1.2.0", + }) + }) + + var out bytes.Buffer + config := testConfig(t, &out, now) + config.HTTPClient = client + var renderedMarkdown string + var renderWidth int + config.RenderMarkdown = func(markdown string, width int) (string, error) { + renderedMarkdown = markdown + renderWidth = width + return markdown, nil + } + + newNotifier(config).notify(context.Background()) + + assert.Equal(t, 1, requests) + assert.Equal(t, 82, renderWidth) + assert.Equal(t, `### New sloctl features in v1.2.0 + +## 🚀 Features + +- feat: Add workflow insights (#123) @octocat + +[View release](https://github.com/nobl9/sloctl/releases/tag/v1.2.0)`, renderedMarkdown) + plainOutput := stripANSI(out.String()) + assert.Contains(t, plainOutput, "╭") + assert.Contains(t, plainOutput, "New sloctl features in v1.2.0") + assert.Contains(t, plainOutput, "## 🚀 Features") + assert.Contains(t, plainOutput, "- feat: Add workflow insights (#123) @octocat") + assert.Contains(t, plainOutput, "[View release](https://github.com/nobl9/sloctl/releases/tag/v1.2.0)") + + out.Reset() + newNotifier(config).notify(context.Background()) + + assert.Equal(t, 1, requests) + assert.Empty(t, out.String()) +} + +func TestNotifier_notifyFallsBackWhenMarkdownRenderingFails(t *testing.T) { + client := testHTTPClient(func(*http.Request) (int, string) { + return http.StatusOK, releaseJSON(t, githubRelease{ + TagName: "v1.2.0", + Body: `## 🚀 Features + +- Add workflow insights (#123) @octocat +`, + HTMLURL: "https://github.com/nobl9/sloctl/releases/tag/v1.2.0", + }) + }) + + var out bytes.Buffer + config := testConfig(t, &out, time.Now()) + config.HTTPClient = client + config.RenderMarkdown = func(string, int) (string, error) { + return "", errors.New("render failed") + } + + newNotifier(config).notify(context.Background()) + + plainOutput := stripANSI(out.String()) + assert.Contains(t, plainOutput, "New sloctl features in v1.2.0") + assert.Contains(t, plainOutput, "- Add workflow insights (#123) @octocat") + assert.Contains(t, plainOutput, "https://github.com/nobl9/sloctl/releases/tag/v1.2.0") +} + +func TestNotifier_notifySkipsBeforeFetch(t *testing.T) { + tests := map[string]struct { + configure func(Config) Config + }{ + "ci": { + configure: func(config Config) Config { + config.Getenv = func(key string) string { + if key == ciEnv { + return "true" + } + return "" + } + return config + }, + }, + "development version": { + configure: func(config Config) Config { + config.CurrentVersion = "1.0.0-test" + return config + }, + }, + "non tty": { + configure: func(config Config) Config { + config.IsTTY = func() bool { return false } + return config + }, + }, + "opt out": { + configure: func(config Config) Config { + config.Getenv = func(key string) string { + if key == optOutEnv { + return "1" + } + return "" + } + return config + }, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + var out bytes.Buffer + requests := 0 + config := testConfig(t, &out, time.Now()) + config.HTTPClient = testHTTPClient(func(*http.Request) (int, string) { + requests++ + return http.StatusOK, "{}" + }) + config = tt.configure(config) + + newNotifier(config).notify(context.Background()) + + assert.Zero(t, requests) + assert.Empty(t, out.String()) + }) + } +} + +func TestNotifier_notifySkipsCurrentRelease(t *testing.T) { + requests := 0 + client := testHTTPClient(func(*http.Request) (int, string) { + requests++ + return http.StatusOK, releaseJSON(t, githubRelease{ + TagName: "v1.2.0", + Body: `## 🚀 Features + +- Add workflow insights (#123) @octocat +`, + HTMLURL: "https://github.com/nobl9/sloctl/releases/tag/v1.2.0", + }) + }) + + var out bytes.Buffer + config := testConfig(t, &out, time.Now()) + config.CurrentVersion = "1.2.0" + config.HTTPClient = client + + newNotifier(config).notify(context.Background()) + + assert.Equal(t, 1, requests) + assert.Empty(t, out.String()) +} + +func TestNotifier_notifySuppressesFetchFailuresAndCachesCheck(t *testing.T) { + now := time.Date(2026, 4, 28, 12, 0, 0, 0, time.UTC) + requests := 0 + client := testHTTPClient(func(*http.Request) (int, string) { + requests++ + return http.StatusForbidden, "rate limited" + }) + + var out bytes.Buffer + config := testConfig(t, &out, now) + config.HTTPClient = client + + newNotifier(config).notify(context.Background()) + config.Now = func() time.Time { return now.Add(time.Hour) } + newNotifier(config).notify(context.Background()) + + assert.Equal(t, 1, requests) + assert.Empty(t, out.String()) + + currentState := readState(t, config.CachePath) + assert.Equal(t, now, currentState.LastCheckedAt) +} + +func TestNotifier_notifySuppressesMalformedRelease(t *testing.T) { + client := testHTTPClient(func(*http.Request) (int, string) { + return http.StatusOK, "{" + }) + + var out bytes.Buffer + config := testConfig(t, &out, time.Now()) + config.HTTPClient = client + + newNotifier(config).notify(context.Background()) + + assert.Empty(t, out.String()) +} + +func TestNotifier_notifyDoesNotSurfaceCacheWriteFailure(t *testing.T) { + client := testHTTPClient(func(*http.Request) (int, string) { + return http.StatusOK, releaseJSON(t, githubRelease{ + TagName: "v1.2.0", + Body: `## 🚀 Features + +- Add workflow insights (#123) @octocat +`, + HTMLURL: "https://github.com/nobl9/sloctl/releases/tag/v1.2.0", + }) + }) + + var out bytes.Buffer + config := testConfig(t, &out, time.Now()) + config.HTTPClient = client + config.CachePath = t.TempDir() + + newNotifier(config).notify(context.Background()) + + assert.Contains(t, stripANSI(out.String()), "New sloctl features in v1.2.0") + assert.Contains(t, stripANSI(out.String()), "Add workflow insights") +} + +func testConfig(t *testing.T, out *bytes.Buffer, now time.Time) Config { + t.Helper() + return Config{ + CurrentVersion: "1.1.0", + Stderr: out, + ReleaseURL: "https://example.com/repos/nobl9/sloctl/releases/latest", + CachePath: filepath.Join(t.TempDir(), "notifications.json"), + Now: func() time.Time { return now }, + Getenv: func(string) string { return "" }, + IsTTY: func() bool { return true }, + TerminalWidth: func() int { return defaultBoxWidth }, + RenderMarkdown: func(markdown string, _ int) (string, error) { return markdown, nil }, + } +} + +func releaseJSON(t *testing.T, release githubRelease) string { + t.Helper() + data, err := json.Marshal(release) + require.NoError(t, err) + return string(data) +} + +func readState(t *testing.T, path string) state { + t.Helper() + data, err := os.ReadFile(path) + require.NoError(t, err) + var currentState state + require.NoError(t, json.Unmarshal(data, ¤tState)) + return currentState +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func testHTTPClient(handler func(*http.Request) (int, string)) *http.Client { + return &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + statusCode, body := handler(req) + return &http.Response{ + StatusCode: statusCode, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + Request: req, + }, nil + })} +} + +var ansiPattern = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +func stripANSI(s string) string { + return ansiPattern.ReplaceAllString(s, "") +} diff --git a/internal/root.go b/internal/root.go index dd55a662..ee333e8f 100644 --- a/internal/root.go +++ b/internal/root.go @@ -12,6 +12,7 @@ import ( "github.com/spf13/cobra" "github.com/nobl9/sloctl/internal/budgetadjustments" + "github.com/nobl9/sloctl/internal/notifications" ) const programName = "sloctl" @@ -19,11 +20,24 @@ 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. func Execute() { - if err := NewRootCmd().Execute(); err != nil { + if execute(NewRootCmd(), func() { + notifications.Notify(notifications.Config{ + CurrentVersion: getBuildVersion(), + Stderr: os.Stderr, + }) + }) != 0 { os.Exit(1) } } +func execute(cmd *cobra.Command, notify func()) int { + if err := cmd.Execute(); err != nil { + return 1 + } + notify() + return 0 +} + type globalFlags struct { ConfigFile string Context string diff --git a/test/inputs/notifications/release_proxy.py b/test/inputs/notifications/release_proxy.py new file mode 100644 index 00000000..851ce545 --- /dev/null +++ b/test/inputs/notifications/release_proxy.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 + +import json +import socketserver +import ssl +import sys +from pathlib import Path + + +GITHUB_HOST = "api.github.com" +GITHUB_PORT = "443" +RELEASE_PATH = "/repos/nobl9/sloctl/releases/latest" + + +class Proxy(socketserver.BaseRequestHandler): + def handle(self): + try: + self.handle_connect() + except OSError: + return + + def handle_connect(self): + request = read_headers(self.request) + request_line = request.splitlines()[0] + method, target, _ = request_line.split(" ", 2) + if method != "CONNECT" or target != f"{GITHUB_HOST}:{GITHUB_PORT}": + self.request.sendall(b"HTTP/1.1 502 Bad Gateway\r\n\r\n") + return + + self.request.sendall(b"HTTP/1.1 200 Connection Established\r\n\r\n") + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(certfile=self.server.cert_file, keyfile=self.server.key_file) + tls_socket = context.wrap_socket(self.request, server_side=True) + try: + self.handle_github_request(tls_socket) + finally: + tls_socket.close() + + def handle_github_request(self, tls_socket): + request = read_headers(tls_socket) + lines = request.splitlines() + method, path, _ = lines[0].split(" ", 2) + headers = parse_headers(lines[1:]) + if method != "GET" or path != RELEASE_PATH or headers.get("host") != GITHUB_HOST: + tls_socket.sendall(b"HTTP/1.1 502 Bad Gateway\r\n\r\n") + return + + body = json.dumps( + { + "tag_name": "v1.1.0", + "body": "## Features\n\n- feat: Add notification tests (#321) @octocat\n", + "html_url": "https://github.com/nobl9/sloctl/releases/tag/v1.1.0", + } + ).encode() + response = ( + b"HTTP/1.1 200 OK\r\n" + b"Content-Type: application/json\r\n" + + f"Content-Length: {len(body)}\r\n".encode() + + b"Connection: close\r\n" + + b"\r\n" + + body + ) + tls_socket.sendall(response) + + +class ThreadingTCPServer(socketserver.ThreadingTCPServer): + allow_reuse_address = True + + +def read_headers(sock): + data = b"" + while b"\r\n\r\n" not in data: + chunk = sock.recv(4096) + if not chunk: + raise OSError("connection closed while reading headers") + data += chunk + return data.decode("iso-8859-1") + + +def parse_headers(lines): + headers = {} + for line in lines: + if not line: + continue + name, value = line.split(":", 1) + headers[name.lower()] = value.strip() + return headers + + +def main(): + port_file = Path(sys.argv[1]) + cert_file = sys.argv[2] + key_file = sys.argv[3] + with ThreadingTCPServer(("127.0.0.1", 0), Proxy) as server: + server.cert_file = cert_file + server.key_file = key_file + 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..6d0ef0a4 --- /dev/null +++ b/test/inputs/notifications/run_with_stderr_pty.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 + +import errno +import os +import pty +import selectors +import subprocess +import sys + + +def main(): + if len(sys.argv) < 2: + print("usage: run_with_stderr_pty.py COMMAND [ARG...]", file=sys.stderr) + return 2 + + master_fd, slave_fd = pty.openpty() + process = subprocess.Popen( + sys.argv[1:], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=slave_fd, + close_fds=True, + ) + os.close(slave_fd) + + selector = selectors.DefaultSelector() + selector.register(process.stdout, selectors.EVENT_READ, sys.stdout.buffer) + selector.register(master_fd, selectors.EVENT_READ, sys.stderr.buffer) + + while selector.get_map(): + for key, _ in selector.select(): + fd = key.fileobj if isinstance(key.fileobj, int) else key.fileobj.fileno() + try: + data = os.read(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) + else: + key.fileobj.close() + continue + + key.data.write(data.replace(b"\r\n", b"\n")) + key.data.flush() + + return process.wait() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/notifications.bats b/test/notifications.bats new file mode 100644 index 00000000..c0c83c65 --- /dev/null +++ b/test/notifications.bats @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# bats file_tags=unit + +setup_file() { + export TEST_INPUTS="$BATS_TEST_DIRNAME/inputs/notifications" +} + +setup() { + load "test_helper/load" + load_lib "bats-support" + load_lib "bats-assert" + + ensure_installed openssl python3 + + unset CI + unset ALL_PROXY + unset HTTPS_PROXY + unset HTTP_PROXY + unset NO_PROXY + unset SSL_CERT_FILE + unset all_proxy + unset https_proxy + unset http_proxy + unset no_proxy + unset SLOCTL_NO_NOTIFICATIONS + export XDG_CACHE_HOME="$BATS_TMPDIR/cache" +} + +teardown() { + if [ -n "${RELEASE_PROXY_PID:-}" ]; then + kill "$RELEASE_PROXY_PID" + wait "$RELEASE_PROXY_PID" 2> /dev/null || true + fi +} + +start_release_proxy() { + local port_file="$BATS_TMPDIR/release-proxy-port" + local cert_config="$BATS_TMPDIR/api-github-openssl.cnf" + local cert_file="$BATS_TMPDIR/api-github.pem" + local key_file="$BATS_TMPDIR/api-github-key.pem" + + cat > "$cert_config" << EOF +[req] +distinguished_name = dn +x509_extensions = extensions +prompt = no + +[dn] +CN = api.github.com + +[extensions] +subjectAltName = DNS:api.github.com +EOF + + openssl req \ + -x509 \ + -newkey rsa:2048 \ + -nodes \ + -keyout "$key_file" \ + -out "$cert_file" \ + -days 1 \ + -config "$cert_config" \ + 2> /dev/null + + python3 "$TEST_INPUTS/release_proxy.py" "$port_file" "$cert_file" "$key_file" & + RELEASE_PROXY_PID="$!" + + for _ in {1..50}; do + if [ -s "$port_file" ]; then + local port + port="$(cat "$port_file")" + export HTTPS_PROXY="http://127.0.0.1:$port" + export SSL_CERT_FILE="$cert_file" + return 0 + fi + sleep 0.1 + done + + fail "release proxy did not start" +} + +run_sloctl_with_stderr_pty() { + bats_require_minimum_version 1.5.0 + run --separate-stderr python3 "$TEST_INPUTS/run_with_stderr_pty.py" sloctl "$@" +} + +refute_stderr() { + local actual="$stderr" + output="$actual" refute_output "$@" +} + +@test "sloctl shows a feature notification on TTY stderr and caches it" { + start_release_proxy + + run_sloctl_with_stderr_pty version + assert_success_joined_output + assert_stderr --partial "╭" + assert_stderr --partial "New sloctl features in" + assert_stderr --partial "v1.1.0" + assert_stderr --partial "Features" + assert_stderr --partial "Add notification tests" + assert_stderr --partial "https://github.com/nobl9/sloctl/releases/tag/v1.1.0" + + run_sloctl_with_stderr_pty version + assert_success_joined_output + assert_stderr "" +} + +@test "sloctl does not show feature notification when opted out" { + start_release_proxy + export SLOCTL_NO_NOTIFICATIONS=1 + + run_sloctl_with_stderr_pty version + assert_success_joined_output + assert_stderr "" +} + +@test "sloctl does not show feature notification without TTY stderr" { + start_release_proxy + + run_sloctl version + assert_success_joined_output + refute_stderr --partial "New sloctl feature" +} From 30b2efdd81c53d096c6c70c010ac905912b03aca Mon Sep 17 00:00:00 2001 From: Mateusz Hawrus Date: Tue, 26 May 2026 13:49:33 +0200 Subject: [PATCH 02/20] feat: show update command in release notifications Detect how sloctl was installed so notifications can suggest the right upgrade command for Homebrew, `go install`, or the install script fallback. --- internal/notifications/notifications.go | 136 ++++++++++++++++++- internal/notifications/notifications_test.go | 114 +++++++++++++++- test/notifications.bats | 4 + 3 files changed, 241 insertions(+), 13 deletions(-) diff --git a/internal/notifications/notifications.go b/internal/notifications/notifications.go index 546bd843..d45815e6 100644 --- a/internal/notifications/notifications.go +++ b/internal/notifications/notifications.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "os" + "os/exec" "path/filepath" "regexp" "strconv" @@ -49,9 +50,12 @@ type Config struct { Now func() time.Time Getenv func(string) string IsTTY func() bool + Lookup func(string) (string, error) TerminalWidth func() int RenderMarkdown func(string, int) (string, error) + Executable func() (string, error) + EvalSymlinks func(string) (string, error) } // Notify checks and displays an unobtrusive notification when configured to do so. @@ -70,8 +74,11 @@ type notifier struct { now func() time.Time getenv func(string) string isTTY func() bool + lookup func(string) (string, error) terminalWidth func() int renderMarkdown func(string, int) (string, error) + executable func() (string, error) + evalSymlinks func(string) (string, error) } type state struct { @@ -91,6 +98,14 @@ type feature struct { Title string } +type installChannel string + +const ( + installChannelScript installChannel = "script" + installChannelHomebrew installChannel = "homebrew" + installChannelGo installChannel = "go-install" +) + func newNotifier(config Config) notifier { stderr := io.Writer(os.Stderr) if config.Stderr != nil { @@ -148,6 +163,18 @@ func newNotifier(config Config) notifier { if renderMarkdown == nil { renderMarkdown = renderMarkdownWithGlamour } + lookup := config.Lookup + if lookup == nil { + lookup = exec.LookPath + } + executable := config.Executable + if executable == nil { + executable = os.Executable + } + evalSymlinks := config.EvalSymlinks + if evalSymlinks == nil { + evalSymlinks = filepath.EvalSymlinks + } return notifier{ currentVersion: strings.TrimSpace(config.CurrentVersion), stderr: stderr, @@ -157,8 +184,11 @@ func newNotifier(config Config) notifier { now: now, getenv: getenv, isTTY: isTTY, + lookup: lookup, terminalWidth: terminalWidth, renderMarkdown: renderMarkdown, + executable: executable, + evalSymlinks: evalSymlinks, } } @@ -273,15 +303,20 @@ func (n notifier) renderNotification(release githubRelease, featuresMarkdown str width := notificationWidth(n.terminalWidth()) border := lipgloss.RoundedBorder() contentWidth := width - boxPadding - border.GetLeftSize() - border.GetRightSize() - markdown := strings.Join([]string{ + updateCommand := n.updateCommand() + parts := []string{ fmt.Sprintf("### New sloctl features in %s", release.TagName), strings.TrimSpace(featuresMarkdown), fmt.Sprintf("[View release](%s)", release.HTMLURL), - }, "\n\n") + } + if updateCommand != "" { + parts = append(parts, fmt.Sprintf("Update sloctl:\n\n```shell\n%s\n```", updateCommand)) + } + markdown := strings.Join(parts, "\n\n") rendered, err := n.renderMarkdown(markdown, contentWidth) if err != nil { - rendered = plainNotification(release, featuresMarkdown) + rendered = plainNotification(release, featuresMarkdown, updateCommand) } rendered = strings.TrimSpace(rendered) @@ -305,12 +340,101 @@ func renderMarkdownWithGlamour(markdown string, width int) (string, error) { return renderer.Render(markdown) } -func plainNotification(release githubRelease, featuresMarkdown string) string { - return strings.Join([]string{ +func plainNotification(release githubRelease, featuresMarkdown string, updateCommand string) string { + parts := []string{ fmt.Sprintf("New sloctl features in %s", release.TagName), stripMarkdownHeading(featuresMarkdown), release.HTMLURL, - }, "\n\n") + } + if updateCommand != "" { + parts = append(parts, "Update sloctl:\n"+updateCommand) + } + return strings.Join(parts, "\n\n") +} + +func (n notifier) updateCommand() string { + switch n.installChannel() { + case installChannelHomebrew: + return "brew upgrade sloctl" + case installChannelGo: + return "go install github.com/nobl9/sloctl/cmd/sloctl@latest" + case installChannelScript: + return n.scriptUpdateCommand() + default: + return "" + } +} + +func (n notifier) installChannel() installChannel { + executablePath, err := n.executable() + if err != nil { + return installChannelScript + } + resolvedPath, err := n.evalSymlinks(executablePath) + if err != nil { + resolvedPath = executablePath + } + if isHomebrewExecutable(resolvedPath) { + return installChannelHomebrew + } + if isGoInstallExecutable(resolvedPath, n.getenv) { + return installChannelGo + } + return installChannelScript +} + +func (n notifier) scriptUpdateCommand() string { + const scriptURL = "https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash" + if _, err := n.lookup("curl"); err == nil { + return "curl -fsSL " + scriptURL + " | bash" + } + if _, err := n.lookup("wget"); err == nil { + return "wget -O - -q " + scriptURL + " | bash" + } + return "" +} + +func isHomebrewExecutable(path string) bool { + return strings.Contains(filepath.ToSlash(path), "/Cellar/sloctl/") +} + +func isGoInstallExecutable(path string, getenv func(string) string) bool { + path = filepath.Clean(path) + for _, binDir := range goBinDirs(getenv) { + if samePath(path, filepath.Join(binDir, "sloctl")) || + samePath(path, filepath.Join(binDir, "sloctl.exe")) { + return true + } + } + return false +} + +func goBinDirs(getenv func(string) string) []string { + if goBin := strings.TrimSpace(getenv("GOBIN")); goBin != "" { + return []string{goBin} + } + goPath := strings.TrimSpace(getenv("GOPATH")) + if goPath == "" { + homeDir := strings.TrimSpace(getenv("HOME")) + if homeDir == "" { + homeDir = strings.TrimSpace(getenv("USERPROFILE")) + } + if homeDir == "" { + return nil + } + goPath = filepath.Join(homeDir, "go") + } + var binDirs []string + for _, path := range filepath.SplitList(goPath) { + if path != "" { + binDirs = append(binDirs, filepath.Join(path, "bin")) + } + } + return binDirs +} + +func samePath(a, b string) bool { + return filepath.Clean(a) == filepath.Clean(b) } func notificationWidth(terminalWidth int) int { diff --git a/internal/notifications/notifications_test.go b/internal/notifications/notifications_test.go index 1e3a18df..63842962 100644 --- a/internal/notifications/notifications_test.go +++ b/internal/notifications/notifications_test.go @@ -150,19 +150,21 @@ func TestNotifier_notifyDisplaysFeatureAndCaches(t *testing.T) { assert.Equal(t, 1, requests) assert.Equal(t, 82, renderWidth) - assert.Equal(t, `### New sloctl features in v1.2.0 - -## 🚀 Features - -- feat: Add workflow insights (#123) @octocat - -[View release](https://github.com/nobl9/sloctl/releases/tag/v1.2.0)`, renderedMarkdown) + assert.Equal(t, strings.Join([]string{ + "### New sloctl features in v1.2.0", + "## 🚀 Features\n\n- feat: Add workflow insights (#123) @octocat", + "[View release](https://github.com/nobl9/sloctl/releases/tag/v1.2.0)", + "Update sloctl:\n\n```shell\ncurl -fsSL https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash\n```", + }, "\n\n"), renderedMarkdown) plainOutput := stripANSI(out.String()) assert.Contains(t, plainOutput, "╭") assert.Contains(t, plainOutput, "New sloctl features in v1.2.0") assert.Contains(t, plainOutput, "## 🚀 Features") assert.Contains(t, plainOutput, "- feat: Add workflow insights (#123) @octocat") assert.Contains(t, plainOutput, "[View release](https://github.com/nobl9/sloctl/releases/tag/v1.2.0)") + assert.Contains(t, plainOutput, "Update sloctl") + assert.Contains(t, plainOutput, "curl -fsSL https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash |") + assert.Contains(t, plainOutput, "bash") out.Reset() newNotifier(config).notify(context.Background()) @@ -196,6 +198,101 @@ func TestNotifier_notifyFallsBackWhenMarkdownRenderingFails(t *testing.T) { assert.Contains(t, plainOutput, "New sloctl features in v1.2.0") assert.Contains(t, plainOutput, "- Add workflow insights (#123) @octocat") assert.Contains(t, plainOutput, "https://github.com/nobl9/sloctl/releases/tag/v1.2.0") + assert.Contains(t, plainOutput, "Update sloctl:") + assert.Contains(t, plainOutput, "curl -fsSL https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash |") + assert.Contains(t, plainOutput, "bash") +} + +func TestNotifier_updateCommand(t *testing.T) { + tests := map[string]struct { + executable string + resolved string + env map[string]string + availableTools map[string]bool + expected string + }{ + "homebrew cellar": { + executable: "/opt/homebrew/bin/sloctl", + resolved: "/opt/homebrew/Cellar/sloctl/1.2.0/bin/sloctl", + expected: "brew upgrade sloctl", + }, + "go bin from GOBIN": { + executable: "/home/me/bin/sloctl", + env: map[string]string{ + "GOBIN": "/home/me/bin", + }, + expected: "go install github.com/nobl9/sloctl/cmd/sloctl@latest", + }, + "go bin from GOPATH": { + executable: "/home/me/go-work/bin/sloctl", + env: map[string]string{ + "GOPATH": "/home/me/go-work", + }, + expected: "go install github.com/nobl9/sloctl/cmd/sloctl@latest", + }, + "go bin from default GOPATH": { + executable: "/home/me/go/bin/sloctl", + env: map[string]string{ + "HOME": "/home/me", + }, + expected: "go install github.com/nobl9/sloctl/cmd/sloctl@latest", + }, + "script prefers curl": { + executable: "/usr/local/bin/sloctl", + availableTools: map[string]bool{ + "curl": true, + "wget": true, + }, + expected: "curl -fsSL https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash", + }, + "script falls back to wget": { + executable: "/usr/local/bin/sloctl", + availableTools: map[string]bool{ + "wget": true, + }, + expected: "wget -O - -q https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash", + }, + "script omits command without downloader": { + executable: "/usr/local/bin/sloctl", + expected: "", + }, + "executable failure uses script default": { + availableTools: map[string]bool{ + "curl": true, + }, + expected: "curl -fsSL https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash", + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + config := Config{ + Getenv: func(key string) string { + return tt.env[key] + }, + Lookup: func(name string) (string, error) { + if tt.availableTools[name] { + return "/bin/" + name, nil + } + return "", errors.New("not found") + }, + Executable: func() (string, error) { + if tt.executable == "" { + return "", errors.New("no executable") + } + return tt.executable, nil + }, + EvalSymlinks: func(string) (string, error) { + if tt.resolved == "" { + return "", errors.New("not a symlink") + } + return tt.resolved, nil + }, + } + + assert.Equal(t, tt.expected, newNotifier(config).updateCommand()) + }) + } } func TestNotifier_notifySkipsBeforeFetch(t *testing.T) { @@ -352,8 +449,11 @@ func testConfig(t *testing.T, out *bytes.Buffer, now time.Time) Config { Now: func() time.Time { return now }, Getenv: func(string) string { return "" }, IsTTY: func() bool { return true }, + Lookup: func(name string) (string, error) { return "/bin/" + name, nil }, TerminalWidth: func() int { return defaultBoxWidth }, RenderMarkdown: func(markdown string, _ int) (string, error) { return markdown, nil }, + Executable: func() (string, error) { return "/usr/local/bin/sloctl", nil }, + EvalSymlinks: func(path string) (string, error) { return path, nil }, } } diff --git a/test/notifications.bats b/test/notifications.bats index c0c83c65..29697d27 100644 --- a/test/notifications.bats +++ b/test/notifications.bats @@ -100,6 +100,10 @@ refute_stderr() { assert_stderr --partial "Features" assert_stderr --partial "Add notification tests" assert_stderr --partial "https://github.com/nobl9/sloctl/releases/tag/v1.1.0" + assert_stderr --partial "Update" + assert_stderr --partial "sloctl:" + assert_stderr --partial "install.bash" + assert_stderr --partial "bash" run_sloctl_with_stderr_pty version assert_success_joined_output From 85a3c2f9bb3a6e4ba5a5be1c07375c725d05c9a6 Mon Sep 17 00:00:00 2001 From: Mateusz Hawrus Date: Thu, 28 May 2026 11:19:47 +0200 Subject: [PATCH 03/20] feat: improve release notifications for version and fix notes Show notifications for releases without feature entries and include bug-fix sections when extracting release notes. Refresh the plain-text styling and wrap long install commands to fit narrower terminals. --- internal/notifications/notifications.go | 164 +++++++++++----- internal/notifications/notifications_test.go | 186 ++++++++++++++++--- 2 files changed, 278 insertions(+), 72 deletions(-) diff --git a/internal/notifications/notifications.go b/internal/notifications/notifications.go index d45815e6..c91c645b 100644 --- a/internal/notifications/notifications.go +++ b/internal/notifications/notifications.go @@ -28,9 +28,15 @@ const ( checkInterval = 24 * time.Hour checkTimeout = 750 * time.Millisecond maxResponseSize = 1 << 20 - defaultBoxWidth = 88 + defaultBoxWidth = 92 minBoxWidth = 48 boxPadding = 2 + + notificationTeal = "#00819E" + notificationCyan = "#63D6E5" + notificationGray = "#BABBBB" + notificationLightGray = "#E8E9E9" + notificationWhite = "#FFFFFF" ) var ( @@ -212,24 +218,21 @@ func (n notifier) notify(ctx context.Context) { n.saveState(currentState) return } - featuresMarkdown, ok := featuresSection(release.Body) - if !ok { - n.saveState(currentState) - return + releaseNotesMarkdown, _ := releaseNotesSection(release.Body) + var releaseNoteID string + if note, ok := firstReleaseNote(releaseNotesMarkdown); ok { + releaseNoteID = note.ID + } else { + releaseNotesMarkdown = "" } - nextFeature, ok := firstFeature(featuresMarkdown) - if !ok { - n.saveState(currentState) - return - } - if alreadyShown(currentState, release.TagName, nextFeature.ID) { + if alreadyShown(currentState, release.TagName, releaseNoteID) { n.saveState(currentState) return } - _, _ = fmt.Fprintln(n.stderr, n.renderNotification(release, featuresMarkdown)) + _, _ = fmt.Fprintln(n.stderr, n.renderNotification(release, releaseNotesMarkdown)) currentState.LastShownReleaseTag = release.TagName - currentState.LastShownFeatureID = nextFeature.ID + currentState.LastShownFeatureID = releaseNoteID n.saveState(currentState) } @@ -299,30 +302,37 @@ func defaultCachePath() string { return filepath.Join(cacheDir, "nobl9", "sloctl", "notifications.json") } -func (n notifier) renderNotification(release githubRelease, featuresMarkdown string) string { +func (n notifier) renderNotification(release githubRelease, releaseNotesMarkdown string) string { width := notificationWidth(n.terminalWidth()) border := lipgloss.RoundedBorder() contentWidth := width - boxPadding - border.GetLeftSize() - border.GetRightSize() updateCommand := n.updateCommand() + formattedUpdateCommand := formatShellPipeline(updateCommand, contentWidth-boxPadding*2) parts := []string{ - fmt.Sprintf("### New sloctl features in %s", release.TagName), - strings.TrimSpace(featuresMarkdown), - fmt.Sprintf("[View release](%s)", release.HTMLURL), + notificationTitleMarkdown(releaseNotesMarkdown, release.TagName), + } + if releaseNotesMarkdown != "" { + parts = append(parts, strings.TrimSpace(releaseNotesMarkdown)) } + parts = append(parts, fmt.Sprintf("📜 %s", release.HTMLURL)) if updateCommand != "" { - parts = append(parts, fmt.Sprintf("Update sloctl:\n\n```shell\n%s\n```", updateCommand)) + parts = append(parts, fmt.Sprintf("Update sloctl with:\n\n```shell\n%s\n```", formattedUpdateCommand)) } markdown := strings.Join(parts, "\n\n") - rendered, err := n.renderMarkdown(markdown, contentWidth) - if err != nil { - rendered = plainNotification(release, featuresMarkdown, updateCommand) + rendered := styledPlainNotification(release, releaseNotesMarkdown, formattedUpdateCommand) + if releaseNotesMarkdown != "" { + var err error + rendered, err = n.renderMarkdown(markdown, contentWidth) + if err != nil { + rendered = styledPlainNotification(release, releaseNotesMarkdown, formattedUpdateCommand) + } } rendered = strings.TrimSpace(rendered) return lipgloss.NewStyle(). BorderStyle(border). - BorderForeground(lipgloss.Color("#0EB46E")). + BorderForeground(lipgloss.Color(notificationTeal)). Padding(0, 1). Width(contentWidth). Render(rendered) @@ -340,18 +350,76 @@ func renderMarkdownWithGlamour(markdown string, width int) (string, error) { return renderer.Render(markdown) } -func plainNotification(release githubRelease, featuresMarkdown string, updateCommand string) string { +func styledPlainNotification(release githubRelease, releaseNotesMarkdown, updateCommand string) string { + titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(notificationWhite)) + linkStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color(notificationCyan)). + Underline(true) + labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(notificationGray)) + commandStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color(notificationLightGray)). + Italic(true) + parts := []string{ - fmt.Sprintf("New sloctl features in %s", release.TagName), - stripMarkdownHeading(featuresMarkdown), - release.HTMLURL, + titleStyle.Render(notificationTitle(releaseNotesMarkdown, release.TagName)), } + if releaseNotesMarkdown != "" { + parts = append(parts, stripMarkdownHeading(releaseNotesMarkdown)) + } + parts = append(parts, labelStyle.Render("📜")+" "+linkStyle.Render(release.HTMLURL)) if updateCommand != "" { - parts = append(parts, "Update sloctl:\n"+updateCommand) + parts = append( + parts, + labelStyle.Render("Update sloctl with:")+"\n"+commandStyle.Render(updateCommand), + ) } return strings.Join(parts, "\n\n") } +func notificationTitleMarkdown(releaseNotesMarkdown, releaseTag string) string { + if releaseNotesMarkdown == "" { + return notificationTitle(releaseNotesMarkdown, releaseTag) + } + return "### " + notificationTitle(releaseNotesMarkdown, releaseTag) +} + +func notificationTitle(releaseNotesMarkdown, releaseTag string) string { + if releaseNotesMarkdown == "" { + return fmt.Sprintf("New sloctl version %s is available!", releaseTag) + } + return fmt.Sprintf("New sloctl updates in %s!", releaseTag) +} + +func formatShellPipeline(command string, maxLineWidth int) string { + if len(command) <= maxLineWidth { + return command + } + left, right, ok := strings.Cut(command, " | ") + if !ok { + return command + } + pipeline := left + " \\\n| " + right + if maxLineLen(pipeline) <= maxLineWidth { + return pipeline + } + if url, ok := strings.CutPrefix(left, "curl -fsSL "); ok { + curlPipeline := "curl -fsSL \\\n" + url + " | " + right + if maxLineLen(curlPipeline) <= maxLineWidth { + return curlPipeline + } + return "curl -fsSL \\\n " + url + " \\\n | " + right + } + return pipeline +} + +func maxLineLen(text string) int { + maxLen := 0 + for _, line := range strings.Split(text, "\n") { + maxLen = max(maxLen, len(line)) + } + return maxLen +} + func (n notifier) updateCommand() string { switch n.installChannel() { case installChannelHomebrew: @@ -452,34 +520,35 @@ func widthFromColumnsEnv(getenv func(string) string) int { return width } -func featuresSection(body string) (string, bool) { - var lines []string - inFeaturesSection := false +func releaseNotesSection(body string) (string, bool) { + var section []string + inReleaseNotesSection := false for _, line := range strings.Split(body, "\n") { trimmed := strings.TrimSpace(line) if isLevel2Heading(trimmed) { - if inFeaturesSection { - break + if inReleaseNotesSection { + if _, ok := firstReleaseNote(strings.Join(section, "\n")); ok { + break + } } - inFeaturesSection = strings.Contains(trimmed, "Features") + inReleaseNotesSection = isReleaseNotesHeading(trimmed) + section = nil } - if inFeaturesSection { - lines = append(lines, line) + if inReleaseNotesSection { + section = append(section, line) } } - section := strings.TrimSpace(strings.Join(lines, "\n")) - return section, section != "" + markdown := strings.TrimSpace(strings.Join(section, "\n")) + if _, ok := firstReleaseNote(markdown); !ok { + return "", false + } + return markdown, true } -func firstFeature(body string) (feature, bool) { - inFeaturesSection := false +func firstReleaseNote(body string) (feature, bool) { for _, line := range strings.Split(body, "\n") { trimmed := strings.TrimSpace(line) - if isLevel2Heading(trimmed) { - inFeaturesSection = strings.Contains(trimmed, "Features") - continue - } - if !inFeaturesSection || !strings.HasPrefix(trimmed, "- ") { + if !strings.HasPrefix(trimmed, "- ") { continue } return parseFeature(trimmed[2:]), true @@ -502,10 +571,17 @@ func isLevel2Heading(line string) bool { return strings.HasPrefix(line, "## ") || line == "##" } +func isReleaseNotesHeading(line string) bool { + heading := strings.ToLower(line) + return strings.Contains(heading, "features") || strings.Contains(heading, "fixes") +} + func parseFeature(raw string) feature { title := strings.TrimSpace(raw) title = strings.TrimPrefix(title, "feat:") title = strings.TrimPrefix(title, "Feat:") + title = strings.TrimPrefix(title, "fix:") + title = strings.TrimPrefix(title, "Fix:") title = strings.TrimSpace(title) id := title if matches := releasePRPattern.FindStringSubmatch(raw); len(matches) == 2 { diff --git a/internal/notifications/notifications_test.go b/internal/notifications/notifications_test.go index 63842962..3cb69ed4 100644 --- a/internal/notifications/notifications_test.go +++ b/internal/notifications/notifications_test.go @@ -18,7 +18,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestFirstFeature(t *testing.T) { +func TestFirstReleaseNote(t *testing.T) { tests := map[string]struct { body string expected feature @@ -62,25 +62,26 @@ func TestFirstFeature(t *testing.T) { expected: feature{ID: "333", Title: "Add direct upload"}, ok: true, }, - "missing features section": { + "bug fix": { body: `## 🐞 Bug Fixes - Fix output formatting (#125) @octocat `, + expected: feature{ID: "125", Title: "Fix output formatting"}, + ok: true, }, - "empty features section": { + "empty section": { body: `## 🚀 Features ## 🐞 Bug Fixes -- Fix output formatting (#125) @octocat `, }, } for name, tt := range tests { t.Run(name, func(t *testing.T) { - got, ok := firstFeature(tt.body) + got, ok := firstReleaseNote(tt.body) assert.Equal(t, tt.ok, ok) assert.Equal(t, tt.expected, got) @@ -88,8 +89,14 @@ func TestFirstFeature(t *testing.T) { } } -func TestFeaturesSection(t *testing.T) { - body := `# What's Changed +func TestReleaseNotesSection(t *testing.T) { + tests := map[string]struct { + body string + expected string + ok bool + }{ + "features section": { + body: `# What's Changed ## 🚀 Features @@ -103,19 +110,65 @@ func TestFeaturesSection(t *testing.T) { ## 🐞 Bug Fixes - Fix output formatting (#125) @octocat -` - - got, ok := featuresSection(body) - - require.True(t, ok) - assert.Equal(t, `## 🚀 Features +`, + expected: `## 🚀 Features - Add workflow insights (#123) @octocat > Extra release-note detail. ### Details -- Preserves nested feature details.`, got) +- Preserves nested feature details.`, + ok: true, + }, + "bug fixes section": { + body: `# What's Changed + +## 🧰 Maintenance + +- chore: Update dependencies (#124) @renovate + +## 🐞 Bug Fixes + +- Fix output formatting (#125) @octocat +`, + expected: `## 🐞 Bug Fixes + +- Fix output formatting (#125) @octocat`, + ok: true, + }, + "only maintenance": { + body: `# What's Changed + +## 🧰 Maintenance + +- chore: Update dependencies (#124) @renovate +`, + }, + "empty features then bug fixes": { + body: `# What's Changed + +## 🚀 Features + +## 🐞 Bug Fixes + +- Fix output formatting (#125) @octocat +`, + expected: `## 🐞 Bug Fixes + +- Fix output formatting (#125) @octocat`, + ok: true, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + got, ok := releaseNotesSection(tt.body) + + assert.Equal(t, tt.ok, ok) + assert.Equal(t, tt.expected, got) + }) + } } func TestNotifier_notifyDisplaysFeatureAndCaches(t *testing.T) { @@ -149,22 +202,28 @@ func TestNotifier_notifyDisplaysFeatureAndCaches(t *testing.T) { newNotifier(config).notify(context.Background()) assert.Equal(t, 1, requests) - assert.Equal(t, 82, renderWidth) + assert.Equal(t, 86, renderWidth) assert.Equal(t, strings.Join([]string{ - "### New sloctl features in v1.2.0", + "### New sloctl updates in v1.2.0!", "## 🚀 Features\n\n- feat: Add workflow insights (#123) @octocat", - "[View release](https://github.com/nobl9/sloctl/releases/tag/v1.2.0)", - "Update sloctl:\n\n```shell\ncurl -fsSL https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash\n```", + "📜 https://github.com/nobl9/sloctl/releases/tag/v1.2.0", + strings.Join([]string{ + "Update sloctl with:", + "", + "```shell", + "curl -fsSL https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash", + "```", + }, "\n"), }, "\n\n"), renderedMarkdown) plainOutput := stripANSI(out.String()) assert.Contains(t, plainOutput, "╭") - assert.Contains(t, plainOutput, "New sloctl features in v1.2.0") + assert.Contains(t, plainOutput, "New sloctl updates in v1.2.0!") assert.Contains(t, plainOutput, "## 🚀 Features") assert.Contains(t, plainOutput, "- feat: Add workflow insights (#123) @octocat") - assert.Contains(t, plainOutput, "[View release](https://github.com/nobl9/sloctl/releases/tag/v1.2.0)") + assert.Contains(t, plainOutput, "📜 https://github.com/nobl9/sloctl/releases/tag/v1.2.0") + assert.NotContains(t, plainOutput, "Release notes:") assert.Contains(t, plainOutput, "Update sloctl") - assert.Contains(t, plainOutput, "curl -fsSL https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash |") - assert.Contains(t, plainOutput, "bash") + assert.Contains(t, plainOutput, "curl -fsSL https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash") out.Reset() newNotifier(config).notify(context.Background()) @@ -195,12 +254,83 @@ func TestNotifier_notifyFallsBackWhenMarkdownRenderingFails(t *testing.T) { newNotifier(config).notify(context.Background()) plainOutput := stripANSI(out.String()) - assert.Contains(t, plainOutput, "New sloctl features in v1.2.0") + assert.Contains(t, plainOutput, "New sloctl updates in v1.2.0!") assert.Contains(t, plainOutput, "- Add workflow insights (#123) @octocat") - assert.Contains(t, plainOutput, "https://github.com/nobl9/sloctl/releases/tag/v1.2.0") - assert.Contains(t, plainOutput, "Update sloctl:") - assert.Contains(t, plainOutput, "curl -fsSL https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash |") - assert.Contains(t, plainOutput, "bash") + assert.Contains(t, plainOutput, "📜 https://github.com/nobl9/sloctl/releases/tag/v1.2.0") + assert.NotContains(t, plainOutput, "Release notes:") + assert.Contains(t, plainOutput, "Update sloctl with:") + assert.Contains(t, plainOutput, "curl -fsSL https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash") +} + +func TestNotifier_notifyDisplaysVersionWhenReleaseHasNoFeatures(t *testing.T) { + client := testHTTPClient(func(*http.Request) (int, string) { + return http.StatusOK, releaseJSON(t, githubRelease{ + TagName: "v1.2.0", + Body: `# What's Changed + +## 🧰 Maintenance + +- chore: Update release-drafter/release-drafter action to v7.3.0 (#465) @renovate +`, + HTMLURL: "https://github.com/nobl9/sloctl/releases/tag/v1.2.0", + }) + }) + + var out bytes.Buffer + config := testConfig(t, &out, time.Now()) + config.HTTPClient = client + config.TerminalWidth = func() int { return 80 } + config.RenderMarkdown = func(string, int) (string, error) { + t.Fatal("version-only notification should not use markdown rendering") + return "", nil + } + + newNotifier(config).notify(context.Background()) + + plainOutput := stripANSI(out.String()) + assert.Contains(t, plainOutput, "New sloctl version v1.2.0 is available!") + assert.Contains(t, out.String(), "\x1b[38;2;255;255;255mNew sloctl version v1.2.0 is available!\x1b[m") + assert.Contains(t, out.String(), "\x1b[4;38;2;99;214;229;4mh") + assert.NotContains(t, plainOutput, "###") + assert.Contains(t, plainOutput, "📜 https://github.com/nobl9/sloctl/releases/tag/v1.2.0") + assert.NotContains(t, plainOutput, "Release notes:") + assert.NotContains(t, plainOutput, "View release") + assert.Contains(t, plainOutput, "Update sloctl") + assert.Contains(t, out.String(), "\x1b[3;") + assert.Contains(t, plainOutput, "curl -fsSL \\") + assert.Contains(t, plainOutput, "https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash \\") + assert.Contains(t, plainOutput, "| bash") + assert.NotContains(t, plainOutput, "Maintenance") + + currentState := readState(t, config.CachePath) + assert.Equal(t, "v1.2.0", currentState.LastShownReleaseTag) + assert.Empty(t, currentState.LastShownFeatureID) +} + +func TestNotifier_notifyKeepsInstallCommandOnOneLineWhenTerminalIsWide(t *testing.T) { + client := testHTTPClient(func(*http.Request) (int, string) { + return http.StatusOK, releaseJSON(t, githubRelease{ + TagName: "v1.2.0", + Body: `# What's Changed + +## 🧰 Maintenance + +- chore: Update dependencies (#124) @renovate +`, + HTMLURL: "https://github.com/nobl9/sloctl/releases/tag/v1.2.0", + }) + }) + + var out bytes.Buffer + config := testConfig(t, &out, time.Now()) + config.HTTPClient = client + config.TerminalWidth = func() int { return 120 } + + newNotifier(config).notify(context.Background()) + + plainOutput := stripANSI(out.String()) + assert.Contains(t, plainOutput, "curl -fsSL https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash") + assert.NotContains(t, plainOutput, "curl -fsSL \\") } func TestNotifier_updateCommand(t *testing.T) { @@ -435,7 +565,7 @@ func TestNotifier_notifyDoesNotSurfaceCacheWriteFailure(t *testing.T) { newNotifier(config).notify(context.Background()) - assert.Contains(t, stripANSI(out.String()), "New sloctl features in v1.2.0") + assert.Contains(t, stripANSI(out.String()), "New sloctl updates in v1.2.0!") assert.Contains(t, stripANSI(out.String()), "Add workflow insights") } From 3c39ab30a5d758708cd41f2060529e51ef452ce4 Mon Sep 17 00:00:00 2001 From: Mateusz Hawrus Date: Mon, 1 Jun 2026 22:10:24 +0200 Subject: [PATCH 04/20] chore: polish up --- docs/DEVELOPMENT.md | 24 +- internal/execute_test.go | 43 -- internal/huhform/form.go | 60 +- internal/notifications/notifications.go | 29 +- internal/notifications/notifications_test.go | 529 +----------------- internal/style/theme.go | 122 ++++ .../empty-features-then-bug-fixes.md | 7 + .../release-bodies/feature-without-author.md | 5 + .../notifications/release-bodies/feature.md | 5 + .../release-bodies/features-with-details.md | 14 + .../release-bodies/maintenance.md | 5 + test/inputs/notifications/release_proxy.py | 66 ++- .../notifications/run_with_stderr_pty.py | 10 + test/notifications.bats | 315 +++++++++-- .../notifications/bug-fix-notification.stderr | 14 + .../notifications/failed-command.stderr | 1 + .../notifications/feature-notification.stderr | 14 + .../feature-without-author.stderr | 14 + .../features-with-details.stderr | 20 + .../notifications/install-curl-wide.stderr | 8 + test/outputs/notifications/install-go.stderr | 8 + .../notifications/install-homebrew.stderr | 8 + .../outputs/notifications/install-wget.stderr | 8 + .../notifications/no-install-command.stderr | 5 + .../notifications/version-notification.stderr | 8 + 25 files changed, 634 insertions(+), 708 deletions(-) delete mode 100644 internal/execute_test.go create mode 100644 internal/style/theme.go create mode 100644 test/inputs/notifications/release-bodies/empty-features-then-bug-fixes.md create mode 100644 test/inputs/notifications/release-bodies/feature-without-author.md create mode 100644 test/inputs/notifications/release-bodies/feature.md create mode 100644 test/inputs/notifications/release-bodies/features-with-details.md create mode 100644 test/inputs/notifications/release-bodies/maintenance.md create mode 100644 test/outputs/notifications/bug-fix-notification.stderr create mode 100644 test/outputs/notifications/failed-command.stderr create mode 100755 test/outputs/notifications/feature-notification.stderr create mode 100644 test/outputs/notifications/feature-without-author.stderr create mode 100644 test/outputs/notifications/features-with-details.stderr create mode 100644 test/outputs/notifications/install-curl-wide.stderr create mode 100644 test/outputs/notifications/install-go.stderr create mode 100644 test/outputs/notifications/install-homebrew.stderr create mode 100644 test/outputs/notifications/install-wget.stderr create mode 100644 test/outputs/notifications/no-install-command.stderr create mode 100644 test/outputs/notifications/version-notification.stderr diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 0967610f..12028de7 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -81,6 +81,28 @@ make test/e2e Bats tests are fully containerized, refer to Makefile for more details on how they're executed. +### 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. + ### End-to-end tests When creating new e2e tests make sure you adhere to the existing patterns @@ -88,8 +110,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/internal/execute_test.go b/internal/execute_test.go deleted file mode 100644 index 0bd1b5b9..00000000 --- a/internal/execute_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package internal - -import ( - "errors" - "testing" - - "github.com/spf13/cobra" - "github.com/stretchr/testify/assert" -) - -func TestExecuteRunsNotifierAfterSuccessfulCommand(t *testing.T) { - events := make([]string, 0, 2) - cmd := &cobra.Command{ - Use: "test", - Run: func(*cobra.Command, []string) { - events = append(events, "command") - }, - } - - exitCode := execute(cmd, func() { - events = append(events, "notification") - }) - - assert.Zero(t, exitCode) - assert.Equal(t, []string{"command", "notification"}, events) -} - -func TestExecuteSkipsNotifierAfterFailedCommand(t *testing.T) { - notificationCalled := false - cmd := &cobra.Command{ - Use: "test", - RunE: func(*cobra.Command, []string) error { - return errors.New("command failed") - }, - } - - exitCode := execute(cmd, func() { - notificationCalled = true - }) - - assert.Equal(t, 1, exitCode) - assert.False(t, notificationCalled) -} diff --git a/internal/huhform/form.go b/internal/huhform/form.go index 907c9bdb..7af16128 100644 --- a/internal/huhform/form.go +++ b/internal/huhform/form.go @@ -6,7 +6,7 @@ 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. @@ -15,7 +15,7 @@ const accessibleModeEnv = "SLOCTL_ACCESSIBLE_MODE" func New(groups ...*huh.Group) *huh.Form { return huh.NewForm(groups...). - WithTheme(huh.ThemeFunc(themeNobl9)). + WithTheme(huh.ThemeFunc(style.HuhTheme)). WithAccessible(getAccessibleEnvValue()) } @@ -30,59 +30,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/notifications.go b/internal/notifications/notifications.go index c91c645b..91133c6a 100644 --- a/internal/notifications/notifications.go +++ b/internal/notifications/notifications.go @@ -15,9 +15,9 @@ import ( "strings" "time" - "charm.land/lipgloss/v2" "github.com/charmbracelet/glamour" "github.com/mattn/go-isatty" + "github.com/nobl9/sloctl/internal/style" "golang.org/x/term" ) @@ -31,12 +31,6 @@ const ( defaultBoxWidth = 92 minBoxWidth = 48 boxPadding = 2 - - notificationTeal = "#00819E" - notificationCyan = "#63D6E5" - notificationGray = "#BABBBB" - notificationLightGray = "#E8E9E9" - notificationWhite = "#FFFFFF" ) var ( @@ -304,7 +298,7 @@ func defaultCachePath() string { func (n notifier) renderNotification(release githubRelease, releaseNotesMarkdown string) string { width := notificationWidth(n.terminalWidth()) - border := lipgloss.RoundedBorder() + border := style.NotificationBorder() contentWidth := width - boxPadding - border.GetLeftSize() - border.GetRightSize() updateCommand := n.updateCommand() formattedUpdateCommand := formatShellPipeline(updateCommand, contentWidth-boxPadding*2) @@ -330,12 +324,7 @@ func (n notifier) renderNotification(release githubRelease, releaseNotesMarkdown } rendered = strings.TrimSpace(rendered) - return lipgloss.NewStyle(). - BorderStyle(border). - BorderForeground(lipgloss.Color(notificationTeal)). - Padding(0, 1). - Width(contentWidth). - Render(rendered) + return style.NotificationBox(contentWidth).Render(rendered) } func renderMarkdownWithGlamour(markdown string, width int) (string, error) { @@ -351,14 +340,10 @@ func renderMarkdownWithGlamour(markdown string, width int) (string, error) { } func styledPlainNotification(release githubRelease, releaseNotesMarkdown, updateCommand string) string { - titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(notificationWhite)) - linkStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color(notificationCyan)). - Underline(true) - labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(notificationGray)) - commandStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color(notificationLightGray)). - Italic(true) + titleStyle := style.NotificationTitle() + linkStyle := style.NotificationLink() + labelStyle := style.NotificationLabel() + commandStyle := style.NotificationCommand() parts := []string{ titleStyle.Render(notificationTitle(releaseNotesMarkdown, release.TagName)), diff --git a/internal/notifications/notifications_test.go b/internal/notifications/notifications_test.go index 3cb69ed4..d2760f04 100644 --- a/internal/notifications/notifications_test.go +++ b/internal/notifications/notifications_test.go @@ -7,7 +7,6 @@ import ( "errors" "io" "net/http" - "os" "path/filepath" "regexp" "strings" @@ -18,227 +17,13 @@ import ( "github.com/stretchr/testify/require" ) -func TestFirstReleaseNote(t *testing.T) { - tests := map[string]struct { - body string - expected feature - ok bool - }{ - "release drafter features section": { - body: `# What's Changed - -## 🚀 Features - -- feat: Add workflow insights (#123) @octocat -- Improve import behavior (#124) @hubot - -## 🐞 Bug Fixes - -- Fix output formatting (#125) @octocat -`, - expected: feature{ID: "123", Title: "Add workflow insights"}, - ok: true, - }, - "feature with release notes blockquote": { - body: `# What's Changed - -## 🚀 Features - -- Add replay controls (#222) @octocat - > Release note text is not part of the notification title. - -## 🧰 Maintenance - -- Update dependencies (#223) @renovate -`, - expected: feature{ID: "222", Title: "Add replay controls"}, - ok: true, - }, - "feature without author": { - body: `## Features - -- Add direct upload (#333) -`, - expected: feature{ID: "333", Title: "Add direct upload"}, - ok: true, - }, - "bug fix": { - body: `## 🐞 Bug Fixes - -- Fix output formatting (#125) @octocat -`, - expected: feature{ID: "125", Title: "Fix output formatting"}, - ok: true, - }, - "empty section": { - body: `## 🚀 Features - -## 🐞 Bug Fixes - -`, - }, - } - - for name, tt := range tests { - t.Run(name, func(t *testing.T) { - got, ok := firstReleaseNote(tt.body) - - assert.Equal(t, tt.ok, ok) - assert.Equal(t, tt.expected, got) - }) - } -} - -func TestReleaseNotesSection(t *testing.T) { - tests := map[string]struct { - body string - expected string - ok bool - }{ - "features section": { - body: `# 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 -`, - expected: `## 🚀 Features - -- Add workflow insights (#123) @octocat - > Extra release-note detail. - -### Details - -- Preserves nested feature details.`, - ok: true, - }, - "bug fixes section": { - body: `# What's Changed - -## 🧰 Maintenance - -- chore: Update dependencies (#124) @renovate - -## 🐞 Bug Fixes - -- Fix output formatting (#125) @octocat -`, - expected: `## 🐞 Bug Fixes - -- Fix output formatting (#125) @octocat`, - ok: true, - }, - "only maintenance": { - body: `# What's Changed - -## 🧰 Maintenance - -- chore: Update dependencies (#124) @renovate -`, - }, - "empty features then bug fixes": { - body: `# What's Changed - -## 🚀 Features - -## 🐞 Bug Fixes - -- Fix output formatting (#125) @octocat -`, - expected: `## 🐞 Bug Fixes - -- Fix output formatting (#125) @octocat`, - ok: true, - }, - } - - for name, tt := range tests { - t.Run(name, func(t *testing.T) { - got, ok := releaseNotesSection(tt.body) - - assert.Equal(t, tt.ok, ok) - assert.Equal(t, tt.expected, got) - }) - } -} - -func TestNotifier_notifyDisplaysFeatureAndCaches(t *testing.T) { - now := time.Date(2026, 4, 28, 12, 0, 0, 0, time.UTC) - requests := 0 - client := testHTTPClient(func(r *http.Request) (int, string) { - requests++ - assert.Equal(t, "application/vnd.github+json", r.Header.Get("Accept")) - assert.Equal(t, "sloctl", r.Header.Get("User-Agent")) - return http.StatusOK, releaseJSON(t, githubRelease{ - TagName: "v1.2.0", - Body: `## 🚀 Features - -- feat: Add workflow insights (#123) @octocat -`, - HTMLURL: "https://github.com/nobl9/sloctl/releases/tag/v1.2.0", - }) - }) - - var out bytes.Buffer - config := testConfig(t, &out, now) - config.HTTPClient = client - var renderedMarkdown string - var renderWidth int - config.RenderMarkdown = func(markdown string, width int) (string, error) { - renderedMarkdown = markdown - renderWidth = width - return markdown, nil - } - - newNotifier(config).notify(context.Background()) - - assert.Equal(t, 1, requests) - assert.Equal(t, 86, renderWidth) - assert.Equal(t, strings.Join([]string{ - "### New sloctl updates in v1.2.0!", - "## 🚀 Features\n\n- feat: Add workflow insights (#123) @octocat", - "📜 https://github.com/nobl9/sloctl/releases/tag/v1.2.0", - strings.Join([]string{ - "Update sloctl with:", - "", - "```shell", - "curl -fsSL https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash", - "```", - }, "\n"), - }, "\n\n"), renderedMarkdown) - plainOutput := stripANSI(out.String()) - assert.Contains(t, plainOutput, "╭") - assert.Contains(t, plainOutput, "New sloctl updates in v1.2.0!") - assert.Contains(t, plainOutput, "## 🚀 Features") - assert.Contains(t, plainOutput, "- feat: Add workflow insights (#123) @octocat") - assert.Contains(t, plainOutput, "📜 https://github.com/nobl9/sloctl/releases/tag/v1.2.0") - assert.NotContains(t, plainOutput, "Release notes:") - assert.Contains(t, plainOutput, "Update sloctl") - assert.Contains(t, plainOutput, "curl -fsSL https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash") - - out.Reset() - newNotifier(config).notify(context.Background()) - - assert.Equal(t, 1, requests) - assert.Empty(t, out.String()) -} - func TestNotifier_notifyFallsBackWhenMarkdownRenderingFails(t *testing.T) { client := testHTTPClient(func(*http.Request) (int, string) { return http.StatusOK, releaseJSON(t, githubRelease{ TagName: "v1.2.0", Body: `## 🚀 Features -- Add workflow insights (#123) @octocat +- Add workflow insights (#123) @bot `, HTMLURL: "https://github.com/nobl9/sloctl/releases/tag/v1.2.0", }) @@ -255,320 +40,29 @@ func TestNotifier_notifyFallsBackWhenMarkdownRenderingFails(t *testing.T) { plainOutput := stripANSI(out.String()) assert.Contains(t, plainOutput, "New sloctl updates in v1.2.0!") - assert.Contains(t, plainOutput, "- Add workflow insights (#123) @octocat") + assert.Contains(t, plainOutput, "- Add workflow insights (#123) @bot") assert.Contains(t, plainOutput, "📜 https://github.com/nobl9/sloctl/releases/tag/v1.2.0") assert.NotContains(t, plainOutput, "Release notes:") assert.Contains(t, plainOutput, "Update sloctl with:") assert.Contains(t, plainOutput, "curl -fsSL https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash") } -func TestNotifier_notifyDisplaysVersionWhenReleaseHasNoFeatures(t *testing.T) { - client := testHTTPClient(func(*http.Request) (int, string) { - return http.StatusOK, releaseJSON(t, githubRelease{ - TagName: "v1.2.0", - Body: `# What's Changed - -## 🧰 Maintenance - -- chore: Update release-drafter/release-drafter action to v7.3.0 (#465) @renovate -`, - HTMLURL: "https://github.com/nobl9/sloctl/releases/tag/v1.2.0", - }) - }) - +func TestNotifier_notifySkipsDevelopmentVersion(t *testing.T) { var out bytes.Buffer - config := testConfig(t, &out, time.Now()) - config.HTTPClient = client - config.TerminalWidth = func() int { return 80 } - config.RenderMarkdown = func(string, int) (string, error) { - t.Fatal("version-only notification should not use markdown rendering") - return "", nil - } - - newNotifier(config).notify(context.Background()) - - plainOutput := stripANSI(out.String()) - assert.Contains(t, plainOutput, "New sloctl version v1.2.0 is available!") - assert.Contains(t, out.String(), "\x1b[38;2;255;255;255mNew sloctl version v1.2.0 is available!\x1b[m") - assert.Contains(t, out.String(), "\x1b[4;38;2;99;214;229;4mh") - assert.NotContains(t, plainOutput, "###") - assert.Contains(t, plainOutput, "📜 https://github.com/nobl9/sloctl/releases/tag/v1.2.0") - assert.NotContains(t, plainOutput, "Release notes:") - assert.NotContains(t, plainOutput, "View release") - assert.Contains(t, plainOutput, "Update sloctl") - assert.Contains(t, out.String(), "\x1b[3;") - assert.Contains(t, plainOutput, "curl -fsSL \\") - assert.Contains(t, plainOutput, "https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash \\") - assert.Contains(t, plainOutput, "| bash") - assert.NotContains(t, plainOutput, "Maintenance") - - currentState := readState(t, config.CachePath) - assert.Equal(t, "v1.2.0", currentState.LastShownReleaseTag) - assert.Empty(t, currentState.LastShownFeatureID) -} - -func TestNotifier_notifyKeepsInstallCommandOnOneLineWhenTerminalIsWide(t *testing.T) { - client := testHTTPClient(func(*http.Request) (int, string) { - return http.StatusOK, releaseJSON(t, githubRelease{ - TagName: "v1.2.0", - Body: `# What's Changed - -## 🧰 Maintenance - -- chore: Update dependencies (#124) @renovate -`, - HTMLURL: "https://github.com/nobl9/sloctl/releases/tag/v1.2.0", - }) - }) - - var out bytes.Buffer - config := testConfig(t, &out, time.Now()) - config.HTTPClient = client - config.TerminalWidth = func() int { return 120 } - - newNotifier(config).notify(context.Background()) - - plainOutput := stripANSI(out.String()) - assert.Contains(t, plainOutput, "curl -fsSL https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash") - assert.NotContains(t, plainOutput, "curl -fsSL \\") -} - -func TestNotifier_updateCommand(t *testing.T) { - tests := map[string]struct { - executable string - resolved string - env map[string]string - availableTools map[string]bool - expected string - }{ - "homebrew cellar": { - executable: "/opt/homebrew/bin/sloctl", - resolved: "/opt/homebrew/Cellar/sloctl/1.2.0/bin/sloctl", - expected: "brew upgrade sloctl", - }, - "go bin from GOBIN": { - executable: "/home/me/bin/sloctl", - env: map[string]string{ - "GOBIN": "/home/me/bin", - }, - expected: "go install github.com/nobl9/sloctl/cmd/sloctl@latest", - }, - "go bin from GOPATH": { - executable: "/home/me/go-work/bin/sloctl", - env: map[string]string{ - "GOPATH": "/home/me/go-work", - }, - expected: "go install github.com/nobl9/sloctl/cmd/sloctl@latest", - }, - "go bin from default GOPATH": { - executable: "/home/me/go/bin/sloctl", - env: map[string]string{ - "HOME": "/home/me", - }, - expected: "go install github.com/nobl9/sloctl/cmd/sloctl@latest", - }, - "script prefers curl": { - executable: "/usr/local/bin/sloctl", - availableTools: map[string]bool{ - "curl": true, - "wget": true, - }, - expected: "curl -fsSL https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash", - }, - "script falls back to wget": { - executable: "/usr/local/bin/sloctl", - availableTools: map[string]bool{ - "wget": true, - }, - expected: "wget -O - -q https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash", - }, - "script omits command without downloader": { - executable: "/usr/local/bin/sloctl", - expected: "", - }, - "executable failure uses script default": { - availableTools: map[string]bool{ - "curl": true, - }, - expected: "curl -fsSL https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash", - }, - } - - for name, tt := range tests { - t.Run(name, func(t *testing.T) { - config := Config{ - Getenv: func(key string) string { - return tt.env[key] - }, - Lookup: func(name string) (string, error) { - if tt.availableTools[name] { - return "/bin/" + name, nil - } - return "", errors.New("not found") - }, - Executable: func() (string, error) { - if tt.executable == "" { - return "", errors.New("no executable") - } - return tt.executable, nil - }, - EvalSymlinks: func(string) (string, error) { - if tt.resolved == "" { - return "", errors.New("not a symlink") - } - return tt.resolved, nil - }, - } - - assert.Equal(t, tt.expected, newNotifier(config).updateCommand()) - }) - } -} - -func TestNotifier_notifySkipsBeforeFetch(t *testing.T) { - tests := map[string]struct { - configure func(Config) Config - }{ - "ci": { - configure: func(config Config) Config { - config.Getenv = func(key string) string { - if key == ciEnv { - return "true" - } - return "" - } - return config - }, - }, - "development version": { - configure: func(config Config) Config { - config.CurrentVersion = "1.0.0-test" - return config - }, - }, - "non tty": { - configure: func(config Config) Config { - config.IsTTY = func() bool { return false } - return config - }, - }, - "opt out": { - configure: func(config Config) Config { - config.Getenv = func(key string) string { - if key == optOutEnv { - return "1" - } - return "" - } - return config - }, - }, - } - - for name, tt := range tests { - t.Run(name, func(t *testing.T) { - var out bytes.Buffer - requests := 0 - config := testConfig(t, &out, time.Now()) - config.HTTPClient = testHTTPClient(func(*http.Request) (int, string) { - requests++ - return http.StatusOK, "{}" - }) - config = tt.configure(config) - - newNotifier(config).notify(context.Background()) - - assert.Zero(t, requests) - assert.Empty(t, out.String()) - }) - } -} - -func TestNotifier_notifySkipsCurrentRelease(t *testing.T) { requests := 0 - client := testHTTPClient(func(*http.Request) (int, string) { - requests++ - return http.StatusOK, releaseJSON(t, githubRelease{ - TagName: "v1.2.0", - Body: `## 🚀 Features - -- Add workflow insights (#123) @octocat -`, - HTMLURL: "https://github.com/nobl9/sloctl/releases/tag/v1.2.0", - }) - }) - - var out bytes.Buffer config := testConfig(t, &out, time.Now()) - config.CurrentVersion = "1.2.0" - config.HTTPClient = client - - newNotifier(config).notify(context.Background()) - - assert.Equal(t, 1, requests) - assert.Empty(t, out.String()) -} - -func TestNotifier_notifySuppressesFetchFailuresAndCachesCheck(t *testing.T) { - now := time.Date(2026, 4, 28, 12, 0, 0, 0, time.UTC) - requests := 0 - client := testHTTPClient(func(*http.Request) (int, string) { + config.CurrentVersion = "1.0.0-test" + config.HTTPClient = testHTTPClient(func(*http.Request) (int, string) { requests++ - return http.StatusForbidden, "rate limited" + return http.StatusOK, "{}" }) - var out bytes.Buffer - config := testConfig(t, &out, now) - config.HTTPClient = client - - newNotifier(config).notify(context.Background()) - config.Now = func() time.Time { return now.Add(time.Hour) } - newNotifier(config).notify(context.Background()) - - assert.Equal(t, 1, requests) - assert.Empty(t, out.String()) - - currentState := readState(t, config.CachePath) - assert.Equal(t, now, currentState.LastCheckedAt) -} - -func TestNotifier_notifySuppressesMalformedRelease(t *testing.T) { - client := testHTTPClient(func(*http.Request) (int, string) { - return http.StatusOK, "{" - }) - - var out bytes.Buffer - config := testConfig(t, &out, time.Now()) - config.HTTPClient = client - newNotifier(config).notify(context.Background()) + assert.Zero(t, requests) assert.Empty(t, out.String()) } -func TestNotifier_notifyDoesNotSurfaceCacheWriteFailure(t *testing.T) { - client := testHTTPClient(func(*http.Request) (int, string) { - return http.StatusOK, releaseJSON(t, githubRelease{ - TagName: "v1.2.0", - Body: `## 🚀 Features - -- Add workflow insights (#123) @octocat -`, - HTMLURL: "https://github.com/nobl9/sloctl/releases/tag/v1.2.0", - }) - }) - - var out bytes.Buffer - config := testConfig(t, &out, time.Now()) - config.HTTPClient = client - config.CachePath = t.TempDir() - - newNotifier(config).notify(context.Background()) - - assert.Contains(t, stripANSI(out.String()), "New sloctl updates in v1.2.0!") - assert.Contains(t, stripANSI(out.String()), "Add workflow insights") -} - func testConfig(t *testing.T, out *bytes.Buffer, now time.Time) Config { t.Helper() return Config{ @@ -594,15 +88,6 @@ func releaseJSON(t *testing.T, release githubRelease) string { return string(data) } -func readState(t *testing.T, path string) state { - t.Helper() - data, err := os.ReadFile(path) - require.NoError(t, err) - var currentState state - require.NoError(t, json.Unmarshal(data, ¤tState)) - return currentState -} - type roundTripFunc func(*http.Request) (*http.Response, error) func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { diff --git a/internal/style/theme.go b/internal/style/theme.go new file mode 100644 index 00000000..97ae793c --- /dev/null +++ b/internal/style/theme.go @@ -0,0 +1,122 @@ +// Package style defines shared sloctl terminal styles. +package style + +import ( + huh "charm.land/huh/v2" + "charm.land/lipgloss/v2" +) + +const ( + tealHex = "#00819E" + cyanHex = "#63D6E5" + mutedHex = "#BABBBB" + lightGrayHex = "#E8E9E9" + darkGrayHex = "#383939" + whiteHex = "#FFFFFF" + greenHex = "#0EB46E" + yellowHex = "#C8D655" + pinkHex = "#DB2779" + redHex = "#D42E56" +) + +var ( + // Teal is the primary sloctl terminal accent color. + Teal = lipgloss.Color(tealHex) + // Cyan is the secondary sloctl terminal accent color. + Cyan = lipgloss.Color(cyanHex) + // Muted is used for secondary terminal text. + Muted = lipgloss.Color(mutedHex) + // LightGray is used for emphasized neutral terminal text. + LightGray = lipgloss.Color(lightGrayHex) + // DarkGray is used for low-emphasis backgrounds. + DarkGray = lipgloss.Color(darkGrayHex) + // White is used for primary terminal text. + White = lipgloss.Color(whiteHex) + // Green is used for successful or selected terminal states. + Green = lipgloss.Color(greenHex) + // Yellow is used for active terminal indicators. + Yellow = lipgloss.Color(yellowHex) + // Pink is used for focused terminal actions. + Pink = lipgloss.Color(pinkHex) + // Red is used for terminal error states. + Red = lipgloss.Color(redHex) +) + +// HuhTheme returns the shared Nobl9 terminal theme for interactive forms. +func HuhTheme(isDark bool) *huh.Styles { + t := huh.ThemeBase(isDark) + + t.Focused.Base = t.Focused.Base.BorderForeground(Teal) + t.Focused.Card = t.Focused.Base + t.Focused.Title = t.Focused.Title.Foreground(Cyan) + t.Focused.NoteTitle = t.Focused.NoteTitle.Foreground(Cyan) + t.Focused.Directory = t.Focused.Directory.Foreground(Cyan) + t.Focused.Description = t.Focused.Description.Foreground(Muted) + 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(DarkGray) + + t.Focused.TextInput.Cursor.Foreground(Pink) + t.Focused.TextInput.Placeholder.Foreground(Muted) + 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(Muted) + t.Blurred.Title = t.Blurred.NoteTitle.Foreground(Muted) + + t.Blurred.TextInput.Prompt = t.Blurred.TextInput.Prompt.Foreground(Muted) + 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 +} + +// NotificationBorder returns the shared border for notification containers. +func NotificationBorder() lipgloss.Border { + return lipgloss.RoundedBorder() +} + +// NotificationBox returns the shared style for notification containers. +func NotificationBox(contentWidth int) lipgloss.Style { + return lipgloss.NewStyle(). + BorderStyle(NotificationBorder()). + BorderForeground(Teal). + Padding(0, 1). + Width(contentWidth) +} + +// NotificationTitle returns the shared style for notification titles. +func NotificationTitle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(White) +} + +// NotificationLink returns the shared style for notification links. +func NotificationLink() lipgloss.Style { + return lipgloss.NewStyle().Foreground(Cyan).Underline(true) +} + +// NotificationLabel returns the shared style for notification labels. +func NotificationLabel() lipgloss.Style { + return lipgloss.NewStyle().Foreground(Muted) +} + +// NotificationCommand returns the shared style for notification commands. +func NotificationCommand() lipgloss.Style { + return lipgloss.NewStyle().Foreground(LightGray).Italic(true) +} 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/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_proxy.py b/test/inputs/notifications/release_proxy.py index 851ce545..300683c3 100644 --- a/test/inputs/notifications/release_proxy.py +++ b/test/inputs/notifications/release_proxy.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import json +import os import socketserver import ssl import sys @@ -10,6 +11,7 @@ GITHUB_HOST = "api.github.com" GITHUB_PORT = "443" RELEASE_PATH = "/repos/nobl9/sloctl/releases/latest" +DEFAULT_RELEASE_BODY_FILE = Path(__file__).with_name("release-bodies") / "feature.md" class Proxy(socketserver.BaseRequestHandler): @@ -41,20 +43,35 @@ def handle_github_request(self, tls_socket): lines = request.splitlines() method, path, _ = lines[0].split(" ", 2) headers = parse_headers(lines[1:]) - if method != "GET" or path != RELEASE_PATH or headers.get("host") != GITHUB_HOST: + log_request(method, path, headers) + if ( + method != "GET" + or path != RELEASE_PATH + or headers.get("host") != GITHUB_HOST + or headers.get("accept") != "application/vnd.github+json" + or headers.get("user-agent") != "sloctl" + ): tls_socket.sendall(b"HTTP/1.1 502 Bad Gateway\r\n\r\n") return - body = json.dumps( - { - "tag_name": "v1.1.0", - "body": "## Features\n\n- feat: Add notification tests (#321) @octocat\n", - "html_url": "https://github.com/nobl9/sloctl/releases/tag/v1.1.0", - } - ).encode() + status = int(os.environ.get("RELEASE_PROXY_STATUS", "200")) + raw_body = os.environ.get("RELEASE_PROXY_RAW_RESPONSE") + if raw_body is None: + raw_body = json.dumps( + { + "tag_name": os.environ.get("RELEASE_PROXY_TAG", "v1.1.0"), + "body": release_body(), + "html_url": os.environ.get( + "RELEASE_PROXY_HTML_URL", + "https://github.com/nobl9/sloctl/releases/tag/v1.1.0", + ), + } + ) + body = raw_body.encode() + reason = "OK" if status == 200 else "Error" response = ( - b"HTTP/1.1 200 OK\r\n" - b"Content-Type: application/json\r\n" + f"HTTP/1.1 {status} {reason}\r\n".encode() + + b"Content-Type: application/json\r\n" + f"Content-Length: {len(body)}\r\n".encode() + b"Connection: close\r\n" + b"\r\n" @@ -77,6 +94,16 @@ def read_headers(sock): return data.decode("iso-8859-1") +def release_body(): + body_file = os.environ.get("RELEASE_PROXY_BODY_FILE") + if body_file: + return Path(body_file).read_text(encoding="utf-8") + body = os.environ.get("RELEASE_PROXY_BODY") + if body is not None: + return body + return DEFAULT_RELEASE_BODY_FILE.read_text(encoding="utf-8") + + def parse_headers(lines): headers = {} for line in lines: @@ -87,6 +114,25 @@ def parse_headers(lines): return headers +def log_request(method, path, headers): + log_path = os.environ.get("RELEASE_PROXY_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 main(): port_file = Path(sys.argv[1]) cert_file = sys.argv[2] diff --git a/test/inputs/notifications/run_with_stderr_pty.py b/test/inputs/notifications/run_with_stderr_pty.py index 6d0ef0a4..15300b4a 100644 --- a/test/inputs/notifications/run_with_stderr_pty.py +++ b/test/inputs/notifications/run_with_stderr_pty.py @@ -1,8 +1,11 @@ #!/usr/bin/env python3 import errno +import fcntl import os import pty +import struct +import termios import selectors import subprocess import sys @@ -14,6 +17,13 @@ def main(): return 2 master_fd, slave_fd = pty.openpty() + columns = os.environ.get("SLOCTL_TEST_TTY_COLUMNS") + if columns: + fcntl.ioctl( + slave_fd, + termios.TIOCSWINSZ, + struct.pack("HHHH", 24, int(columns), 0, 0), + ) process = subprocess.Popen( sys.argv[1:], stdin=subprocess.DEVNULL, diff --git a/test/notifications.bats b/test/notifications.bats index 29697d27..bb0879b5 100644 --- a/test/notifications.bats +++ b/test/notifications.bats @@ -3,6 +3,7 @@ setup_file() { export TEST_INPUTS="$BATS_TEST_DIRNAME/inputs/notifications" + export TEST_OUTPUTS="$BATS_TEST_DIRNAME/outputs/notifications" } setup() { @@ -23,7 +24,15 @@ setup() { unset http_proxy unset no_proxy unset SLOCTL_NO_NOTIFICATIONS - export XDG_CACHE_HOME="$BATS_TMPDIR/cache" + unset RELEASE_PROXY_BODY + unset RELEASE_PROXY_BODY_FILE + unset RELEASE_PROXY_HTML_URL + unset RELEASE_PROXY_RAW_RESPONSE + unset RELEASE_PROXY_STATUS + unset RELEASE_PROXY_TAG + unset SLOCTL_TEST_TTY_COLUMNS + export XDG_CACHE_HOME="$BATS_TMPDIR/cache-$BATS_TEST_NUMBER" + export RELEASE_PROXY_LOG="$BATS_TMPDIR/release-proxy-$BATS_TEST_NUMBER.log" } teardown() { @@ -33,11 +42,254 @@ teardown() { fi } +@test "sloctl shows a feature notification on TTY stderr and caches it" { + start_release_proxy + + run_sloctl_with_stderr_pty version + assert_success_joined_output + assert_stderr_file "feature-notification.stderr" + assert_release_proxy_requests 1 + + run_sloctl_with_stderr_pty version + assert_success_joined_output + assert_stderr "" + assert_release_proxy_requests 1 +} + +@test "sloctl shows a notification after successful commands" { + start_release_proxy + + run_sloctl_with_stderr_pty version + assert_success_joined_output + assert_stderr_file "feature-notification.stderr" + assert_release_proxy_requests 1 +} + +@test "sloctl skips notifications after failed commands" { + start_release_proxy + + run_sloctl_with_stderr_pty config rename-context old + assert_failure + assert_stderr_file "failed-command.stderr" + assert_release_proxy_requests 0 +} + +@test "sloctl does not show feature notification when opted out" { + start_release_proxy + export SLOCTL_NO_NOTIFICATIONS=1 + + run_sloctl_with_stderr_pty version + assert_success_joined_output + assert_stderr "" + assert_release_proxy_requests 0 +} + +@test "sloctl does not show feature notification in CI" { + start_release_proxy + export CI=true + + run_sloctl_with_stderr_pty version + assert_success_joined_output + assert_stderr "" + assert_release_proxy_requests 0 +} + +@test "sloctl does not show feature notification without TTY stderr" { + start_release_proxy + + run_sloctl version + assert_success_joined_output + assert_stderr "" + assert_release_proxy_requests 0 +} + +@test "sloctl shows version notification when release has no feature notes" { + export RELEASE_PROXY_BODY_FILE="$TEST_INPUTS/release-bodies/maintenance.md" + start_release_proxy + + run_sloctl_with_stderr_pty version + assert_success_joined_output + assert_stderr_file "version-notification.stderr" + assert_release_proxy_requests 1 +} + +@test "sloctl uses the first non-empty release notes section" { + export RELEASE_PROXY_BODY_FILE="$TEST_INPUTS/release-bodies/empty-features-then-bug-fixes.md" + start_release_proxy + + run_sloctl_with_stderr_pty version + assert_success_joined_output + assert_stderr_file "bug-fix-notification.stderr" + assert_release_proxy_requests 1 +} + +@test "sloctl keeps nested release-note details from the selected section" { + export RELEASE_PROXY_BODY_FILE="$TEST_INPUTS/release-bodies/features-with-details.md" + start_release_proxy + + run_sloctl_with_stderr_pty version + assert_success_joined_output + assert_stderr_file "features-with-details.stderr" + assert_release_proxy_requests 1 +} + +@test "sloctl shows release note without author metadata" { + export RELEASE_PROXY_BODY_FILE="$TEST_INPUTS/release-bodies/feature-without-author.md" + start_release_proxy + + run_sloctl_with_stderr_pty version + assert_success_joined_output + assert_stderr_file "feature-without-author.stderr" + assert_release_proxy_requests 1 +} + +@test "sloctl does not show notification for current release" { + export RELEASE_PROXY_TAG=v1.0.0 + export RELEASE_PROXY_HTML_URL=https://github.com/nobl9/sloctl/releases/tag/v1.0.0 + start_release_proxy + + run_sloctl_with_stderr_pty version + assert_success_joined_output + assert_stderr "" + assert_release_proxy_requests 1 +} + +@test "sloctl suppresses fetch failures and caches the check" { + export RELEASE_PROXY_STATUS=403 + export RELEASE_PROXY_RAW_RESPONSE="rate limited" + start_release_proxy + + run_sloctl_with_stderr_pty version + assert_success_joined_output + assert_stderr "" + + run_sloctl_with_stderr_pty version + assert_success_joined_output + assert_stderr "" + assert_release_proxy_requests 1 +} + +@test "sloctl suppresses malformed release responses" { + export RELEASE_PROXY_RAW_RESPONSE="{" + start_release_proxy + + run_sloctl_with_stderr_pty version + assert_success_joined_output + assert_stderr "" + assert_release_proxy_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_proxy + + run_sloctl_with_stderr_pty version + assert_success_joined_output + assert_stderr_file "feature-notification.stderr" + assert_release_proxy_requests 1 +} + +@test "sloctl keeps install command on one line when terminal is wide" { + export RELEASE_PROXY_BODY_FILE="$TEST_INPUTS/release-bodies/maintenance.md" + export SLOCTL_TEST_TTY_COLUMNS=140 + local tools_dir="$BATS_TEST_TMPDIR/tools" + mkdir -p "$tools_dir" + touch "$tools_dir/curl" + chmod +x "$tools_dir/curl" + start_release_proxy + + run_sloctl_binary_with_path /usr/bin/sloctl "$tools_dir" version + assert_success_joined_output + assert_stderr_file "install-curl-wide.stderr" +} + +@test "sloctl suggests Homebrew upgrade for Homebrew installs" { + export RELEASE_PROXY_BODY_FILE="$TEST_INPUTS/release-bodies/maintenance.md" + 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" + copy_sloctl_binary "$cellar_binary" + mkdir -p "$(dirname "$linked_binary")" + ln -s "$cellar_binary" "$linked_binary" + start_release_proxy + + run_sloctl_binary_with_stderr_pty "$linked_binary" version + assert_success_joined_output + assert_stderr_file "install-homebrew.stderr" +} + +@test "sloctl suggests go install for Go bin installs" { + export RELEASE_PROXY_BODY_FILE="$TEST_INPUTS/release-bodies/maintenance.md" + export HOME="$BATS_TEST_TMPDIR/home" + local go_binary="$HOME/go/bin/sloctl" + copy_sloctl_binary "$go_binary" + start_release_proxy + + run_sloctl_binary_with_stderr_pty "$go_binary" version + assert_success_joined_output + assert_stderr_file "install-go.stderr" +} + +@test "sloctl falls back to wget when curl is unavailable" { + export RELEASE_PROXY_BODY_FILE="$TEST_INPUTS/release-bodies/maintenance.md" + local tools_dir="$BATS_TEST_TMPDIR/tools" + mkdir -p "$tools_dir" + touch "$tools_dir/wget" + chmod +x "$tools_dir/wget" + start_release_proxy + + run_sloctl_binary_with_path /usr/bin/sloctl "$tools_dir" version + assert_success_joined_output + assert_stderr_file "install-wget.stderr" +} + +@test "sloctl omits update command when no downloader is available" { + export RELEASE_PROXY_BODY_FILE="$TEST_INPUTS/release-bodies/maintenance.md" + local tools_dir="$BATS_TEST_TMPDIR/tools" + mkdir -p "$tools_dir" + start_release_proxy + + run_sloctl_binary_with_path /usr/bin/sloctl "$tools_dir" version + assert_success_joined_output + assert_stderr_file "no-install-command.stderr" +} + +assert_stderr_file() { + local file="$1" + assert_stderr - < "$TEST_OUTPUTS/$file" +} + +run_sloctl_with_stderr_pty() { + run_sloctl_binary_with_stderr_pty sloctl "$@" +} + +run_sloctl_binary_with_stderr_pty() { + 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_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" + mkdir -p "$(dirname "$target")" + cp /usr/bin/sloctl "$target" + chmod +x "$target" +} + start_release_proxy() { - local port_file="$BATS_TMPDIR/release-proxy-port" - local cert_config="$BATS_TMPDIR/api-github-openssl.cnf" - local cert_file="$BATS_TMPDIR/api-github.pem" - local key_file="$BATS_TMPDIR/api-github-key.pem" + local port_file="$BATS_TMPDIR/release-proxy-$BATS_TEST_NUMBER.port" + local cert_config="$BATS_TMPDIR/api-github-$BATS_TEST_NUMBER-openssl.cnf" + local cert_file="$BATS_TMPDIR/api-github-$BATS_TEST_NUMBER.pem" + local key_file="$BATS_TMPDIR/api-github-$BATS_TEST_NUMBER-key.pem" cat > "$cert_config" << EOF [req] @@ -79,50 +331,11 @@ EOF fail "release proxy did not start" } -run_sloctl_with_stderr_pty() { - bats_require_minimum_version 1.5.0 - run --separate-stderr python3 "$TEST_INPUTS/run_with_stderr_pty.py" sloctl "$@" -} - -refute_stderr() { - local actual="$stderr" - output="$actual" refute_output "$@" -} - -@test "sloctl shows a feature notification on TTY stderr and caches it" { - start_release_proxy - - run_sloctl_with_stderr_pty version - assert_success_joined_output - assert_stderr --partial "╭" - assert_stderr --partial "New sloctl features in" - assert_stderr --partial "v1.1.0" - assert_stderr --partial "Features" - assert_stderr --partial "Add notification tests" - assert_stderr --partial "https://github.com/nobl9/sloctl/releases/tag/v1.1.0" - assert_stderr --partial "Update" - assert_stderr --partial "sloctl:" - assert_stderr --partial "install.bash" - assert_stderr --partial "bash" - - run_sloctl_with_stderr_pty version - assert_success_joined_output - assert_stderr "" -} - -@test "sloctl does not show feature notification when opted out" { - start_release_proxy - export SLOCTL_NO_NOTIFICATIONS=1 - - run_sloctl_with_stderr_pty version - assert_success_joined_output - assert_stderr "" -} - -@test "sloctl does not show feature notification without TTY stderr" { - start_release_proxy - - run_sloctl version - assert_success_joined_output - refute_stderr --partial "New sloctl feature" +assert_release_proxy_requests() { + local expected="$1" + local actual=0 + if [ -f "$RELEASE_PROXY_LOG" ]; then + actual="$(wc -l < "$RELEASE_PROXY_LOG" | tr -d " ")" + fi + assert_equal "$actual" "$expected" } diff --git a/test/outputs/notifications/bug-fix-notification.stderr b/test/outputs/notifications/bug-fix-notification.stderr new file mode 100644 index 00000000..7c533e0f --- /dev/null +++ b/test/outputs/notifications/bug-fix-notification.stderr @@ -0,0 +1,14 @@ +╭───────────────────────────────────────────────────────────────────────────────────────╮ +│  ### New sloctl updates in v1.1.0!                                                   │ +│                                                                                      │ +│  ## Bug Fixes                                                                        │ +│                                                                                      │ +│  • Fix output formatting (#125) @octocat                                             │ +│                                                                                     │ +│  📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0                              │ +│                                                                                     │ +│  Update sloctl with:                                                                 │ +│                                                                                     │ +│    wget -O - -q https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash |   │ +│  bash                                                                                │ +╰───────────────────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/test/outputs/notifications/failed-command.stderr b/test/outputs/notifications/failed-command.stderr new file mode 100644 index 00000000..0ab10aa1 --- /dev/null +++ b/test/outputs/notifications/failed-command.stderr @@ -0,0 +1 @@ +Error: either provide new and old context names or no arguments at all, received 1 arguments \ No newline at end of file diff --git a/test/outputs/notifications/feature-notification.stderr b/test/outputs/notifications/feature-notification.stderr new file mode 100755 index 00000000..beaf1584 --- /dev/null +++ b/test/outputs/notifications/feature-notification.stderr @@ -0,0 +1,14 @@ +╭───────────────────────────────────────────────────────────────────────────────────────╮ +│  ### New sloctl updates in v1.1.0!                                                   │ +│                                                                                      │ +│  ## Features                                                                         │ +│                                                                                      │ +│  • feat: Add notification tests (#321) @octocat                                      │ +│                                                                                     │ +│  📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0                              │ +│                                                                                     │ +│  Update sloctl with:                                                                 │ +│                                                                                     │ +│    wget -O - -q https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash |   │ +│  bash                                                                                │ +╰───────────────────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/test/outputs/notifications/feature-without-author.stderr b/test/outputs/notifications/feature-without-author.stderr new file mode 100644 index 00000000..16981acd --- /dev/null +++ b/test/outputs/notifications/feature-without-author.stderr @@ -0,0 +1,14 @@ +╭───────────────────────────────────────────────────────────────────────────────────────╮ +│  ### New sloctl updates in v1.1.0!                                                   │ +│                                                                                      │ +│  ## Features                                                                         │ +│                                                                                      │ +│  • Add direct upload (#333)                                                          │ +│                                                                                     │ +│  📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0                              │ +│                                                                                     │ +│  Update sloctl with:                                                                 │ +│                                                                                     │ +│    wget -O - -q https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash |   │ +│  bash                                                                                │ +╰───────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/test/outputs/notifications/features-with-details.stderr b/test/outputs/notifications/features-with-details.stderr new file mode 100644 index 00000000..993b8f5a --- /dev/null +++ b/test/outputs/notifications/features-with-details.stderr @@ -0,0 +1,20 @@ +╭───────────────────────────────────────────────────────────────────────────────────────╮ +│  ### New sloctl updates in v1.1.0!                                                   │ +│                                                                                      │ +│  ## 🚀 Features                                                                      │ +│                                                                                      │ +│  • Add workflow insights (#123) @octocat                                             │ +│  │ Extra release-note detail.                                                        │ +│                                                                                      │ +│                                                                                      │ +│  ### Details                                                                         │ +│                                                                                      │ +│  • Preserves nested feature details.                                                 │ +│                                                                                     │ +│  📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0                              │ +│                                                                                     │ +│  Update sloctl with:                                                                 │ +│                                                                                     │ +│    wget -O - -q https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash |   │ +│  bash                                                                                │ +╰───────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/test/outputs/notifications/install-curl-wide.stderr b/test/outputs/notifications/install-curl-wide.stderr new file mode 100644 index 00000000..370499f9 --- /dev/null +++ b/test/outputs/notifications/install-curl-wide.stderr @@ -0,0 +1,8 @@ +╭──────────────────────────────────────────────────────────────────────────────────────╮ +│ New sloctl version v1.1.0 is available! │ +│ │ +│ 📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 │ +│ │ +│ Update sloctl with: │ +│ curl -fsSL https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash │ +╰──────────────────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/test/outputs/notifications/install-go.stderr b/test/outputs/notifications/install-go.stderr new file mode 100644 index 00000000..05f1b33a --- /dev/null +++ b/test/outputs/notifications/install-go.stderr @@ -0,0 +1,8 @@ +╭──────────────────────────────────────────────────────────────────────────────────────╮ +│ New sloctl version v1.1.0 is available! │ +│ │ +│ 📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 │ +│ │ +│ Update sloctl with: │ +│ go install github.com/nobl9/sloctl/cmd/sloctl@latest │ +╰──────────────────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/test/outputs/notifications/install-homebrew.stderr b/test/outputs/notifications/install-homebrew.stderr new file mode 100644 index 00000000..028ad98b --- /dev/null +++ b/test/outputs/notifications/install-homebrew.stderr @@ -0,0 +1,8 @@ +╭──────────────────────────────────────────────────────────────────────────────────────╮ +│ New sloctl version v1.1.0 is available! │ +│ │ +│ 📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 │ +│ │ +│ Update sloctl with: │ +│ brew upgrade sloctl │ +╰──────────────────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/test/outputs/notifications/install-wget.stderr b/test/outputs/notifications/install-wget.stderr new file mode 100644 index 00000000..07cfda6b --- /dev/null +++ b/test/outputs/notifications/install-wget.stderr @@ -0,0 +1,8 @@ +╭──────────────────────────────────────────────────────────────────────────────────────╮ +│ New sloctl version v1.1.0 is available! │ +│ │ +│ 📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 │ +│ │ +│ Update sloctl with: │ +│ wget -O - -q https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash │ +╰──────────────────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/test/outputs/notifications/no-install-command.stderr b/test/outputs/notifications/no-install-command.stderr new file mode 100644 index 00000000..69e5fa61 --- /dev/null +++ b/test/outputs/notifications/no-install-command.stderr @@ -0,0 +1,5 @@ +╭──────────────────────────────────────────────────────────────────────────────────────╮ +│ New sloctl version v1.1.0 is available! │ +│ │ +│ 📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 │ +╰──────────────────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/test/outputs/notifications/version-notification.stderr b/test/outputs/notifications/version-notification.stderr new file mode 100644 index 00000000..07cfda6b --- /dev/null +++ b/test/outputs/notifications/version-notification.stderr @@ -0,0 +1,8 @@ +╭──────────────────────────────────────────────────────────────────────────────────────╮ +│ New sloctl version v1.1.0 is available! │ +│ │ +│ 📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 │ +│ │ +│ Update sloctl with: │ +│ wget -O - -q https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash │ +╰──────────────────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file From 0e6b507ac1ff6778113971e356762dbac2623a4b Mon Sep 17 00:00:00 2001 From: Mateusz Hawrus Date: Thu, 25 Jun 2026 21:23:15 +0200 Subject: [PATCH 05/20] feat: add interactive update prompts for notifications Run update notifications before command execution and let users upgrade, skip, or defer until the next version. Replace the GitHub proxy tests with deterministic TTY fixtures and a local release fixture server. --- docs/DEVELOPMENT.md | 8 + internal/huhform/form.go | 1 + internal/notifications/notifications.go | 211 +++++++---- internal/notifications/notifications_test.go | 113 ------ internal/root.go | 16 +- internal/style/theme.go | 126 +++++-- .../{release_proxy.py => release_server.py} | 66 ++-- .../notifications/run_with_stderr_pty.py | 12 +- test/notifications.bats | 343 ++++++++++-------- .../notifications/bug-fix-notification.stderr | 14 - .../notifications/bug-fix-prompt-skip.stderr | 12 + .../failed-command-after-skip.stderr | 13 + .../notifications/failed-command.stderr | 1 - .../notifications/feature-notification.stderr | 14 - ...ture-prompt-skip-until-next-version.stderr | 12 + .../notifications/feature-prompt-skip.stderr | 12 + .../feature-without-author-prompt-skip.stderr | 12 + .../feature-without-author.stderr | 14 - .../features-with-details-prompt-skip.stderr | 18 + .../features-with-details.stderr | 20 - .../install-curl-wide-prompt.stderr | 8 + .../notifications/install-curl-wide.stderr | 8 - .../notifications/install-go-prompt.stderr | 8 + test/outputs/notifications/install-go.stderr | 8 - .../install-homebrew-prompt.stderr | 8 + .../notifications/install-homebrew.stderr | 8 - .../notifications/install-wget-prompt.stderr | 8 + .../outputs/notifications/install-wget.stderr | 8 - .../no-install-command-prompt.stderr | 7 + .../notifications/no-install-command.stderr | 5 - .../notifications/version-notification.stderr | 8 - .../version-prompt-run-upgrade.stderr | 8 + .../notifications/version-prompt-skip.stderr | 8 + test/test_helper/load.bash | 6 +- 34 files changed, 636 insertions(+), 508 deletions(-) delete mode 100644 internal/notifications/notifications_test.go rename test/inputs/notifications/{release_proxy.py => release_server.py} (60%) delete mode 100644 test/outputs/notifications/bug-fix-notification.stderr create mode 100644 test/outputs/notifications/bug-fix-prompt-skip.stderr create mode 100644 test/outputs/notifications/failed-command-after-skip.stderr delete mode 100644 test/outputs/notifications/failed-command.stderr delete mode 100755 test/outputs/notifications/feature-notification.stderr create mode 100644 test/outputs/notifications/feature-prompt-skip-until-next-version.stderr create mode 100644 test/outputs/notifications/feature-prompt-skip.stderr create mode 100644 test/outputs/notifications/feature-without-author-prompt-skip.stderr delete mode 100644 test/outputs/notifications/feature-without-author.stderr create mode 100644 test/outputs/notifications/features-with-details-prompt-skip.stderr delete mode 100644 test/outputs/notifications/features-with-details.stderr create mode 100644 test/outputs/notifications/install-curl-wide-prompt.stderr delete mode 100644 test/outputs/notifications/install-curl-wide.stderr create mode 100644 test/outputs/notifications/install-go-prompt.stderr delete mode 100644 test/outputs/notifications/install-go.stderr create mode 100644 test/outputs/notifications/install-homebrew-prompt.stderr delete mode 100644 test/outputs/notifications/install-homebrew.stderr create mode 100644 test/outputs/notifications/install-wget-prompt.stderr delete mode 100644 test/outputs/notifications/install-wget.stderr create mode 100644 test/outputs/notifications/no-install-command-prompt.stderr delete mode 100644 test/outputs/notifications/no-install-command.stderr delete mode 100644 test/outputs/notifications/version-notification.stderr create mode 100644 test/outputs/notifications/version-prompt-run-upgrade.stderr create mode 100644 test/outputs/notifications/version-prompt-skip.stderr diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 12028de7..5f33185d 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -103,6 +103,14 @@ 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 When creating new e2e tests make sure you adhere to the existing patterns diff --git a/internal/huhform/form.go b/internal/huhform/form.go index 7af16128..24915190 100644 --- a/internal/huhform/form.go +++ b/internal/huhform/form.go @@ -6,6 +6,7 @@ import ( "strconv" huh "charm.land/huh/v2" + "github.com/nobl9/sloctl/internal/style" ) diff --git a/internal/notifications/notifications.go b/internal/notifications/notifications.go index 91133c6a..d5ed4797 100644 --- a/internal/notifications/notifications.go +++ b/internal/notifications/notifications.go @@ -15,14 +15,18 @@ import ( "strings" "time" + huh "charm.land/huh/v2" "github.com/charmbracelet/glamour" "github.com/mattn/go-isatty" - "github.com/nobl9/sloctl/internal/style" "golang.org/x/term" + + "github.com/nobl9/sloctl/internal/huhform" + "github.com/nobl9/sloctl/internal/style" ) const ( defaultReleaseURL = "https://api.github.com/repos/nobl9/sloctl/releases/latest" + releaseURLEnv = "SLOCTL_NOTIFICATIONS_RELEASE_URL" optOutEnv = "SLOCTL_NO_NOTIFICATIONS" ciEnv = "CI" checkInterval = 24 * time.Hour @@ -30,7 +34,6 @@ const ( maxResponseSize = 1 << 20 defaultBoxWidth = 92 minBoxWidth = 48 - boxPadding = 2 ) var ( @@ -41,6 +44,8 @@ var ( // Config defines notification runtime dependencies. type Config struct { CurrentVersion string + Stdin io.Reader + Stdout io.Writer Stderr io.Writer ReleaseURL string @@ -56,17 +61,29 @@ type Config struct { RenderMarkdown func(string, int) (string, error) Executable func() (string, error) EvalSymlinks func(string) (string, error) + RunCommand func(context.Context, string) error } -// Notify checks and displays an unobtrusive notification when configured to do so. -func Notify(config Config) { +// Result describes what the caller should do after checking notifications. +type Result int + +const ( + ResultContinue Result = iota + ResultExitSuccess + ResultExitFailure +) + +// Notify checks and displays an interactive update prompt when configured to do so. +func Notify(config Config) Result { ctx, cancel := context.WithTimeout(context.Background(), checkTimeout) defer cancel() - newNotifier(config).notify(ctx) + return newNotifier(config).notify(ctx) } type notifier struct { currentVersion string + stdin io.Reader + stdout io.Writer stderr io.Writer releaseURL string httpClient *http.Client @@ -79,6 +96,7 @@ type notifier struct { renderMarkdown func(string, int) (string, error) executable func() (string, error) evalSymlinks func(string) (string, error) + runCommand func(context.Context, string) error } type state struct { @@ -106,12 +124,35 @@ const ( installChannelGo installChannel = "go-install" ) +type updateAction string + +const ( + updateActionRunUpgrade updateAction = "run-upgrade" + updateActionSkip updateAction = "skip" + updateActionSkipUntilNextVersion updateAction = "skip-until-next-version" +) + func newNotifier(config Config) notifier { + stdin := io.Reader(os.Stdin) + if config.Stdin != nil { + stdin = config.Stdin + } + stdout := io.Writer(os.Stdout) + if config.Stdout != nil { + stdout = config.Stdout + } stderr := io.Writer(os.Stderr) if config.Stderr != nil { stderr = config.Stderr } - releaseURL := config.ReleaseURL + getenv := config.Getenv + if getenv == nil { + getenv = os.Getenv + } + releaseURL := strings.TrimSpace(config.ReleaseURL) + if releaseURL == "" { + releaseURL = strings.TrimSpace(getenv(releaseURLEnv)) + } if releaseURL == "" { releaseURL = defaultReleaseURL } @@ -127,10 +168,6 @@ func newNotifier(config Config) notifier { if now == nil { now = time.Now } - getenv := config.Getenv - if getenv == nil { - getenv = os.Getenv - } isTTY := config.IsTTY if isTTY == nil { isTTY = func() bool { @@ -175,8 +212,20 @@ func newNotifier(config Config) notifier { if evalSymlinks == nil { evalSymlinks = filepath.EvalSymlinks } + runCommand := config.RunCommand + if runCommand == nil { + runCommand = func(ctx context.Context, command string) error { + cmd := exec.CommandContext(ctx, "sh", "-c", command) + cmd.Stdin = stdin + cmd.Stdout = stdout + cmd.Stderr = stderr + return cmd.Run() + } + } return notifier{ currentVersion: strings.TrimSpace(config.CurrentVersion), + stdin: stdin, + stdout: stdout, stderr: stderr, releaseURL: releaseURL, httpClient: httpClient, @@ -189,28 +238,29 @@ func newNotifier(config Config) notifier { renderMarkdown: renderMarkdown, executable: executable, evalSymlinks: evalSymlinks, + runCommand: runCommand, } } -func (n notifier) notify(ctx context.Context) { +func (n notifier) notify(ctx context.Context) Result { if !n.canNotify() { - return + return ResultContinue } currentState := n.readState() now := n.now() if !currentState.LastCheckedAt.IsZero() && now.Sub(currentState.LastCheckedAt) < checkInterval { - return + return ResultContinue } release, err := n.fetchLatestRelease(ctx) currentState.LastCheckedAt = now if err != nil { n.saveState(currentState) - return + return ResultContinue } if isCurrentRelease(n.currentVersion, release.TagName) { n.saveState(currentState) - return + return ResultContinue } releaseNotesMarkdown, _ := releaseNotesSection(release.Body) var releaseNoteID string @@ -221,13 +271,31 @@ func (n notifier) notify(ctx context.Context) { } if alreadyShown(currentState, release.TagName, releaseNoteID) { n.saveState(currentState) - return + return ResultContinue } - _, _ = fmt.Fprintln(n.stderr, n.renderNotification(release, releaseNotesMarkdown)) - currentState.LastShownReleaseTag = release.TagName - currentState.LastShownFeatureID = releaseNoteID + updateCommand := n.updateCommand() + action, err := n.promptUpdate(release, releaseNotesMarkdown, updateCommand) + if err != nil { + n.saveState(currentState) + return ResultContinue + } + if action == updateActionSkipUntilNextVersion { + currentState.LastShownReleaseTag = release.TagName + currentState.LastShownFeatureID = releaseNoteID + } n.saveState(currentState) + if action != updateActionRunUpgrade { + return ResultContinue + } + if updateCommand == "" { + return ResultContinue + } + if err := n.runCommand(context.Background(), updateCommand); err != nil { + _, _ = fmt.Fprintf(n.stderr, "failed to update sloctl: %v\n", err) + return ResultExitFailure + } + return ResultExitSuccess } func (n notifier) canNotify() bool { @@ -296,12 +364,42 @@ func defaultCachePath() string { return filepath.Join(cacheDir, "nobl9", "sloctl", "notifications.json") } +func (n notifier) promptUpdate( + release githubRelease, + releaseNotesMarkdown string, + updateCommand string, +) (updateAction, error) { + _, _ = fmt.Fprintln(n.stderr, n.renderNotification(release, releaseNotesMarkdown)) + action := updateActionSkip + form := huhform.New(huh.NewGroup( + huh.NewSelect[updateAction](). + Title("Choose update action"). + Options(updateActionOptions(updateCommand)...). + Value(&action), + )). + WithInput(n.stdin). + WithOutput(n.stderr) + return action, form.Run() +} + +func updateActionOptions(updateCommand string) []huh.Option[updateAction] { + options := []huh.Option[updateAction]{ + huh.NewOption("Skip", updateActionSkip), + huh.NewOption("Skip until next version", updateActionSkipUntilNextVersion), + } + if updateCommand == "" { + return options + } + return append( + []huh.Option[updateAction]{ + huh.NewOption(fmt.Sprintf("Update (runs %s)", updateCommand), updateActionRunUpgrade), + }, + options..., + ) +} + func (n notifier) renderNotification(release githubRelease, releaseNotesMarkdown string) string { width := notificationWidth(n.terminalWidth()) - border := style.NotificationBorder() - contentWidth := width - boxPadding - border.GetLeftSize() - border.GetRightSize() - updateCommand := n.updateCommand() - formattedUpdateCommand := formatShellPipeline(updateCommand, contentWidth-boxPadding*2) parts := []string{ notificationTitleMarkdown(releaseNotesMarkdown, release.TagName), } @@ -309,27 +407,27 @@ func (n notifier) renderNotification(release githubRelease, releaseNotesMarkdown parts = append(parts, strings.TrimSpace(releaseNotesMarkdown)) } parts = append(parts, fmt.Sprintf("📜 %s", release.HTMLURL)) - if updateCommand != "" { - parts = append(parts, fmt.Sprintf("Update sloctl with:\n\n```shell\n%s\n```", formattedUpdateCommand)) - } markdown := strings.Join(parts, "\n\n") - rendered := styledPlainNotification(release, releaseNotesMarkdown, formattedUpdateCommand) + rendered := styledPlainNotification(release, releaseNotesMarkdown) if releaseNotesMarkdown != "" { var err error - rendered, err = n.renderMarkdown(markdown, contentWidth) + rendered, err = n.renderMarkdown(markdown, width) if err != nil { - rendered = styledPlainNotification(release, releaseNotesMarkdown, formattedUpdateCommand) + rendered = styledPlainNotification(release, releaseNotesMarkdown) } + rendered = trimTrailingLineSpace(rendered) } - rendered = strings.TrimSpace(rendered) - - return style.NotificationBox(contentWidth).Render(rendered) + return strings.TrimSpace(rendered) } func renderMarkdownWithGlamour(markdown string, width int) (string, error) { + styleName := "dark" + if os.Getenv("NO_COLOR") != "" { + styleName = "ascii" + } renderer, err := glamour.NewTermRenderer( - glamour.WithStandardStyle("dark"), + glamour.WithStandardStyle(styleName), glamour.WithWordWrap(width), glamour.WithPreservedNewLines(), ) @@ -339,11 +437,10 @@ func renderMarkdownWithGlamour(markdown string, width int) (string, error) { return renderer.Render(markdown) } -func styledPlainNotification(release githubRelease, releaseNotesMarkdown, updateCommand string) string { +func styledPlainNotification(release githubRelease, releaseNotesMarkdown string) string { titleStyle := style.NotificationTitle() linkStyle := style.NotificationLink() labelStyle := style.NotificationLabel() - commandStyle := style.NotificationCommand() parts := []string{ titleStyle.Render(notificationTitle(releaseNotesMarkdown, release.TagName)), @@ -352,15 +449,17 @@ func styledPlainNotification(release githubRelease, releaseNotesMarkdown, update parts = append(parts, stripMarkdownHeading(releaseNotesMarkdown)) } parts = append(parts, labelStyle.Render("📜")+" "+linkStyle.Render(release.HTMLURL)) - if updateCommand != "" { - parts = append( - parts, - labelStyle.Render("Update sloctl with:")+"\n"+commandStyle.Render(updateCommand), - ) - } 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 notificationTitleMarkdown(releaseNotesMarkdown, releaseTag string) string { if releaseNotesMarkdown == "" { return notificationTitle(releaseNotesMarkdown, releaseTag) @@ -375,36 +474,6 @@ func notificationTitle(releaseNotesMarkdown, releaseTag string) string { return fmt.Sprintf("New sloctl updates in %s!", releaseTag) } -func formatShellPipeline(command string, maxLineWidth int) string { - if len(command) <= maxLineWidth { - return command - } - left, right, ok := strings.Cut(command, " | ") - if !ok { - return command - } - pipeline := left + " \\\n| " + right - if maxLineLen(pipeline) <= maxLineWidth { - return pipeline - } - if url, ok := strings.CutPrefix(left, "curl -fsSL "); ok { - curlPipeline := "curl -fsSL \\\n" + url + " | " + right - if maxLineLen(curlPipeline) <= maxLineWidth { - return curlPipeline - } - return "curl -fsSL \\\n " + url + " \\\n | " + right - } - return pipeline -} - -func maxLineLen(text string) int { - maxLen := 0 - for _, line := range strings.Split(text, "\n") { - maxLen = max(maxLen, len(line)) - } - return maxLen -} - func (n notifier) updateCommand() string { switch n.installChannel() { case installChannelHomebrew: diff --git a/internal/notifications/notifications_test.go b/internal/notifications/notifications_test.go deleted file mode 100644 index d2760f04..00000000 --- a/internal/notifications/notifications_test.go +++ /dev/null @@ -1,113 +0,0 @@ -package notifications - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "io" - "net/http" - "path/filepath" - "regexp" - "strings" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestNotifier_notifyFallsBackWhenMarkdownRenderingFails(t *testing.T) { - client := testHTTPClient(func(*http.Request) (int, string) { - return http.StatusOK, releaseJSON(t, githubRelease{ - TagName: "v1.2.0", - Body: `## 🚀 Features - -- Add workflow insights (#123) @bot -`, - HTMLURL: "https://github.com/nobl9/sloctl/releases/tag/v1.2.0", - }) - }) - - var out bytes.Buffer - config := testConfig(t, &out, time.Now()) - config.HTTPClient = client - config.RenderMarkdown = func(string, int) (string, error) { - return "", errors.New("render failed") - } - - newNotifier(config).notify(context.Background()) - - plainOutput := stripANSI(out.String()) - assert.Contains(t, plainOutput, "New sloctl updates in v1.2.0!") - assert.Contains(t, plainOutput, "- Add workflow insights (#123) @bot") - assert.Contains(t, plainOutput, "📜 https://github.com/nobl9/sloctl/releases/tag/v1.2.0") - assert.NotContains(t, plainOutput, "Release notes:") - assert.Contains(t, plainOutput, "Update sloctl with:") - assert.Contains(t, plainOutput, "curl -fsSL https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash") -} - -func TestNotifier_notifySkipsDevelopmentVersion(t *testing.T) { - var out bytes.Buffer - requests := 0 - config := testConfig(t, &out, time.Now()) - config.CurrentVersion = "1.0.0-test" - config.HTTPClient = testHTTPClient(func(*http.Request) (int, string) { - requests++ - return http.StatusOK, "{}" - }) - - newNotifier(config).notify(context.Background()) - - assert.Zero(t, requests) - assert.Empty(t, out.String()) -} - -func testConfig(t *testing.T, out *bytes.Buffer, now time.Time) Config { - t.Helper() - return Config{ - CurrentVersion: "1.1.0", - Stderr: out, - ReleaseURL: "https://example.com/repos/nobl9/sloctl/releases/latest", - CachePath: filepath.Join(t.TempDir(), "notifications.json"), - Now: func() time.Time { return now }, - Getenv: func(string) string { return "" }, - IsTTY: func() bool { return true }, - Lookup: func(name string) (string, error) { return "/bin/" + name, nil }, - TerminalWidth: func() int { return defaultBoxWidth }, - RenderMarkdown: func(markdown string, _ int) (string, error) { return markdown, nil }, - Executable: func() (string, error) { return "/usr/local/bin/sloctl", nil }, - EvalSymlinks: func(path string) (string, error) { return path, nil }, - } -} - -func releaseJSON(t *testing.T, release githubRelease) string { - t.Helper() - data, err := json.Marshal(release) - require.NoError(t, err) - return string(data) -} - -type roundTripFunc func(*http.Request) (*http.Response, error) - -func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { - return f(req) -} - -func testHTTPClient(handler func(*http.Request) (int, string)) *http.Client { - return &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { - statusCode, body := handler(req) - return &http.Response{ - StatusCode: statusCode, - Body: io.NopCloser(strings.NewReader(body)), - Header: make(http.Header), - Request: req, - }, nil - })} -} - -var ansiPattern = regexp.MustCompile(`\x1b\[[0-9;]*m`) - -func stripANSI(s string) string { - return ansiPattern.ReplaceAllString(s, "") -} diff --git a/internal/root.go b/internal/root.go index ee333e8f..c1db940c 100644 --- a/internal/root.go +++ b/internal/root.go @@ -20,9 +20,11 @@ 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. func Execute() { - if execute(NewRootCmd(), func() { - notifications.Notify(notifications.Config{ + if execute(NewRootCmd(), func() notifications.Result { + return notifications.Notify(notifications.Config{ CurrentVersion: getBuildVersion(), + Stdin: os.Stdin, + Stdout: os.Stdout, Stderr: os.Stderr, }) }) != 0 { @@ -30,11 +32,17 @@ func Execute() { } } -func execute(cmd *cobra.Command, notify func()) int { +func execute(cmd *cobra.Command, notify func() notifications.Result) int { + switch notify() { + case notifications.ResultExitSuccess: + return 0 + case notifications.ResultExitFailure: + return 1 + case notifications.ResultContinue: + } if err := cmd.Execute(); err != nil { return 1 } - notify() return 0 } diff --git a/internal/style/theme.go b/internal/style/theme.go index 97ae793c..3a4a9a16 100644 --- a/internal/style/theme.go +++ b/internal/style/theme.go @@ -2,13 +2,15 @@ package style import ( + "os" + huh "charm.land/huh/v2" "charm.land/lipgloss/v2" ) const ( tealHex = "#00819E" - cyanHex = "#63D6E5" + cyanHex = "#00D5FF" mutedHex = "#BABBBB" lightGrayHex = "#E8E9E9" darkGrayHex = "#383939" @@ -17,6 +19,7 @@ const ( yellowHex = "#C8D655" pinkHex = "#DB2779" redHex = "#D42E56" + noColorEnv = "NO_COLOR" ) var ( @@ -45,6 +48,14 @@ var ( // HuhTheme returns the shared Nobl9 terminal theme for interactive forms. func HuhTheme(isDark bool) *huh.Styles { t := huh.ThemeBase(isDark) + if noColor() { + return plainHuhTheme(t) + } + + optionText := White + if !isDark { + optionText = DarkGray + } t.Focused.Base = t.Focused.Base.BorderForeground(Teal) t.Focused.Card = t.Focused.Base @@ -54,20 +65,27 @@ func HuhTheme(isDark bool) *huh.Styles { t.Focused.Description = t.Focused.Description.Foreground(Muted) 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.SelectSelector = t.Focused.SelectSelector.Foreground(Teal) + t.Focused.NextIndicator = t.Focused.NextIndicator.Foreground(Teal) + t.Focused.PrevIndicator = t.Focused.PrevIndicator.Foreground(Teal) + t.Focused.Option = t.Focused.Option.Foreground(optionText) + t.Focused.MultiSelectSelector = t.Focused.MultiSelectSelector.Foreground(Teal) 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.UnselectedOption = t.Focused.UnselectedOption.Foreground(optionText) t.Focused.FocusedButton = t.Focused.FocusedButton.Foreground(White).Background(Pink) t.Focused.BlurredButton = t.Focused.BlurredButton.Foreground(White).Background(DarkGray) t.Focused.TextInput.Cursor.Foreground(Pink) t.Focused.TextInput.Placeholder.Foreground(Muted) - t.Focused.TextInput.Prompt.Foreground(Yellow) + t.Focused.TextInput.Prompt.Foreground(Teal) + + t.Help.ShortKey = t.Help.ShortKey.Foreground(Teal) + t.Help.FullKey = t.Help.FullKey.Foreground(Teal) + t.Help.ShortDesc = t.Help.ShortDesc.Foreground(Muted) + t.Help.FullDesc = t.Help.FullDesc.Foreground(Muted) + t.Help.ShortSeparator = t.Help.ShortSeparator.Foreground(Muted) + t.Help.FullSeparator = t.Help.FullSeparator.Foreground(Muted) t.Blurred = t.Focused t.Blurred.Base = t.Blurred.Base.BorderStyle(lipgloss.HiddenBorder()) @@ -76,7 +94,7 @@ func HuhTheme(isDark bool) *huh.Styles { t.Blurred.Title = t.Blurred.NoteTitle.Foreground(Muted) t.Blurred.TextInput.Prompt = t.Blurred.TextInput.Prompt.Foreground(Muted) - t.Blurred.TextInput.Text = t.Blurred.TextInput.Text.Foreground(White) + t.Blurred.TextInput.Text = t.Blurred.TextInput.Text.Foreground(optionText) t.Blurred.NextIndicator = lipgloss.NewStyle() t.Blurred.PrevIndicator = lipgloss.NewStyle() @@ -87,36 +105,90 @@ func HuhTheme(isDark bool) *huh.Styles { return t } -// NotificationBorder returns the shared border for notification containers. -func NotificationBorder() lipgloss.Border { - return lipgloss.RoundedBorder() -} - -// NotificationBox returns the shared style for notification containers. -func NotificationBox(contentWidth int) lipgloss.Style { - return lipgloss.NewStyle(). - BorderStyle(NotificationBorder()). - BorderForeground(Teal). - Padding(0, 1). - Width(contentWidth) -} - // NotificationTitle returns the shared style for notification titles. func NotificationTitle() lipgloss.Style { - return lipgloss.NewStyle().Foreground(White) + if noColor() { + return lipgloss.NewStyle() + } + return lipgloss.NewStyle().Foreground(White).Bold(true) } // NotificationLink returns the shared style for notification links. func NotificationLink() lipgloss.Style { + if noColor() { + return lipgloss.NewStyle() + } return lipgloss.NewStyle().Foreground(Cyan).Underline(true) } // NotificationLabel returns the shared style for notification labels. func NotificationLabel() lipgloss.Style { + if noColor() { + return lipgloss.NewStyle() + } return lipgloss.NewStyle().Foreground(Muted) } -// NotificationCommand returns the shared style for notification commands. -func NotificationCommand() lipgloss.Style { - return lipgloss.NewStyle().Foreground(LightGray).Italic(true) +func noColor() bool { + return os.Getenv(noColorEnv) != "" +} + +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_proxy.py b/test/inputs/notifications/release_server.py similarity index 60% rename from test/inputs/notifications/release_proxy.py rename to test/inputs/notifications/release_server.py index 300683c3..5d24819b 100644 --- a/test/inputs/notifications/release_proxy.py +++ b/test/inputs/notifications/release_server.py @@ -3,81 +3,66 @@ import json import os import socketserver -import ssl import sys +from http import HTTPStatus from pathlib import Path -GITHUB_HOST = "api.github.com" -GITHUB_PORT = "443" RELEASE_PATH = "/repos/nobl9/sloctl/releases/latest" DEFAULT_RELEASE_BODY_FILE = Path(__file__).with_name("release-bodies") / "feature.md" -class Proxy(socketserver.BaseRequestHandler): +class ReleaseHandler(socketserver.BaseRequestHandler): def handle(self): try: - self.handle_connect() + request = read_headers(self.request) except OSError: return - def handle_connect(self): - request = read_headers(self.request) - request_line = request.splitlines()[0] - method, target, _ = request_line.split(" ", 2) - if method != "CONNECT" or target != f"{GITHUB_HOST}:{GITHUB_PORT}": - self.request.sendall(b"HTTP/1.1 502 Bad Gateway\r\n\r\n") + lines = request.splitlines() + if not lines: return - self.request.sendall(b"HTTP/1.1 200 Connection Established\r\n\r\n") - context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) - context.load_cert_chain(certfile=self.server.cert_file, keyfile=self.server.key_file) - tls_socket = context.wrap_socket(self.request, server_side=True) try: - self.handle_github_request(tls_socket) - finally: - tls_socket.close() + method, path, _ = lines[0].split(" ", 2) + except ValueError: + self.request.sendall(b"HTTP/1.1 400 Bad Request\r\n\r\n") + return - def handle_github_request(self, tls_socket): - request = read_headers(tls_socket) - lines = request.splitlines() - method, path, _ = lines[0].split(" ", 2) headers = parse_headers(lines[1:]) log_request(method, path, headers) if ( method != "GET" or path != RELEASE_PATH - or headers.get("host") != GITHUB_HOST or headers.get("accept") != "application/vnd.github+json" or headers.get("user-agent") != "sloctl" ): - tls_socket.sendall(b"HTTP/1.1 502 Bad Gateway\r\n\r\n") + self.request.sendall(b"HTTP/1.1 502 Bad Gateway\r\n\r\n") return - status = int(os.environ.get("RELEASE_PROXY_STATUS", "200")) - raw_body = os.environ.get("RELEASE_PROXY_RAW_RESPONSE") + 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_PROXY_TAG", "v1.1.0"), + "tag_name": os.environ.get("RELEASE_SERVER_TAG", "v1.1.0"), "body": release_body(), "html_url": os.environ.get( - "RELEASE_PROXY_HTML_URL", + "RELEASE_SERVER_HTML_URL", "https://github.com/nobl9/sloctl/releases/tag/v1.1.0", ), } ) body = raw_body.encode() - reason = "OK" if status == 200 else "Error" response = ( - f"HTTP/1.1 {status} {reason}\r\n".encode() + f"HTTP/1.1 {status} {reason_phrase(status)}\r\n".encode() + b"Content-Type: application/json\r\n" + f"Content-Length: {len(body)}\r\n".encode() + b"Connection: close\r\n" + b"\r\n" + body ) - tls_socket.sendall(response) + self.request.sendall(response) class ThreadingTCPServer(socketserver.ThreadingTCPServer): @@ -95,10 +80,10 @@ def read_headers(sock): def release_body(): - body_file = os.environ.get("RELEASE_PROXY_BODY_FILE") + body_file = os.environ.get("RELEASE_SERVER_BODY_FILE") if body_file: return Path(body_file).read_text(encoding="utf-8") - body = os.environ.get("RELEASE_PROXY_BODY") + body = os.environ.get("RELEASE_SERVER_BODY") if body is not None: return body return DEFAULT_RELEASE_BODY_FILE.read_text(encoding="utf-8") @@ -115,7 +100,7 @@ def parse_headers(lines): def log_request(method, path, headers): - log_path = os.environ.get("RELEASE_PROXY_LOG") + log_path = os.environ.get("RELEASE_SERVER_LOG") if not log_path: return with open(log_path, "a", encoding="utf-8") as log_file: @@ -133,13 +118,16 @@ def log_request(method, path, headers): ) +def reason_phrase(status): + try: + return HTTPStatus(status).phrase + except ValueError: + return "Status" + + def main(): port_file = Path(sys.argv[1]) - cert_file = sys.argv[2] - key_file = sys.argv[3] - with ThreadingTCPServer(("127.0.0.1", 0), Proxy) as server: - server.cert_file = cert_file - server.key_file = key_file + with ThreadingTCPServer(("127.0.0.1", 0), ReleaseHandler) as server: port_file.write_text(str(server.server_address[1]), encoding="utf-8") server.serve_forever() diff --git a/test/inputs/notifications/run_with_stderr_pty.py b/test/inputs/notifications/run_with_stderr_pty.py index 15300b4a..55ecb853 100644 --- a/test/inputs/notifications/run_with_stderr_pty.py +++ b/test/inputs/notifications/run_with_stderr_pty.py @@ -24,13 +24,21 @@ def main(): termios.TIOCSWINSZ, struct.pack("HHHH", 24, int(columns), 0, 0), ) + stdin = subprocess.DEVNULL + input_text = os.environ.get("SLOCTL_TEST_TTY_INPUT") + if input_text is not None: + stdin = subprocess.PIPE + process = subprocess.Popen( sys.argv[1:], - stdin=subprocess.DEVNULL, + stdin=stdin, stdout=subprocess.PIPE, stderr=slave_fd, close_fds=True, ) + if input_text is not None: + process.stdin.write(input_text.encode()) + process.stdin.close() os.close(slave_fd) selector = selectors.DefaultSelector() @@ -55,7 +63,7 @@ def main(): key.fileobj.close() continue - key.data.write(data.replace(b"\r\n", b"\n")) + key.data.write(data.replace(b"\r\n", b"\n").replace(b"\r", b"")) key.data.flush() return process.wait() diff --git a/test/notifications.bats b/test/notifications.bats index bb0879b5..c33c73f0 100644 --- a/test/notifications.bats +++ b/test/notifications.bats @@ -11,7 +11,7 @@ setup() { load_lib "bats-support" load_lib "bats-assert" - ensure_installed openssl python3 + ensure_installed python3 unset CI unset ALL_PROXY @@ -24,246 +24,323 @@ setup() { unset http_proxy unset no_proxy unset SLOCTL_NO_NOTIFICATIONS - unset RELEASE_PROXY_BODY - unset RELEASE_PROXY_BODY_FILE - unset RELEASE_PROXY_HTML_URL - unset RELEASE_PROXY_RAW_RESPONSE - unset RELEASE_PROXY_STATUS - unset RELEASE_PROXY_TAG + unset SLOCTL_NOTIFICATIONS_RELEASE_URL unset SLOCTL_TEST_TTY_COLUMNS + unset SLOCTL_TEST_TTY_INPUT + unset RELEASE_SERVER_BODY + unset RELEASE_SERVER_BODY_FILE + unset RELEASE_SERVER_HTML_URL + unset RELEASE_SERVER_RAW_RESPONSE + unset RELEASE_SERVER_STATUS + unset RELEASE_SERVER_TAG + + export NO_COLOR=1 + export SLOCTL_ACCESSIBLE_MODE=1 export XDG_CACHE_HOME="$BATS_TMPDIR/cache-$BATS_TEST_NUMBER" - export RELEASE_PROXY_LOG="$BATS_TMPDIR/release-proxy-$BATS_TEST_NUMBER.log" + export RELEASE_SERVER_LOG="$BATS_TMPDIR/release-server-$BATS_TEST_NUMBER.log" + select_update_action skip } teardown() { - if [ -n "${RELEASE_PROXY_PID:-}" ]; then - kill "$RELEASE_PROXY_PID" - wait "$RELEASE_PROXY_PID" 2> /dev/null || true + if [ -n "${RELEASE_SERVER_PID:-}" ]; then + kill "$RELEASE_SERVER_PID" + wait "$RELEASE_SERVER_PID" 2> /dev/null || true fi } @test "sloctl shows a feature notification on TTY stderr and caches it" { - start_release_proxy + start_release_server - run_sloctl_with_stderr_pty version + run_sloctl_with_tty_stderr version assert_success_joined_output - assert_stderr_file "feature-notification.stderr" - assert_release_proxy_requests 1 + assert_notification_stderr feature-prompt-skip + assert_release_requests 1 - run_sloctl_with_stderr_pty version + run_sloctl_with_tty_stderr version assert_success_joined_output - assert_stderr "" - assert_release_proxy_requests 1 + refute_stderr + assert_release_requests 1 } -@test "sloctl shows a notification after successful commands" { - start_release_proxy +@test "sloctl prompts before command validation and then runs the command after skip" { + start_release_server - run_sloctl_with_stderr_pty version + 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" { + select_update_action skip-until-next-version + start_release_server + + run_sloctl_with_tty_stderr version assert_success_joined_output - assert_stderr_file "feature-notification.stderr" - assert_release_proxy_requests 1 + assert_notification_stderr feature-prompt-skip-until-next-version + assert_release_requests 1 + + run_sloctl_with_tty_stderr version + assert_success_joined_output + refute_stderr + assert_release_requests 1 } -@test "sloctl skips notifications after failed commands" { - start_release_proxy +@test "sloctl runs upgrade and exits without running the command" { + use_release_body maintenance + select_update_action run-upgrade + export SLOCTL_TEST_UPGRADE_MARKER="$BATS_TEST_TMPDIR/upgrade-ran" + local tools_dir="$BATS_TEST_TMPDIR/tools" + mkdir -p "$tools_dir" + printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'printf "%s\n" "touch \"$SLOCTL_TEST_UPGRADE_MARKER\""' \ + > "$tools_dir/curl" + chmod +x "$tools_dir/curl" + start_release_server - run_sloctl_with_stderr_pty config rename-context old - assert_failure - assert_stderr_file "failed-command.stderr" - assert_release_proxy_requests 0 + run_sloctl_binary_with_prefixed_path /usr/bin/sloctl "$tools_dir" version + assert_success_joined_output + assert_output "" + assert_notification_stderr version-prompt-run-upgrade + assert [ -f "$SLOCTL_TEST_UPGRADE_MARKER" ] + assert_release_requests 1 } @test "sloctl does not show feature notification when opted out" { - start_release_proxy + start_release_server export SLOCTL_NO_NOTIFICATIONS=1 - run_sloctl_with_stderr_pty version + run_sloctl_with_tty_stderr version assert_success_joined_output - assert_stderr "" - assert_release_proxy_requests 0 + refute_stderr + assert_release_requests 0 } @test "sloctl does not show feature notification in CI" { - start_release_proxy + start_release_server export CI=true - run_sloctl_with_stderr_pty version + run_sloctl_with_tty_stderr version assert_success_joined_output - assert_stderr "" - assert_release_proxy_requests 0 + refute_stderr + assert_release_requests 0 } @test "sloctl does not show feature notification without TTY stderr" { - start_release_proxy + start_release_server run_sloctl version assert_success_joined_output - assert_stderr "" - assert_release_proxy_requests 0 + refute_stderr + assert_release_requests 0 } @test "sloctl shows version notification when release has no feature notes" { - export RELEASE_PROXY_BODY_FILE="$TEST_INPUTS/release-bodies/maintenance.md" - start_release_proxy + use_release_body maintenance + start_release_server - run_sloctl_with_stderr_pty version + run_sloctl_with_tty_stderr version assert_success_joined_output - assert_stderr_file "version-notification.stderr" - assert_release_proxy_requests 1 + assert_notification_stderr version-prompt-skip + assert_release_requests 1 } @test "sloctl uses the first non-empty release notes section" { - export RELEASE_PROXY_BODY_FILE="$TEST_INPUTS/release-bodies/empty-features-then-bug-fixes.md" - start_release_proxy + use_release_body empty-features-then-bug-fixes + start_release_server - run_sloctl_with_stderr_pty version + run_sloctl_with_tty_stderr version assert_success_joined_output - assert_stderr_file "bug-fix-notification.stderr" - assert_release_proxy_requests 1 + assert_notification_stderr bug-fix-prompt-skip + assert_release_requests 1 } @test "sloctl keeps nested release-note details from the selected section" { - export RELEASE_PROXY_BODY_FILE="$TEST_INPUTS/release-bodies/features-with-details.md" - start_release_proxy + use_release_body features-with-details + start_release_server - run_sloctl_with_stderr_pty version + run_sloctl_with_tty_stderr version assert_success_joined_output - assert_stderr_file "features-with-details.stderr" - assert_release_proxy_requests 1 + assert_notification_stderr features-with-details-prompt-skip + assert_release_requests 1 } @test "sloctl shows release note without author metadata" { - export RELEASE_PROXY_BODY_FILE="$TEST_INPUTS/release-bodies/feature-without-author.md" - start_release_proxy + use_release_body feature-without-author + start_release_server - run_sloctl_with_stderr_pty version + run_sloctl_with_tty_stderr version assert_success_joined_output - assert_stderr_file "feature-without-author.stderr" - assert_release_proxy_requests 1 + assert_notification_stderr feature-without-author-prompt-skip + assert_release_requests 1 } @test "sloctl does not show notification for current release" { - export RELEASE_PROXY_TAG=v1.0.0 - export RELEASE_PROXY_HTML_URL=https://github.com/nobl9/sloctl/releases/tag/v1.0.0 - start_release_proxy + 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_stderr_pty version + run_sloctl_with_tty_stderr version assert_success_joined_output - assert_stderr "" - assert_release_proxy_requests 1 + refute_stderr + assert_release_requests 1 } @test "sloctl suppresses fetch failures and caches the check" { - export RELEASE_PROXY_STATUS=403 - export RELEASE_PROXY_RAW_RESPONSE="rate limited" - start_release_proxy + export RELEASE_SERVER_STATUS=403 + export RELEASE_SERVER_RAW_RESPONSE="rate limited" + start_release_server - run_sloctl_with_stderr_pty version + run_sloctl_with_tty_stderr version assert_success_joined_output - assert_stderr "" + refute_stderr - run_sloctl_with_stderr_pty version + run_sloctl_with_tty_stderr version assert_success_joined_output - assert_stderr "" - assert_release_proxy_requests 1 + refute_stderr + assert_release_requests 1 } @test "sloctl suppresses malformed release responses" { - export RELEASE_PROXY_RAW_RESPONSE="{" - start_release_proxy + export RELEASE_SERVER_RAW_RESPONSE="{" + start_release_server - run_sloctl_with_stderr_pty version + run_sloctl_with_tty_stderr version assert_success_joined_output - assert_stderr "" - assert_release_proxy_requests 1 + refute_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_proxy + start_release_server - run_sloctl_with_stderr_pty version + run_sloctl_with_tty_stderr version assert_success_joined_output - assert_stderr_file "feature-notification.stderr" - assert_release_proxy_requests 1 + assert_notification_stderr feature-prompt-skip + assert_release_requests 1 } @test "sloctl keeps install command on one line when terminal is wide" { - export RELEASE_PROXY_BODY_FILE="$TEST_INPUTS/release-bodies/maintenance.md" + use_release_body maintenance export SLOCTL_TEST_TTY_COLUMNS=140 local tools_dir="$BATS_TEST_TMPDIR/tools" mkdir -p "$tools_dir" touch "$tools_dir/curl" chmod +x "$tools_dir/curl" - start_release_proxy + start_release_server run_sloctl_binary_with_path /usr/bin/sloctl "$tools_dir" version assert_success_joined_output - assert_stderr_file "install-curl-wide.stderr" + assert_notification_stderr install-curl-wide-prompt } @test "sloctl suggests Homebrew upgrade for Homebrew installs" { - export RELEASE_PROXY_BODY_FILE="$TEST_INPUTS/release-bodies/maintenance.md" + 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" copy_sloctl_binary "$cellar_binary" mkdir -p "$(dirname "$linked_binary")" ln -s "$cellar_binary" "$linked_binary" - start_release_proxy + start_release_server - run_sloctl_binary_with_stderr_pty "$linked_binary" version + run_sloctl_binary_with_tty_stderr "$linked_binary" version assert_success_joined_output - assert_stderr_file "install-homebrew.stderr" + assert_notification_stderr install-homebrew-prompt } @test "sloctl suggests go install for Go bin installs" { - export RELEASE_PROXY_BODY_FILE="$TEST_INPUTS/release-bodies/maintenance.md" + use_release_body maintenance export HOME="$BATS_TEST_TMPDIR/home" local go_binary="$HOME/go/bin/sloctl" copy_sloctl_binary "$go_binary" - start_release_proxy + start_release_server - run_sloctl_binary_with_stderr_pty "$go_binary" version + run_sloctl_binary_with_tty_stderr "$go_binary" version assert_success_joined_output - assert_stderr_file "install-go.stderr" + assert_notification_stderr install-go-prompt } @test "sloctl falls back to wget when curl is unavailable" { - export RELEASE_PROXY_BODY_FILE="$TEST_INPUTS/release-bodies/maintenance.md" + use_release_body maintenance local tools_dir="$BATS_TEST_TMPDIR/tools" mkdir -p "$tools_dir" touch "$tools_dir/wget" chmod +x "$tools_dir/wget" - start_release_proxy + start_release_server run_sloctl_binary_with_path /usr/bin/sloctl "$tools_dir" version assert_success_joined_output - assert_stderr_file "install-wget.stderr" + assert_notification_stderr install-wget-prompt } @test "sloctl omits update command when no downloader is available" { - export RELEASE_PROXY_BODY_FILE="$TEST_INPUTS/release-bodies/maintenance.md" + use_release_body maintenance + select_update_action_without_update skip local tools_dir="$BATS_TEST_TMPDIR/tools" mkdir -p "$tools_dir" - start_release_proxy + start_release_server run_sloctl_binary_with_path /usr/bin/sloctl "$tools_dir" version assert_success_joined_output - assert_stderr_file "no-install-command.stderr" + assert_notification_stderr no-install-command-prompt +} + +assert_notification_stderr() { + local name="$1" + local expected + expected="$(normalize_tty_output < "$TEST_OUTPUTS/$name.stderr")" + output="$(normalize_tty_output <<< "$stderr")" + assert_output "$expected" +} + +normalize_tty_output() { + sed -e 's/\r//g' -e 's/[[:blank:]]$//' } -assert_stderr_file() { - local file="$1" - assert_stderr - < "$TEST_OUTPUTS/$file" +use_release_body() { + local name="$1" + export RELEASE_SERVER_BODY_FILE="$TEST_INPUTS/release-bodies/$name.md" } -run_sloctl_with_stderr_pty() { - run_sloctl_binary_with_stderr_pty sloctl "$@" +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 } -run_sloctl_binary_with_stderr_pty() { +select_update_action_without_update() { + case "$1" in + skip) + export SLOCTL_TEST_TTY_INPUT=$'1\n' + ;; + skip-until-next-version) + export SLOCTL_TEST_TTY_INPUT=$'2\n' + ;; + *) + fail "unknown update action without update command: $1" + ;; + esac +} + +run_sloctl_with_tty_stderr() { + run_sloctl_binary_with_tty_stderr sloctl "$@" +} + +run_sloctl_binary_with_tty_stderr() { local binary="$1" shift bats_require_minimum_version 1.5.0 @@ -278,6 +355,14 @@ run_sloctl_binary_with_path() { run --separate-stderr env PATH="$path" /usr/bin/python3 "$TEST_INPUTS/run_with_stderr_pty.py" "$binary" "$@" } +run_sloctl_binary_with_prefixed_path() { + local binary="$1" + local path="$2" + shift 2 + bats_require_minimum_version 1.5.0 + run --separate-stderr env PATH="$path:$PATH" /usr/bin/python3 "$TEST_INPUTS/run_with_stderr_pty.py" "$binary" "$@" +} + copy_sloctl_binary() { local target="$1" mkdir -p "$(dirname "$target")" @@ -285,57 +370,29 @@ copy_sloctl_binary() { chmod +x "$target" } -start_release_proxy() { - local port_file="$BATS_TMPDIR/release-proxy-$BATS_TEST_NUMBER.port" - local cert_config="$BATS_TMPDIR/api-github-$BATS_TEST_NUMBER-openssl.cnf" - local cert_file="$BATS_TMPDIR/api-github-$BATS_TEST_NUMBER.pem" - local key_file="$BATS_TMPDIR/api-github-$BATS_TEST_NUMBER-key.pem" - - cat > "$cert_config" << EOF -[req] -distinguished_name = dn -x509_extensions = extensions -prompt = no - -[dn] -CN = api.github.com - -[extensions] -subjectAltName = DNS:api.github.com -EOF - - openssl req \ - -x509 \ - -newkey rsa:2048 \ - -nodes \ - -keyout "$key_file" \ - -out "$cert_file" \ - -days 1 \ - -config "$cert_config" \ - 2> /dev/null - - python3 "$TEST_INPUTS/release_proxy.py" "$port_file" "$cert_file" "$key_file" & - RELEASE_PROXY_PID="$!" +start_release_server() { + local port_file="$BATS_TMPDIR/release-server-$BATS_TEST_NUMBER.port" + python3 "$TEST_INPUTS/release_server.py" "$port_file" & + RELEASE_SERVER_PID="$!" for _ in {1..50}; do if [ -s "$port_file" ]; then local port port="$(cat "$port_file")" - export HTTPS_PROXY="http://127.0.0.1:$port" - export SSL_CERT_FILE="$cert_file" + export SLOCTL_NOTIFICATIONS_RELEASE_URL="http://127.0.0.1:$port/repos/nobl9/sloctl/releases/latest" return 0 fi sleep 0.1 done - fail "release proxy did not start" + fail "release server did not start" } -assert_release_proxy_requests() { +assert_release_requests() { local expected="$1" local actual=0 - if [ -f "$RELEASE_PROXY_LOG" ]; then - actual="$(wc -l < "$RELEASE_PROXY_LOG" | tr -d " ")" + 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/bug-fix-notification.stderr b/test/outputs/notifications/bug-fix-notification.stderr deleted file mode 100644 index 7c533e0f..00000000 --- a/test/outputs/notifications/bug-fix-notification.stderr +++ /dev/null @@ -1,14 +0,0 @@ -╭───────────────────────────────────────────────────────────────────────────────────────╮ -│  ### New sloctl updates in v1.1.0!                                                   │ -│                                                                                      │ -│  ## Bug Fixes                                                                        │ -│                                                                                      │ -│  • Fix output formatting (#125) @octocat                                             │ -│                                                                                     │ -│  📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0                              │ -│                                                                                     │ -│  Update sloctl with:                                                                 │ -│                                                                                     │ -│    wget -O - -q https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash |   │ -│  bash                                                                                │ -╰───────────────────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file 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..a5d923bc --- /dev/null +++ b/test/outputs/notifications/bug-fix-prompt-skip.stderr @@ -0,0 +1,12 @@ +### New sloctl updates in v1.1.0! + + ## Bug Fixes + + • Fix output formatting (#125) @octocat + + 📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 +Choose update action +1. Update (runs wget -O - -q https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash) +2. Skip +3. Skip until next version +Enter a number between 1 and 3: 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..fa42c33f --- /dev/null +++ b/test/outputs/notifications/failed-command-after-skip.stderr @@ -0,0 +1,13 @@ +### New sloctl updates in v1.1.0! + + ## Features + + • feat: Add notification tests (#321) @octocat + + 📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 +Choose update action +1. Update (runs wget -O - -q https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash) +2. Skip +3. Skip until next version +Enter a number between 1 and 3: +Error: either provide new and old context names or no arguments at all, received 1 arguments diff --git a/test/outputs/notifications/failed-command.stderr b/test/outputs/notifications/failed-command.stderr deleted file mode 100644 index 0ab10aa1..00000000 --- a/test/outputs/notifications/failed-command.stderr +++ /dev/null @@ -1 +0,0 @@ -Error: either provide new and old context names or no arguments at all, received 1 arguments \ No newline at end of file diff --git a/test/outputs/notifications/feature-notification.stderr b/test/outputs/notifications/feature-notification.stderr deleted file mode 100755 index beaf1584..00000000 --- a/test/outputs/notifications/feature-notification.stderr +++ /dev/null @@ -1,14 +0,0 @@ -╭───────────────────────────────────────────────────────────────────────────────────────╮ -│  ### New sloctl updates in v1.1.0!                                                   │ -│                                                                                      │ -│  ## Features                                                                         │ -│                                                                                      │ -│  • feat: Add notification tests (#321) @octocat                                      │ -│                                                                                     │ -│  📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0                              │ -│                                                                                     │ -│  Update sloctl with:                                                                 │ -│                                                                                     │ -│    wget -O - -q https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash |   │ -│  bash                                                                                │ -╰───────────────────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file 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..24bdd8c6 --- /dev/null +++ b/test/outputs/notifications/feature-prompt-skip-until-next-version.stderr @@ -0,0 +1,12 @@ +### New sloctl updates in v1.1.0! + + ## Features + + • feat: Add notification tests (#321) @octocat + + 📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 +Choose update action +1. Update (runs wget -O - -q https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash) +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..24bdd8c6 --- /dev/null +++ b/test/outputs/notifications/feature-prompt-skip.stderr @@ -0,0 +1,12 @@ +### New sloctl updates in v1.1.0! + + ## Features + + • feat: Add notification tests (#321) @octocat + + 📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 +Choose update action +1. Update (runs wget -O - -q https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash) +2. Skip +3. Skip until next version +Enter a number between 1 and 3: 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..b6efc120 --- /dev/null +++ b/test/outputs/notifications/feature-without-author-prompt-skip.stderr @@ -0,0 +1,12 @@ +### New sloctl updates in v1.1.0! + + ## Features + + • Add direct upload (#333) + + 📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 +Choose update action +1. Update (runs wget -O - -q https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash) +2. Skip +3. Skip until next version +Enter a number between 1 and 3: diff --git a/test/outputs/notifications/feature-without-author.stderr b/test/outputs/notifications/feature-without-author.stderr deleted file mode 100644 index 16981acd..00000000 --- a/test/outputs/notifications/feature-without-author.stderr +++ /dev/null @@ -1,14 +0,0 @@ -╭───────────────────────────────────────────────────────────────────────────────────────╮ -│  ### New sloctl updates in v1.1.0!                                                   │ -│                                                                                      │ -│  ## Features                                                                         │ -│                                                                                      │ -│  • Add direct upload (#333)                                                          │ -│                                                                                     │ -│  📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0                              │ -│                                                                                     │ -│  Update sloctl with:                                                                 │ -│                                                                                     │ -│    wget -O - -q https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash |   │ -│  bash                                                                                │ -╰───────────────────────────────────────────────────────────────────────────────────────╯ 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..328480e0 --- /dev/null +++ b/test/outputs/notifications/features-with-details-prompt-skip.stderr @@ -0,0 +1,18 @@ +### New sloctl updates in v1.1.0! + + ## 🚀 Features + + • Add workflow insights (#123) @octocat + | Extra release-note detail. + + + ### Details + + • Preserves nested feature details. + + 📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 +Choose update action +1. Update (runs wget -O - -q https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash) +2. Skip +3. Skip until next version +Enter a number between 1 and 3: diff --git a/test/outputs/notifications/features-with-details.stderr b/test/outputs/notifications/features-with-details.stderr deleted file mode 100644 index 993b8f5a..00000000 --- a/test/outputs/notifications/features-with-details.stderr +++ /dev/null @@ -1,20 +0,0 @@ -╭───────────────────────────────────────────────────────────────────────────────────────╮ -│  ### New sloctl updates in v1.1.0!                                                   │ -│                                                                                      │ -│  ## 🚀 Features                                                                      │ -│                                                                                      │ -│  • Add workflow insights (#123) @octocat                                             │ -│  │ Extra release-note detail.                                                        │ -│                                                                                      │ -│                                                                                      │ -│  ### Details                                                                         │ -│                                                                                      │ -│  • Preserves nested feature details.                                                 │ -│                                                                                     │ -│  📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0                              │ -│                                                                                     │ -│  Update sloctl with:                                                                 │ -│                                                                                     │ -│    wget -O - -q https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash |   │ -│  bash                                                                                │ -╰───────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/test/outputs/notifications/install-curl-wide-prompt.stderr b/test/outputs/notifications/install-curl-wide-prompt.stderr new file mode 100644 index 00000000..3c7ac6e3 --- /dev/null +++ b/test/outputs/notifications/install-curl-wide-prompt.stderr @@ -0,0 +1,8 @@ +New sloctl version v1.1.0 is available! + +📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 +Choose update action +1. Update (runs curl -fsSL https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash) +2. Skip +3. Skip until next version +Enter a number between 1 and 3: diff --git a/test/outputs/notifications/install-curl-wide.stderr b/test/outputs/notifications/install-curl-wide.stderr deleted file mode 100644 index 370499f9..00000000 --- a/test/outputs/notifications/install-curl-wide.stderr +++ /dev/null @@ -1,8 +0,0 @@ -╭──────────────────────────────────────────────────────────────────────────────────────╮ -│ New sloctl version v1.1.0 is available! │ -│ │ -│ 📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 │ -│ │ -│ Update sloctl with: │ -│ curl -fsSL https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash │ -╰──────────────────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/test/outputs/notifications/install-go-prompt.stderr b/test/outputs/notifications/install-go-prompt.stderr new file mode 100644 index 00000000..a7413363 --- /dev/null +++ b/test/outputs/notifications/install-go-prompt.stderr @@ -0,0 +1,8 @@ +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-go.stderr b/test/outputs/notifications/install-go.stderr deleted file mode 100644 index 05f1b33a..00000000 --- a/test/outputs/notifications/install-go.stderr +++ /dev/null @@ -1,8 +0,0 @@ -╭──────────────────────────────────────────────────────────────────────────────────────╮ -│ New sloctl version v1.1.0 is available! │ -│ │ -│ 📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 │ -│ │ -│ Update sloctl with: │ -│ go install github.com/nobl9/sloctl/cmd/sloctl@latest │ -╰──────────────────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/test/outputs/notifications/install-homebrew-prompt.stderr b/test/outputs/notifications/install-homebrew-prompt.stderr new file mode 100644 index 00000000..91aaedff --- /dev/null +++ b/test/outputs/notifications/install-homebrew-prompt.stderr @@ -0,0 +1,8 @@ +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/install-homebrew.stderr b/test/outputs/notifications/install-homebrew.stderr deleted file mode 100644 index 028ad98b..00000000 --- a/test/outputs/notifications/install-homebrew.stderr +++ /dev/null @@ -1,8 +0,0 @@ -╭──────────────────────────────────────────────────────────────────────────────────────╮ -│ New sloctl version v1.1.0 is available! │ -│ │ -│ 📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 │ -│ │ -│ Update sloctl with: │ -│ brew upgrade sloctl │ -╰──────────────────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/test/outputs/notifications/install-wget-prompt.stderr b/test/outputs/notifications/install-wget-prompt.stderr new file mode 100644 index 00000000..07ff3720 --- /dev/null +++ b/test/outputs/notifications/install-wget-prompt.stderr @@ -0,0 +1,8 @@ +New sloctl version v1.1.0 is available! + +📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 +Choose update action +1. Update (runs wget -O - -q https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash) +2. Skip +3. Skip until next version +Enter a number between 1 and 3: diff --git a/test/outputs/notifications/install-wget.stderr b/test/outputs/notifications/install-wget.stderr deleted file mode 100644 index 07cfda6b..00000000 --- a/test/outputs/notifications/install-wget.stderr +++ /dev/null @@ -1,8 +0,0 @@ -╭──────────────────────────────────────────────────────────────────────────────────────╮ -│ New sloctl version v1.1.0 is available! │ -│ │ -│ 📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 │ -│ │ -│ Update sloctl with: │ -│ wget -O - -q https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash │ -╰──────────────────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/test/outputs/notifications/no-install-command-prompt.stderr b/test/outputs/notifications/no-install-command-prompt.stderr new file mode 100644 index 00000000..eafad01f --- /dev/null +++ b/test/outputs/notifications/no-install-command-prompt.stderr @@ -0,0 +1,7 @@ +New sloctl version v1.1.0 is available! + +📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 +Choose update action +1. Skip +2. Skip until next version +Enter a number between 1 and 2: diff --git a/test/outputs/notifications/no-install-command.stderr b/test/outputs/notifications/no-install-command.stderr deleted file mode 100644 index 69e5fa61..00000000 --- a/test/outputs/notifications/no-install-command.stderr +++ /dev/null @@ -1,5 +0,0 @@ -╭──────────────────────────────────────────────────────────────────────────────────────╮ -│ New sloctl version v1.1.0 is available! │ -│ │ -│ 📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 │ -╰──────────────────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/test/outputs/notifications/version-notification.stderr b/test/outputs/notifications/version-notification.stderr deleted file mode 100644 index 07cfda6b..00000000 --- a/test/outputs/notifications/version-notification.stderr +++ /dev/null @@ -1,8 +0,0 @@ -╭──────────────────────────────────────────────────────────────────────────────────────╮ -│ New sloctl version v1.1.0 is available! │ -│ │ -│ 📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 │ -│ │ -│ Update sloctl with: │ -│ wget -O - -q https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash │ -╰──────────────────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file 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..3c7ac6e3 --- /dev/null +++ b/test/outputs/notifications/version-prompt-run-upgrade.stderr @@ -0,0 +1,8 @@ +New sloctl version v1.1.0 is available! + +📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 +Choose update action +1. Update (runs curl -fsSL https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash) +2. Skip +3. Skip until next version +Enter a number between 1 and 3: diff --git a/test/outputs/notifications/version-prompt-skip.stderr b/test/outputs/notifications/version-prompt-skip.stderr new file mode 100644 index 00000000..07ff3720 --- /dev/null +++ b/test/outputs/notifications/version-prompt-skip.stderr @@ -0,0 +1,8 @@ +New sloctl version v1.1.0 is available! + +📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 +Choose update action +1. Update (runs wget -O - -q https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash) +2. Skip +3. Skip until next version +Enter a number between 1 and 3: diff --git a/test/test_helper/load.bash b/test/test_helper/load.bash index c5feec4c..5e1ff558 100644 --- a/test/test_helper/load.bash +++ b/test/test_helper/load.bash @@ -294,6 +294,11 @@ assert_stderr() { assert_output "$@" } +refute_stderr() { + output="$stderr" + refute_output "$@" +} + # run_mcp_inspector # ========== # @@ -340,4 +345,3 @@ generate_outputs() { export TEST_OUTPUTS } - From 6e6eed6686a8848aef2978dcbe11b8d083b3536c Mon Sep 17 00:00:00 2001 From: Mateusz Hawrus Date: Wed, 1 Jul 2026 00:10:56 +0200 Subject: [PATCH 06/20] feat: add interactive update prompts --- .github/workflows/checks.yml | 2 +- .github/workflows/e2e-tests.yml | 2 +- .github/workflows/pr-autolabeler.yml | 19 + .github/workflows/release-candidate.yml | 2 +- .github/workflows/release-drafter.yml | 21 +- .github/workflows/release.yml | 32 +- .github/workflows/unit-tests.yml | 2 +- .github/workflows/vulns.yml | 2 +- go.mod | 23 +- go.sum | 75 ++- internal/get.go | 62 ++- internal/huhform/form.go | 8 +- internal/notifications/install.go | 98 ++++ internal/notifications/notifications.go | 524 ++---------------- internal/notifications/prompt.go | 205 +++++++ internal/notifications/release_notes.go | 123 ++++ internal/root.go | 30 +- internal/stdin.go | 34 ++ internal/style/theme.go | 264 ++++++--- package.json | 4 +- test/get.bats | 48 +- .../notifications/release-bodies/breaking.md | 5 + test/inputs/notifications/release_server.py | 81 +-- .../notifications/run_with_stderr_pty.py | 14 +- test/notifications.bats | 83 ++- .../notifications/breaking-prompt-skip.stderr | 15 + .../notifications/bug-fix-prompt-skip.stderr | 11 +- .../failed-command-after-skip.stderr | 11 +- ...ture-prompt-skip-until-next-version.stderr | 11 +- .../notifications/feature-prompt-skip.stderr | 11 +- .../feature-without-author-prompt-skip.stderr | 11 +- .../features-with-details-prompt-skip.stderr | 21 +- .../install-curl-wide-prompt.stderr | 3 + .../notifications/install-go-prompt.stderr | 3 + .../install-homebrew-prompt.stderr | 3 + .../notifications/install-wget-prompt.stderr | 3 + .../no-install-command-prompt.stderr | 3 + .../version-prompt-run-upgrade.stderr | 3 + .../notifications/version-prompt-skip.stderr | 3 + test/test_helper/load.bash | 12 +- 40 files changed, 1106 insertions(+), 781 deletions(-) create mode 100644 .github/workflows/pr-autolabeler.yml create mode 100644 internal/notifications/install.go create mode 100644 internal/notifications/prompt.go create mode 100644 internal/notifications/release_notes.go create mode 100644 internal/stdin.go create mode 100644 test/inputs/notifications/release-bodies/breaking.md create mode 100644 test/outputs/notifications/breaking-prompt-skip.stderr diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 2e3cc2c1..8b1f69b1 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -14,7 +14,7 @@ jobs: GO111MODULE: on steps: - name: Check out code - uses: actions/checkout@v6.0.2 + uses: actions/checkout@v7.0.0 - uses: actions/setup-go@v6.4.0 with: go-version-file: go.mod diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index ca79cec0..8a6b87a6 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -40,7 +40,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@v6.0.2 + uses: actions/checkout@v7.0.0 with: repository: nobl9/sloctl ref: ${{ inputs.ref }} diff --git a/.github/workflows/pr-autolabeler.yml b/.github/workflows/pr-autolabeler.yml new file mode 100644 index 00000000..573a9cc1 --- /dev/null +++ b/.github/workflows/pr-autolabeler.yml @@ -0,0 +1,19 @@ +name: PR Autolabeler + +on: + # pull_request_target is required to label PRs from forks. + # This workflow must stay metadata-only: do not checkout or run PR code here. + pull_request_target: + types: [opened, reopened, synchronize, edited] + +permissions: + contents: read + pull-requests: write + +jobs: + autolabeler: + runs-on: ubuntu-latest + steps: + - uses: release-drafter/release-drafter/autolabeler@v7.4.0 + with: + token: ${{ github.token }} diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index d5a5e08c..630c0ed0 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Source - uses: actions/checkout@v6.0.2 + uses: actions/checkout@v7.0.0 - name: Set up Go uses: actions/setup-go@v6.4.0 with: diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index cf5a7a92..bfba8001 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -4,28 +4,11 @@ on: push: branches: - main - # pull_request event is required only for autolabeler - # 'edited' event is required to account for initial invalid PR names - pull_request: - types: [opened, reopened, synchronize, edited] - permissions: contents: read jobs: - autolabeler: - if: github.event_name == 'pull_request' - permissions: - # write permission is required to add labels to pull requests - pull-requests: write - runs-on: ubuntu-latest - steps: - - uses: release-drafter/release-drafter/autolabeler@v7.3.1 - with: - token: ${{ secrets.RELEASE_LABELER_TOKEN }} - update_release_draft: - if: github.event_name == 'push' permissions: # write permission is required to create a github release contents: write @@ -35,7 +18,7 @@ jobs: steps: # Drafts your next Release notes as Pull Requests are merged into "main" - id: drafter - uses: release-drafter/release-drafter@v7.3.1 + uses: release-drafter/release-drafter@v7.4.0 with: token: ${{ secrets.RELEASE_LABELER_TOKEN }} @@ -49,7 +32,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.RELEASE_LABELER_TOKEN }} steps: - name: Checkout code - uses: actions/checkout@v6.0.2 + uses: actions/checkout@v7.0.0 with: ref: main fetch-depth: 0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ddd90b36..ee62f7ba 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,7 +23,7 @@ jobs: needs: [test, qa-approval] steps: - name: Checkout Source - uses: actions/checkout@v6.0.2 + uses: actions/checkout@v7.0.0 with: fetch-depth: 0 - name: Set up Go @@ -31,8 +31,24 @@ jobs: with: go-version-file: go.mod check-latest: true + - name: Release Binaries + uses: goreleaser/goreleaser-action@v7.2.2 + with: + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GORELEASER_TOKEN }} + GORELEASER_CURRENT_TAG: ${{ github.ref_name }} + docker-publish: + runs-on: ubuntu-latest + needs: [release] + outputs: + dockerVersion: ${{ steps.meta.outputs.version }} + steps: + - name: Checkout Source + uses: actions/checkout@v7.0.0 - name: Set up QEMU - uses: docker/setup-qemu-action@v4.0.0 + uses: docker/setup-qemu-action@v4.1.0 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4.1.0 - name: Login to Docker Hub @@ -58,14 +74,6 @@ jobs: tags: | type=sha type=semver,pattern={{version}} - - name: Release Binaries - uses: goreleaser/goreleaser-action@v7.2.2 - with: - version: latest - args: release --clean - env: - GITHUB_TOKEN: ${{ secrets.GORELEASER_TOKEN }} - GORELEASER_CURRENT_TAG: ${{ github.ref_name }} - name: Build and push id: docker_build uses: docker/build-push-action@v7.2.0 @@ -77,14 +85,14 @@ jobs: build-args: LDFLAGS=-s -w -X github.com/nobl9/sloctl/internal.BuildVersion=${{ github.ref_name }} docker-test: uses: ./.github/workflows/e2e-tests.yml - needs: [release] + needs: [docker-publish] strategy: matrix: image: [nobl9/sloctl, quay.io/nobl9/sloctl] with: clientId: ${{ vars.SLOCTL_CLIENT_ID }} ref: ${{ github.ref_name }} - sloctlImage: ${{ matrix.image }}:${{ github.ref_name }} + sloctlImage: ${{ matrix.image }}:${{ needs.docker-publish.outputs.dockerVersion }} target: test/go/e2e-docker secrets: clientSecret: "${{ secrets.SLOCTL_CLIENT_SECRET }}" diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 35cfaf17..9655b915 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@v6.0.2 + uses: actions/checkout@v7.0.0 - uses: actions/setup-go@v6.4.0 with: go-version-file: go.mod diff --git a/.github/workflows/vulns.yml b/.github/workflows/vulns.yml index 122e56d0..7f1dcfcf 100644 --- a/.github/workflows/vulns.yml +++ b/.github/workflows/vulns.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@v6.0.2 + uses: actions/checkout@v7.0.0 - name: Setup Golang uses: actions/setup-go@v6.4.0 with: diff --git a/go.mod b/go.mod index f43648c8..5fad4e9c 100644 --- a/go.mod +++ b/go.mod @@ -1,20 +1,20 @@ module github.com/nobl9/sloctl -go 1.26.3 +go 1.26.4 require ( charm.land/huh/v2 v2.0.3 - charm.land/lipgloss/v2 v2.0.3 + charm.land/lipgloss/v2 v2.0.4 github.com/BurntSushi/toml v1.6.0 github.com/OpenSLO/go-sdk v0.9.2 github.com/charmbracelet/glamour v1.0.0 - github.com/go-playground/validator/v10 v10.30.2 + 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.20 + github.com/mattn/go-isatty v0.0.22 github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db github.com/nobl9/go-yaml v1.0.1 - github.com/nobl9/nobl9-go v0.129.1 + github.com/nobl9/nobl9-go v0.133.0-rc1 github.com/nobl9/nobl9-openslo v0.1.6 github.com/pkg/errors v0.9.1 github.com/schollz/progressbar/v3 v3.19.0 @@ -22,7 +22,7 @@ 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/sync v0.20.0 + golang.org/x/sync v0.21.0 golang.org/x/term v0.43.0 ) @@ -61,17 +61,23 @@ require ( github.com/hashicorp/go-retryablehttp v0.7.8 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/itchyny/timefmt-go v0.1.8 // indirect + 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-runewidth v0.0.23 // 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 + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.67.5 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/teambition/rrule-go v1.8.2 // indirect github.com/tidwall/gjson v1.19.0 // indirect @@ -81,13 +87,14 @@ require ( github.com/yuin/goldmark v1.7.13 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect - golang.org/x/crypto v0.51.0 // indirect + golang.org/x/crypto v0.52.0 // indirect golang.org/x/mod v0.36.0 // indirect golang.org/x/net v0.54.0 // indirect - golang.org/x/sys v0.44.0 // indirect + golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.37.0 // indirect golang.org/x/tools v0.45.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 sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index 0e9da66e..8d691060 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,8 @@ charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= 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.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU= -charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA= +charm.land/lipgloss/v2 v2.0.4 h1:lcPeVtcp23SNra7lHy8iYE4UC2aIipVQ47sbGyyxR5Q= +charm.land/lipgloss/v2 v2.0.4/go.mod h1:0653x8epbZSzdDfO/XPS1a/uYPOBeSsCssOpJOqDzik= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= @@ -26,10 +26,14 @@ github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ 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= github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +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/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08= @@ -71,6 +75,8 @@ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= @@ -87,14 +93,17 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ= -github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc= +github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8= +github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +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= @@ -111,14 +120,22 @@ github.com/itchyny/gojq v0.12.19 h1:ttXA0XCLEMoaLOz5lSeFOZ6u6Q3QxmG46vfgI4O0DEs= github.com/itchyny/gojq v0.12.19/go.mod h1:5galtVPDywX8SPSOrqjGxkBeDhSxEW1gSxoy7tn1iZY= github.com/itchyny/timefmt-go v0.1.8 h1:1YEo1JvfXeAHKdjelbYr/uCuhkybaHCeTkH8Bo791OI= github.com/itchyny/timefmt-go v0.1.8/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI= +github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= +github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 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.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +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.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= @@ -128,28 +145,48 @@ github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2Em 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= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +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= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/nobl9/go-yaml v1.0.1 h1:Aj1kSaYdRQTKlvS6ihvXzQJhCpoHhtf9nfA95zqWH4Q= github.com/nobl9/go-yaml v1.0.1/go.mod h1:t7vCO8ctYdBweZxU5lUgxzAw31+ZcqJYeqRtrv+5RHI= github.com/nobl9/govy v0.26.0 h1:pjHXreO+3Rl+Uz6/rFX7L54zH4LW/SaTjb6YX3GQGs4= github.com/nobl9/govy v0.26.0/go.mod h1:fExiIzXORe0ktwg2bWasOAmCZtEFMQkR4PpgzhHcZSA= -github.com/nobl9/nobl9-go v0.129.1 h1:qJSbFwEuKBwOM+fALWQDOuUkWuLuCGA41w+vg6MXxVY= -github.com/nobl9/nobl9-go v0.129.1/go.mod h1:UoFmEtBEI7tQL2Fnh+vXKtG5o0XMs3YJL41SlRxfuSY= +github.com/nobl9/nobl9-go v0.133.0-rc1 h1:sNLLfL7ag445KctymKzDP2MpHuyvMeGDeKZwXqJtvts= +github.com/nobl9/nobl9-go v0.133.0-rc1/go.mod h1:XUE+S6kcSkelfyXIuXxi6ep50LefQqVUUsn/Sqb5/2s= github.com/nobl9/nobl9-openslo v0.1.6 h1:0VJscyj0va+DMYKhzKskI1w5Q9ZvqnowlvaHqjtCJP0= github.com/nobl9/nobl9-openslo v0.1.6/go.mod h1:A5GxukjS31tsIp0Raseh9eyEcxCZWsqi9drWW6aAEg0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +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= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/schollz/progressbar/v3 v3.19.0 h1:Ea18xuIRQXLAUidVDox3AbwfUhD0/1IvohyTutOIFoc= github.com/schollz/progressbar/v3 v3.19.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec= @@ -158,6 +195,8 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8= @@ -183,19 +222,20 @@ 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.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= -golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +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/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/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +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.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= @@ -204,8 +244,11 @@ 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/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= diff --git a/internal/get.go b/internal/get.go index b3f1ac81..dfb369b2 100644 --- a/internal/get.go +++ b/internal/get.go @@ -16,6 +16,7 @@ import ( v1alphaAnnotation "github.com/nobl9/nobl9-go/manifest/v1alpha/annotation" "github.com/nobl9/nobl9-go/sdk" objectsV1 "github.com/nobl9/nobl9-go/sdk/endpoints/objects/v1" + usersV2 "github.com/nobl9/nobl9-go/sdk/endpoints/users/v2" "github.com/spf13/cobra" "golang.org/x/sync/errgroup" @@ -94,6 +95,7 @@ To get more details in output use one of the available flags.`, `List the requested object(s) across all projects.`) cmd.AddCommand(sc) } + cmd.AddCommand(get.newGetUserCommand()) return cmd } @@ -107,8 +109,13 @@ func (g *GetCmd) newGetObjectsCommand( Use: use, Aliases: aliases, Short: short, + Long: "Resource names can be provided as positional arguments or read from stdin.", RunE: func(cmd *cobra.Command, args []string) error { - objects, err := g.getObjects(cmd.Context(), kind, args) + names, err := readStdinArgs(cmd, args) + if err != nil { + return err + } + objects, err := g.getObjects(cmd.Context(), kind, names) if err != nil { return err } @@ -117,6 +124,37 @@ func (g *GetCmd) newGetObjectsCommand( } } +func (g *GetCmd) newGetUserCommand() *cobra.Command { + limit := uint(100) + cmd := &cobra.Command{ + Use: "user", + Short: "Displays users by ID.", + Long: "Provide user IDs as arguments, when no user ID is provided, all users are returned.\n" + + "User IDs can also be read from stdin.\n" + + fmt.Sprintf("By default a maximum of %d users are returned when no IDs are provided.", limit), + RunE: func(cmd *cobra.Command, args []string) error { + ids, err := readStdinArgs(cmd, args) + if err != nil { + return err + } + request := usersV2.GetUsersRequest{IDs: ids} + if len(ids) == 0 || cmd.Flags().Changed("limit") { + request.Limit = limit + } + users, err := g.client.Users().V2().GetUsers( + cmd.Context(), + request, + ) + if err != nil { + return err + } + return g.printUsers(users) + }, + } + cmd.Flags().UintVar(&limit, "limit", limit, "Maximum number of users to return.") + return cmd +} + // nolint: gocognit func (g *GetCmd) newGetAlertCommand(cmd *cobra.Command) *cobra.Command { cmd.Example = getAlertExample @@ -191,8 +229,12 @@ func (g *GetCmd) newGetAlertCommand(cmd *cobra.Command) *cobra.Command { cmd.Flags().Lookup("objective-value").Hidden = true cmd.RunE = func(cmd *cobra.Command, args []string) error { - if len(args) > 0 { - params.Names = args + names, err := readStdinArgs(cmd, args) + if err != nil { + return err + } + if len(names) > 0 { + params.Names = names } params.ObjectiveValues = objectiveValuesFlag @@ -251,7 +293,11 @@ func (g *GetCmd) newGetAgentCommand(cmd *cobra.Command) *cobra.Command { `Displays client_secret and client_id.`) cmd.RunE = func(cmd *cobra.Command, args []string) error { - objects, err := g.getObjects(cmd.Context(), manifest.KindAgent, args) + names, err := readStdinArgs(cmd, args) + if err != nil { + return err + } + objects, err := g.getObjects(cmd.Context(), manifest.KindAgent, names) if err != nil { return err } @@ -445,6 +491,14 @@ func (g *GetCmd) printObjects(kind manifest.Kind, objects []manifest.Object) err return g.printer.Print(objects) } +func (g *GetCmd) printUsers(users []usersV2.User) error { + if len(users) == 0 { + fmt.Printf("No resources found.\n") + return nil + } + return g.printer.Print(users) +} + func parseFilterLabel(filterLabels []string) string { labels := make(v1alpha.Labels) for _, filterLabel := range filterLabels { diff --git a/internal/huhform/form.go b/internal/huhform/form.go index 24915190..372527e3 100644 --- a/internal/huhform/form.go +++ b/internal/huhform/form.go @@ -14,9 +14,15 @@ import ( // 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(style.HuhTheme)). + WithTheme(theme). WithAccessible(getAccessibleEnvValue()) } diff --git a/internal/notifications/install.go b/internal/notifications/install.go new file mode 100644 index 00000000..1a8a586b --- /dev/null +++ b/internal/notifications/install.go @@ -0,0 +1,98 @@ +package notifications + +import ( + "os" + "os/exec" + "path/filepath" + "strings" +) + +type installChannel string + +const ( + installChannelScript installChannel = "script" + installChannelHomebrew installChannel = "homebrew" + installChannelGo installChannel = "go-install" +) + +func (n notifier) updateCommand() string { + switch n.installChannel() { + case installChannelHomebrew: + return "brew upgrade sloctl" + case installChannelGo: + return "go install github.com/nobl9/sloctl/cmd/sloctl@latest" + case installChannelScript: + return n.scriptUpdateCommand() + default: + return "" + } +} + +func (n notifier) installChannel() installChannel { + executablePath, err := os.Executable() + if err != nil { + return installChannelScript + } + resolvedPath, err := filepath.EvalSymlinks(executablePath) + if err != nil { + resolvedPath = executablePath + } + if isHomebrewExecutable(resolvedPath) { + return installChannelHomebrew + } + if isGoInstallExecutable(resolvedPath) { + return installChannelGo + } + return installChannelScript +} + +func (n notifier) scriptUpdateCommand() string { + const scriptURL = "https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash" + if _, err := exec.LookPath("curl"); err == nil { + return "curl -fsSL " + scriptURL + " | bash" + } + if _, err := exec.LookPath("wget"); err == nil { + return "wget -O - -q " + scriptURL + " | bash" + } + return "" +} + +func isHomebrewExecutable(path string) bool { + return strings.Contains(filepath.ToSlash(path), "/Cellar/sloctl/") +} + +func isGoInstallExecutable(path string) bool { + path = filepath.Clean(path) + for _, binDir := range goBinDirs() { + if path == filepath.Clean(filepath.Join(binDir, "sloctl")) || + path == filepath.Clean(filepath.Join(binDir, "sloctl.exe")) { + return true + } + } + return false +} + +func goBinDirs() []string { + if goBin := strings.TrimSpace(os.Getenv("GOBIN")); goBin != "" { + return []string{goBin} + } + goPath := strings.TrimSpace(os.Getenv("GOPATH")) + if goPath == "" { + homeDir := strings.TrimSpace(os.Getenv("HOME")) + if homeDir == "" { + homeDir = strings.TrimSpace(os.Getenv("USERPROFILE")) + } + if homeDir == "" { + return nil + } + goPath = filepath.Join(homeDir, "go") + } + goPaths := filepath.SplitList(goPath) + binDirs := make([]string, 0, len(goPaths)) + for _, path := range goPaths { + if path != "" { + binDirs = append(binDirs, filepath.Join(path, "bin")) + } + } + return binDirs +} diff --git a/internal/notifications/notifications.go b/internal/notifications/notifications.go index d5ed4797..b0659dac 100644 --- a/internal/notifications/notifications.go +++ b/internal/notifications/notifications.go @@ -1,4 +1,4 @@ -// Package notifications handles unobtrusive CLI notifications. +// Package notifications checks for new sloctl releases and prompts for updates. package notifications import ( @@ -10,18 +10,10 @@ import ( "os" "os/exec" "path/filepath" - "regexp" - "strconv" "strings" "time" - huh "charm.land/huh/v2" - "github.com/charmbracelet/glamour" "github.com/mattn/go-isatty" - "golang.org/x/term" - - "github.com/nobl9/sloctl/internal/huhform" - "github.com/nobl9/sloctl/internal/style" ) const ( @@ -32,38 +24,8 @@ const ( checkInterval = 24 * time.Hour checkTimeout = 750 * time.Millisecond maxResponseSize = 1 << 20 - defaultBoxWidth = 92 - minBoxWidth = 48 -) - -var ( - releaseMetadataPattern = regexp.MustCompile(`\s+\(#\d+\)(?:\s+@\S+)?$`) - releasePRPattern = regexp.MustCompile(`#(\d+)`) ) -// Config defines notification runtime dependencies. -type Config struct { - CurrentVersion string - Stdin io.Reader - Stdout io.Writer - Stderr io.Writer - - ReleaseURL string - HTTPClient *http.Client - CachePath string - - Now func() time.Time - Getenv func(string) string - IsTTY func() bool - Lookup func(string) (string, error) - - TerminalWidth func() int - RenderMarkdown func(string, int) (string, error) - Executable func() (string, error) - EvalSymlinks func(string) (string, error) - RunCommand func(context.Context, string) error -} - // Result describes what the caller should do after checking notifications. type Result int @@ -74,35 +36,22 @@ const ( ) // Notify checks and displays an interactive update prompt when configured to do so. -func Notify(config Config) Result { - ctx, cancel := context.WithTimeout(context.Background(), checkTimeout) - defer cancel() - return newNotifier(config).notify(ctx) +func Notify(currentVersion string) Result { + return newNotifier(currentVersion).notify() } type notifier struct { currentVersion string - stdin io.Reader - stdout io.Writer - stderr io.Writer + stdin *os.File + stdout *os.File + stderr *os.File releaseURL string - httpClient *http.Client cachePath string - now func() time.Time - getenv func(string) string - isTTY func() bool - lookup func(string) (string, error) - terminalWidth func() int - renderMarkdown func(string, int) (string, error) - executable func() (string, error) - evalSymlinks func(string) (string, error) - runCommand func(context.Context, string) error } type state struct { LastCheckedAt time.Time `json:"lastCheckedAt"` LastShownReleaseTag string `json:"lastShownReleaseTag"` - LastShownFeatureID string `json:"lastShownFeatureID"` } type githubRelease struct { @@ -111,148 +60,32 @@ type githubRelease struct { HTMLURL string `json:"html_url"` } -type feature struct { - ID string - Title string -} - -type installChannel string - -const ( - installChannelScript installChannel = "script" - installChannelHomebrew installChannel = "homebrew" - installChannelGo installChannel = "go-install" -) - -type updateAction string - -const ( - updateActionRunUpgrade updateAction = "run-upgrade" - updateActionSkip updateAction = "skip" - updateActionSkipUntilNextVersion updateAction = "skip-until-next-version" -) - -func newNotifier(config Config) notifier { - stdin := io.Reader(os.Stdin) - if config.Stdin != nil { - stdin = config.Stdin - } - stdout := io.Writer(os.Stdout) - if config.Stdout != nil { - stdout = config.Stdout - } - stderr := io.Writer(os.Stderr) - if config.Stderr != nil { - stderr = config.Stderr - } - getenv := config.Getenv - if getenv == nil { - getenv = os.Getenv - } - releaseURL := strings.TrimSpace(config.ReleaseURL) - if releaseURL == "" { - releaseURL = strings.TrimSpace(getenv(releaseURLEnv)) - } +func newNotifier(currentVersion string) notifier { + releaseURL := strings.TrimSpace(os.Getenv(releaseURLEnv)) if releaseURL == "" { releaseURL = defaultReleaseURL } - httpClient := config.HTTPClient - if httpClient == nil { - httpClient = &http.Client{Timeout: checkTimeout} - } - cachePath := config.CachePath - if cachePath == "" { - cachePath = defaultCachePath() - } - now := config.Now - if now == nil { - now = time.Now - } - isTTY := config.IsTTY - if isTTY == nil { - isTTY = func() bool { - stderrFile, ok := stderr.(*os.File) - if !ok { - return false - } - return isatty.IsTerminal(stderrFile.Fd()) - } - } - terminalWidth := config.TerminalWidth - if terminalWidth == nil { - terminalWidth = func() int { - stderrFile, ok := stderr.(*os.File) - if !ok { - return defaultBoxWidth - } - fd, err := strconv.Atoi(strconv.FormatUint(uint64(stderrFile.Fd()), 10)) - if err != nil { - return widthFromColumnsEnv(getenv) - } - width, _, err := term.GetSize(fd) - if err != nil { - return widthFromColumnsEnv(getenv) - } - return width - } - } - renderMarkdown := config.RenderMarkdown - if renderMarkdown == nil { - renderMarkdown = renderMarkdownWithGlamour - } - lookup := config.Lookup - if lookup == nil { - lookup = exec.LookPath - } - executable := config.Executable - if executable == nil { - executable = os.Executable - } - evalSymlinks := config.EvalSymlinks - if evalSymlinks == nil { - evalSymlinks = filepath.EvalSymlinks - } - runCommand := config.RunCommand - if runCommand == nil { - runCommand = func(ctx context.Context, command string) error { - cmd := exec.CommandContext(ctx, "sh", "-c", command) - cmd.Stdin = stdin - cmd.Stdout = stdout - cmd.Stderr = stderr - return cmd.Run() - } - } return notifier{ - currentVersion: strings.TrimSpace(config.CurrentVersion), - stdin: stdin, - stdout: stdout, - stderr: stderr, + currentVersion: strings.TrimSpace(currentVersion), + stdin: os.Stdin, + stdout: os.Stdout, + stderr: os.Stderr, releaseURL: releaseURL, - httpClient: httpClient, - cachePath: cachePath, - now: now, - getenv: getenv, - isTTY: isTTY, - lookup: lookup, - terminalWidth: terminalWidth, - renderMarkdown: renderMarkdown, - executable: executable, - evalSymlinks: evalSymlinks, - runCommand: runCommand, + cachePath: defaultCachePath(), } } -func (n notifier) notify(ctx context.Context) Result { +func (n notifier) notify() Result { if !n.canNotify() { return ResultContinue } currentState := n.readState() - now := n.now() + now := time.Now() if !currentState.LastCheckedAt.IsZero() && now.Sub(currentState.LastCheckedAt) < checkInterval { return ResultContinue } - release, err := n.fetchLatestRelease(ctx) + release, err := n.fetchLatestReleaseWithTimeout() currentState.LastCheckedAt = now if err != nil { n.saveState(currentState) @@ -262,18 +95,12 @@ func (n notifier) notify(ctx context.Context) Result { n.saveState(currentState) return ResultContinue } - releaseNotesMarkdown, _ := releaseNotesSection(release.Body) - var releaseNoteID string - if note, ok := firstReleaseNote(releaseNotesMarkdown); ok { - releaseNoteID = note.ID - } else { - releaseNotesMarkdown = "" - } - if alreadyShown(currentState, release.TagName, releaseNoteID) { + if currentState.LastShownReleaseTag == release.TagName { n.saveState(currentState) return ResultContinue } + releaseNotesMarkdown := extractReleaseNotesMarkdown(release.Body) updateCommand := n.updateCommand() action, err := n.promptUpdate(release, releaseNotesMarkdown, updateCommand) if err != nil { @@ -282,7 +109,6 @@ func (n notifier) notify(ctx context.Context) Result { } if action == updateActionSkipUntilNextVersion { currentState.LastShownReleaseTag = release.TagName - currentState.LastShownFeatureID = releaseNoteID } n.saveState(currentState) if action != updateActionRunUpgrade { @@ -291,21 +117,37 @@ func (n notifier) notify(ctx context.Context) Result { if updateCommand == "" { return ResultContinue } - if err := n.runCommand(context.Background(), updateCommand); err != nil { + 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 n.isTTY() && + return isatty.IsTerminal(n.stdin.Fd()) && + isatty.IsTerminal(n.stderr.Fd()) && n.cachePath != "" && - n.getenv(ciEnv) == "" && - n.getenv(optOutEnv) == "" && + os.Getenv(ciEnv) == "" && + os.Getenv(optOutEnv) == "" && !isDevelopmentVersion(n.currentVersion) } +func (n notifier) runCommand(command string) error { + //nolint:gosec // The command is assembled from fixed sloctl update templates. + cmd := exec.Command("sh", "-c", command) + cmd.Stdin = n.stdin + cmd.Stdout = n.stdout + cmd.Stderr = n.stderr + return cmd.Run() +} + func (n notifier) fetchLatestRelease(ctx context.Context) (githubRelease, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, n.releaseURL, nil) if err != nil { @@ -314,7 +156,7 @@ func (n notifier) fetchLatestRelease(ctx context.Context) (githubRelease, error) req.Header.Set("Accept", "application/vnd.github+json") req.Header.Set("User-Agent", "sloctl") - resp, err := n.httpClient.Do(req) + resp, err := http.DefaultClient.Do(req) if err != nil { return githubRelease{}, err } @@ -364,294 +206,6 @@ func defaultCachePath() string { return filepath.Join(cacheDir, "nobl9", "sloctl", "notifications.json") } -func (n notifier) promptUpdate( - release githubRelease, - releaseNotesMarkdown string, - updateCommand string, -) (updateAction, error) { - _, _ = fmt.Fprintln(n.stderr, n.renderNotification(release, releaseNotesMarkdown)) - action := updateActionSkip - form := huhform.New(huh.NewGroup( - huh.NewSelect[updateAction](). - Title("Choose update action"). - Options(updateActionOptions(updateCommand)...). - Value(&action), - )). - WithInput(n.stdin). - WithOutput(n.stderr) - return action, form.Run() -} - -func updateActionOptions(updateCommand string) []huh.Option[updateAction] { - options := []huh.Option[updateAction]{ - huh.NewOption("Skip", updateActionSkip), - huh.NewOption("Skip until next version", updateActionSkipUntilNextVersion), - } - if updateCommand == "" { - return options - } - return append( - []huh.Option[updateAction]{ - huh.NewOption(fmt.Sprintf("Update (runs %s)", updateCommand), updateActionRunUpgrade), - }, - options..., - ) -} - -func (n notifier) renderNotification(release githubRelease, releaseNotesMarkdown string) string { - width := notificationWidth(n.terminalWidth()) - parts := []string{ - notificationTitleMarkdown(releaseNotesMarkdown, release.TagName), - } - if releaseNotesMarkdown != "" { - parts = append(parts, strings.TrimSpace(releaseNotesMarkdown)) - } - parts = append(parts, fmt.Sprintf("📜 %s", release.HTMLURL)) - markdown := strings.Join(parts, "\n\n") - - rendered := styledPlainNotification(release, releaseNotesMarkdown) - if releaseNotesMarkdown != "" { - var err error - rendered, err = n.renderMarkdown(markdown, width) - if err != nil { - rendered = styledPlainNotification(release, releaseNotesMarkdown) - } - rendered = trimTrailingLineSpace(rendered) - } - return strings.TrimSpace(rendered) -} - -func renderMarkdownWithGlamour(markdown string, width int) (string, error) { - styleName := "dark" - if os.Getenv("NO_COLOR") != "" { - styleName = "ascii" - } - renderer, err := glamour.NewTermRenderer( - glamour.WithStandardStyle(styleName), - glamour.WithWordWrap(width), - glamour.WithPreservedNewLines(), - ) - if err != nil { - return "", err - } - return renderer.Render(markdown) -} - -func styledPlainNotification(release githubRelease, releaseNotesMarkdown string) string { - titleStyle := style.NotificationTitle() - linkStyle := style.NotificationLink() - labelStyle := style.NotificationLabel() - - parts := []string{ - titleStyle.Render(notificationTitle(releaseNotesMarkdown, release.TagName)), - } - if releaseNotesMarkdown != "" { - parts = append(parts, stripMarkdownHeading(releaseNotesMarkdown)) - } - 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 notificationTitleMarkdown(releaseNotesMarkdown, releaseTag string) string { - if releaseNotesMarkdown == "" { - return notificationTitle(releaseNotesMarkdown, releaseTag) - } - return "### " + notificationTitle(releaseNotesMarkdown, releaseTag) -} - -func notificationTitle(releaseNotesMarkdown, releaseTag string) string { - if releaseNotesMarkdown == "" { - return fmt.Sprintf("New sloctl version %s is available!", releaseTag) - } - return fmt.Sprintf("New sloctl updates in %s!", releaseTag) -} - -func (n notifier) updateCommand() string { - switch n.installChannel() { - case installChannelHomebrew: - return "brew upgrade sloctl" - case installChannelGo: - return "go install github.com/nobl9/sloctl/cmd/sloctl@latest" - case installChannelScript: - return n.scriptUpdateCommand() - default: - return "" - } -} - -func (n notifier) installChannel() installChannel { - executablePath, err := n.executable() - if err != nil { - return installChannelScript - } - resolvedPath, err := n.evalSymlinks(executablePath) - if err != nil { - resolvedPath = executablePath - } - if isHomebrewExecutable(resolvedPath) { - return installChannelHomebrew - } - if isGoInstallExecutable(resolvedPath, n.getenv) { - return installChannelGo - } - return installChannelScript -} - -func (n notifier) scriptUpdateCommand() string { - const scriptURL = "https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash" - if _, err := n.lookup("curl"); err == nil { - return "curl -fsSL " + scriptURL + " | bash" - } - if _, err := n.lookup("wget"); err == nil { - return "wget -O - -q " + scriptURL + " | bash" - } - return "" -} - -func isHomebrewExecutable(path string) bool { - return strings.Contains(filepath.ToSlash(path), "/Cellar/sloctl/") -} - -func isGoInstallExecutable(path string, getenv func(string) string) bool { - path = filepath.Clean(path) - for _, binDir := range goBinDirs(getenv) { - if samePath(path, filepath.Join(binDir, "sloctl")) || - samePath(path, filepath.Join(binDir, "sloctl.exe")) { - return true - } - } - return false -} - -func goBinDirs(getenv func(string) string) []string { - if goBin := strings.TrimSpace(getenv("GOBIN")); goBin != "" { - return []string{goBin} - } - goPath := strings.TrimSpace(getenv("GOPATH")) - if goPath == "" { - homeDir := strings.TrimSpace(getenv("HOME")) - if homeDir == "" { - homeDir = strings.TrimSpace(getenv("USERPROFILE")) - } - if homeDir == "" { - return nil - } - goPath = filepath.Join(homeDir, "go") - } - var binDirs []string - for _, path := range filepath.SplitList(goPath) { - if path != "" { - binDirs = append(binDirs, filepath.Join(path, "bin")) - } - } - return binDirs -} - -func samePath(a, b string) bool { - return filepath.Clean(a) == filepath.Clean(b) -} - -func notificationWidth(terminalWidth int) int { - if terminalWidth <= 0 { - return defaultBoxWidth - } - return min(max(terminalWidth-2, minBoxWidth), defaultBoxWidth) -} - -func widthFromColumnsEnv(getenv func(string) string) int { - width, err := strconv.Atoi(getenv("COLUMNS")) - if err != nil { - return defaultBoxWidth - } - return width -} - -func releaseNotesSection(body string) (string, bool) { - var section []string - inReleaseNotesSection := false - for _, line := range strings.Split(body, "\n") { - trimmed := strings.TrimSpace(line) - if isLevel2Heading(trimmed) { - if inReleaseNotesSection { - if _, ok := firstReleaseNote(strings.Join(section, "\n")); ok { - break - } - } - inReleaseNotesSection = isReleaseNotesHeading(trimmed) - section = nil - } - if inReleaseNotesSection { - section = append(section, line) - } - } - markdown := strings.TrimSpace(strings.Join(section, "\n")) - if _, ok := firstReleaseNote(markdown); !ok { - return "", false - } - return markdown, true -} - -func firstReleaseNote(body string) (feature, bool) { - for _, line := range strings.Split(body, "\n") { - trimmed := strings.TrimSpace(line) - if !strings.HasPrefix(trimmed, "- ") { - continue - } - return parseFeature(trimmed[2:]), true - } - return feature{}, false -} - -func stripMarkdownHeading(markdown string) string { - lines := strings.Split(markdown, "\n") - if len(lines) == 0 { - return markdown - } - if strings.HasPrefix(strings.TrimSpace(lines[0]), "##") { - return strings.TrimSpace(strings.Join(lines[1:], "\n")) - } - return strings.TrimSpace(markdown) -} - -func isLevel2Heading(line string) bool { - return strings.HasPrefix(line, "## ") || line == "##" -} - -func isReleaseNotesHeading(line string) bool { - heading := strings.ToLower(line) - return strings.Contains(heading, "features") || strings.Contains(heading, "fixes") -} - -func parseFeature(raw string) feature { - title := strings.TrimSpace(raw) - title = strings.TrimPrefix(title, "feat:") - title = strings.TrimPrefix(title, "Feat:") - title = strings.TrimPrefix(title, "fix:") - title = strings.TrimPrefix(title, "Fix:") - title = strings.TrimSpace(title) - id := title - if matches := releasePRPattern.FindStringSubmatch(raw); len(matches) == 2 { - id = matches[1] - } - title = releaseMetadataPattern.ReplaceAllString(title, "") - return feature{ - ID: id, - Title: strings.TrimSpace(title), - } -} - -func alreadyShown(currentState state, releaseTag, featureID string) bool { - return currentState.LastShownReleaseTag == releaseTag && currentState.LastShownFeatureID == featureID -} - func isDevelopmentVersion(version string) bool { version = strings.TrimSpace(version) return version == "" || diff --git a/internal/notifications/prompt.go b/internal/notifications/prompt.go new file mode 100644 index 00000000..5e4ee6b2 --- /dev/null +++ b/internal/notifications/prompt.go @@ -0,0 +1,205 @@ +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 +) + +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, + updateCommand string, +) (updateAction, error) { + terminal := n.terminalInfo() + n.printNotification(release, releaseNotesMarkdown, terminal) + + action := defaultUpdateAction(updateCommand) + 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(updateCommand)...). + Value(&action), + ), + ). + WithInput(n.stdin). + WithOutput(n.stderr) + return action, form.Run() +} + +func (n notifier) printNotification( + release githubRelease, + releaseNotesMarkdown string, + terminal terminalInfo, +) { + _, _ = fmt.Fprintln(n.stderr, renderNotification(release, releaseNotesMarkdown, terminal.width, terminal.dark)) + _, _ = 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] { + if updateCommand == "" { + return []huh.Option[updateAction]{ + huh.NewOption("Skip", updateActionSkip), + huh.NewOption("Skip until next version", updateActionSkipUntilNextVersion), + } + } + 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 defaultUpdateAction(updateCommand string) updateAction { + if updateCommand == "" { + return updateActionSkip + } + return updateActionRunUpgrade +} + +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..6ac539d1 --- /dev/null +++ b/internal/notifications/release_notes.go @@ -0,0 +1,123 @@ +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 { + heading := strings.ToLower(line) + return strings.Contains(heading, "features") || + strings.Contains(heading, "fixes") || + strings.Contains(heading, "breaking") +} + +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:", + } { + if strings.HasPrefix(lowerTitle, prefix) { + return strings.TrimSpace(title[len(prefix):]) + } + } + return title +} diff --git a/internal/root.go b/internal/root.go index c1db940c..f93fe310 100644 --- a/internal/root.go +++ b/internal/root.go @@ -17,33 +17,21 @@ import ( 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 checks for updates and then runs the requested command. +// When the user chooses to update sloctl, it runs the update command and exits +// without running the requested command. func Execute() { - if execute(NewRootCmd(), func() notifications.Result { - return notifications.Notify(notifications.Config{ - CurrentVersion: getBuildVersion(), - Stdin: os.Stdin, - Stdout: os.Stdout, - Stderr: os.Stderr, - }) - }) != 0 { - os.Exit(1) - } -} - -func execute(cmd *cobra.Command, notify func() notifications.Result) int { - switch notify() { + switch notifications.Notify(getBuildVersion()) { case notifications.ResultExitSuccess: - return 0 + return case notifications.ResultExitFailure: - return 1 + os.Exit(1) case notifications.ResultContinue: } - if err := cmd.Execute(); err != nil { - return 1 + + if err := NewRootCmd().Execute(); err != nil { + os.Exit(1) } - return 0 } type globalFlags struct { diff --git a/internal/stdin.go b/internal/stdin.go new file mode 100644 index 00000000..ac623f8a --- /dev/null +++ b/internal/stdin.go @@ -0,0 +1,34 @@ +package internal + +import ( + "fmt" + "io" + "os" + "strings" + + "github.com/spf13/cobra" +) + +// readStdinArgs reads STDIN and appends it to the list of arguments. +// It's useful when we want to support piping. +func readStdinArgs(cmd *cobra.Command, args []string) ([]string, error) { + input := cmd.InOrStdin() + if file, ok := input.(*os.File); ok { + stat, err := file.Stat() + if err != nil { + return nil, fmt.Errorf("stat stdin: %w", err) + } + if stat.Mode()&os.ModeCharDevice != 0 { + return args, nil + } + } + + data, err := io.ReadAll(input) + if err != nil { + return nil, fmt.Errorf("read stdin: %w", err) + } + names := make([]string, 0, len(args)) + names = append(names, args...) + names = append(names, strings.Fields(string(data))...) + return names, nil +} diff --git a/internal/style/theme.go b/internal/style/theme.go index 3a4a9a16..9412f5dd 100644 --- a/internal/style/theme.go +++ b/internal/style/theme.go @@ -2,99 +2,157 @@ 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 ( - tealHex = "#00819E" - cyanHex = "#00D5FF" + blue900Hex = "#01465C" + blue800Hex = "#00819E" + blue600Hex = "#00BAD3" + blue200Hex = "#E5F9FD" + gray850Hex = "#676868" mutedHex = "#BABBBB" lightGrayHex = "#E8E9E9" darkGrayHex = "#383939" + blackHex = "#000000" whiteHex = "#FFFFFF" - greenHex = "#0EB46E" - yellowHex = "#C8D655" pinkHex = "#DB2779" redHex = "#D42E56" noColorEnv = "NO_COLOR" ) var ( - // Teal is the primary sloctl terminal accent color. - Teal = lipgloss.Color(tealHex) - // Cyan is the secondary sloctl terminal accent color. - Cyan = lipgloss.Color(cyanHex) - // Muted is used for secondary terminal text. - Muted = lipgloss.Color(mutedHex) - // LightGray is used for emphasized neutral terminal text. - LightGray = lipgloss.Color(lightGrayHex) - // DarkGray is used for low-emphasis backgrounds. - DarkGray = lipgloss.Color(darkGrayHex) - // White is used for primary terminal text. - White = lipgloss.Color(whiteHex) - // Green is used for successful or selected terminal states. - Green = lipgloss.Color(greenHex) - // Yellow is used for active terminal indicators. - Yellow = lipgloss.Color(yellowHex) - // Pink is used for focused terminal actions. - Pink = lipgloss.Color(pinkHex) - // Red is used for terminal error states. - Red = lipgloss.Color(redHex) + 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 noColor() { + if !ColorEnabled() { return plainHuhTheme(t) } - optionText := White - if !isDark { - optionText = DarkGray - } - - t.Focused.Base = t.Focused.Base.BorderForeground(Teal) + 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(Cyan) - t.Focused.NoteTitle = t.Focused.NoteTitle.Foreground(Cyan) - t.Focused.Directory = t.Focused.Directory.Foreground(Cyan) - t.Focused.Description = t.Focused.Description.Foreground(Muted) - t.Focused.ErrorIndicator = t.Focused.ErrorIndicator.Foreground(Red) - t.Focused.ErrorMessage = t.Focused.ErrorMessage.Foreground(Red) - t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(Teal) - t.Focused.NextIndicator = t.Focused.NextIndicator.Foreground(Teal) - t.Focused.PrevIndicator = t.Focused.PrevIndicator.Foreground(Teal) - t.Focused.Option = t.Focused.Option.Foreground(optionText) - t.Focused.MultiSelectSelector = t.Focused.MultiSelectSelector.Foreground(Teal) - t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(Green) - t.Focused.SelectedPrefix = t.Focused.SelectedPrefix.Foreground(Green) - t.Focused.UnselectedOption = t.Focused.UnselectedOption.Foreground(optionText) - t.Focused.FocusedButton = t.Focused.FocusedButton.Foreground(White).Background(Pink) - t.Focused.BlurredButton = t.Focused.BlurredButton.Foreground(White).Background(DarkGray) - - t.Focused.TextInput.Cursor.Foreground(Pink) - t.Focused.TextInput.Placeholder.Foreground(Muted) - t.Focused.TextInput.Prompt.Foreground(Teal) - - t.Help.ShortKey = t.Help.ShortKey.Foreground(Teal) - t.Help.FullKey = t.Help.FullKey.Foreground(Teal) - t.Help.ShortDesc = t.Help.ShortDesc.Foreground(Muted) - t.Help.FullDesc = t.Help.FullDesc.Foreground(Muted) - t.Help.ShortSeparator = t.Help.ShortSeparator.Foreground(Muted) - t.Help.FullSeparator = t.Help.FullSeparator.Foreground(Muted) + 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(Muted) - t.Blurred.Title = t.Blurred.NoteTitle.Foreground(Muted) + 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(Muted) - t.Blurred.TextInput.Text = t.Blurred.TextInput.Text.Foreground(optionText) + 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() @@ -106,31 +164,91 @@ func HuhTheme(isDark bool) *huh.Styles { } // NotificationTitle returns the shared style for notification titles. -func NotificationTitle() lipgloss.Style { - if noColor() { +func NotificationTitle(isDark bool) lipgloss.Style { + if !ColorEnabled() { return lipgloss.NewStyle() } - return lipgloss.NewStyle().Foreground(White).Bold(true) + return lipgloss.NewStyle().Foreground(terminalPaletteFor(isDark).strong()).Bold(true) } // NotificationLink returns the shared style for notification links. -func NotificationLink() lipgloss.Style { - if noColor() { +func NotificationLink(isDark bool) lipgloss.Style { + if !ColorEnabled() { return lipgloss.NewStyle() } - return lipgloss.NewStyle().Foreground(Cyan).Underline(true) + return lipgloss.NewStyle().Foreground(terminalPaletteFor(isDark).accent()).Underline(true) } // NotificationLabel returns the shared style for notification labels. -func NotificationLabel() lipgloss.Style { - if noColor() { +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(Muted) + 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 noColor() bool { - return os.Getenv(noColorEnv) != "" +func setMarkdownHeading(heading *ansi.StyleBlock, colorHex string) { + heading.Color = new(colorHex) + heading.Bold = new(true) } func plainHuhTheme(t *huh.Styles) *huh.Styles { diff --git a/package.json b/package.json index 305a4334..c4ae509d 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,8 @@ "private": true, "type": "module", "devDependencies": { - "cspell": "9.8.0", - "markdownlint-cli": "0.48.0" + "cspell": "10.0.1", + "markdownlint-cli": "0.49.0" }, "scripts": { "check-trailing-whitespaces": "node ./scripts/check-trailing-whitespaces.js", diff --git a/test/get.bats b/test/get.bats index 9f6dd405..f5e47410 100644 --- a/test/get.bats +++ b/test/get.bats @@ -210,8 +210,8 @@ setup() { run_sloctl get agent -p "death-star" "$flag" assert_success_joined_output # Assert length of client_id and regex of client_secret, as the latter may vary. - client_id="$(yq -r .[].metadata.client_id <<<"$output")" - client_secret="$(yq -r .[].metadata.client_secret <<<"$output")" + client_id="$(yq -r .[].metadata.client_id <<< "$output")" + client_secret="$(yq -r .[].metadata.client_secret <<< "$output")" # Assert that client_id length is either 16 or 20 assert [ "${#client_id}" -eq 16 ] || [ "${#client_id}" -eq 20 ] @@ -222,11 +222,43 @@ setup() { done } +@test "user by ID" { + run_sloctl config current-user + assert_success_joined_output + user_id="$output" + + run_sloctl get user "$user_id" -o json + assert_success_joined_output + assert_equal "$(jq -r .[0].userId <<< "$output")" "$user_id" +} + +@test "users" { + run_sloctl get user -o json + assert_success_joined_output + assert [ "$(jq length <<< "$output")" -gt 1 ] +} + +@test "users with limit" { + run_sloctl get user --limit 1 -o json + assert_success_joined_output + assert [ "$(jq length <<< "$output")" -eq 1 ] +} + @test "projects, multiple names" { run_sloctl get project death-star hoth-base verify_get_success "$output" "$(read_files "${TEST_INPUTS}/projects.yaml")" } +@test "projects, names from stdin and positional args" { + run --separate-stderr bash -c "set -eo pipefail; printf '%s\n' death-star | sloctl get project hoth-base" + verify_get_success "$output" "$(read_files "${TEST_INPUTS}/projects.yaml")" +} + +@test "projects, names from stdin only" { + run --separate-stderr bash -c "set -eo pipefail; printf '%s\n' death-star hoth-base | sloctl get project" + verify_get_success "$output" "$(read_files "${TEST_INPUTS}/projects.yaml")" +} + @test "projects, labels filtering, OR conditions" { want=$(read_files "${TEST_INPUTS}/projects.yaml") for label in \ @@ -273,7 +305,7 @@ setup() { run_sloctl get alertpolicy -p death-star trigger-alert-immediately assert_success_joined_output assert_equal \ - "$(yq --sort-keys -y -r . <<<"$output")" \ + "$(yq --sort-keys -y -r . <<< "$output")" \ "$(yq --sort-keys -y -r . "${TEST_OUTPUTS}/alertpolicy.yaml")" } @@ -281,7 +313,7 @@ setup() { run_sloctl get direct -p death-star splunk-direct assert_success_joined_output assert_equal \ - "$(yq --sort-keys -y -r . <<<"$output")" \ + "$(yq --sort-keys -y -r . <<< "$output")" \ "$(yq --sort-keys -y -r . "${TEST_OUTPUTS}/direct.yaml")" } @@ -310,7 +342,7 @@ test_get() { input="$3" \ output="$4" local aliases - IFS=" " read -ra aliases <<<"$2" + IFS=" " read -ra aliases <<< "$2" aliases+=("$kind") for alias in "${aliases[@]}"; do @@ -337,7 +369,7 @@ test_get() { # need to filter out only the ones we created. if [[ "$kind" == "RoleBinding" ]]; then verify_get_success \ - "$(yq '[.[] | select(.spec.roleRef == "project-viewer")]' <<<"$output")" \ + "$(yq '[.[] | select(.spec.roleRef == "project-viewer")]' <<< "$output")" \ "$(read_files "$input")" else verify_get_success "$output" "$(read_files "$input")" @@ -378,6 +410,6 @@ verify_get_success() { # We can't retrieve the same object we applied so we need to compare the minimum. filter='[.[] | {"name": .metadata.name, "project": .metadata.project, "labels": .metadata.labels, "annotations": .metadata.annotations}] | sort_by(.name, .project)' assert_equal \ - "$(yq --sort-keys -y -r "$filter" <<<"$have")" \ - "$(yq --sort-keys -y -r "$filter" <<<"$want")" + "$(yq --sort-keys -y -r "$filter" <<< "$have")" \ + "$(yq --sort-keys -y -r "$filter" <<< "$want")" } 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_server.py b/test/inputs/notifications/release_server.py index 5d24819b..9b0e8efb 100644 --- a/test/inputs/notifications/release_server.py +++ b/test/inputs/notifications/release_server.py @@ -2,9 +2,9 @@ import json import os -import socketserver import sys from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path @@ -12,32 +12,16 @@ DEFAULT_RELEASE_BODY_FILE = Path(__file__).with_name("release-bodies") / "feature.md" -class ReleaseHandler(socketserver.BaseRequestHandler): - def handle(self): - try: - request = read_headers(self.request) - except OSError: - return - - lines = request.splitlines() - if not lines: - return - - try: - method, path, _ = lines[0].split(" ", 2) - except ValueError: - self.request.sendall(b"HTTP/1.1 400 Bad Request\r\n\r\n") - return - - headers = parse_headers(lines[1:]) - log_request(method, path, headers) +class ReleaseHandler(BaseHTTPRequestHandler): + def do_GET(self): + log_request(self.command, self.path, self.headers) if ( - method != "GET" - or path != RELEASE_PATH - or headers.get("accept") != "application/vnd.github+json" - or headers.get("user-agent") != "sloctl" + self.path != RELEASE_PATH + or self.headers.get("Accept") != "application/vnd.github+json" + or self.headers.get("User-Agent") != "sloctl" ): - self.request.sendall(b"HTTP/1.1 502 Bad Gateway\r\n\r\n") + self.send_response(HTTPStatus.BAD_GATEWAY) + self.end_headers() return status = int(os.environ.get("RELEASE_SERVER_STATUS", "200")) @@ -54,51 +38,24 @@ def handle(self): } ) body = raw_body.encode() - response = ( - f"HTTP/1.1 {status} {reason_phrase(status)}\r\n".encode() - + b"Content-Type: application/json\r\n" - + f"Content-Length: {len(body)}\r\n".encode() - + b"Connection: close\r\n" - + b"\r\n" - + body - ) - self.request.sendall(response) - - -class ThreadingTCPServer(socketserver.ThreadingTCPServer): - allow_reuse_address = True + 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 read_headers(sock): - data = b"" - while b"\r\n\r\n" not in data: - chunk = sock.recv(4096) - if not chunk: - raise OSError("connection closed while reading headers") - data += chunk - return data.decode("iso-8859-1") + def log_message(self, format, *args): + pass def release_body(): body_file = os.environ.get("RELEASE_SERVER_BODY_FILE") if body_file: return Path(body_file).read_text(encoding="utf-8") - body = os.environ.get("RELEASE_SERVER_BODY") - if body is not None: - return body return DEFAULT_RELEASE_BODY_FILE.read_text(encoding="utf-8") -def parse_headers(lines): - headers = {} - for line in lines: - if not line: - continue - name, value = line.split(":", 1) - headers[name.lower()] = value.strip() - return headers - - def log_request(method, path, headers): log_path = os.environ.get("RELEASE_SERVER_LOG") if not log_path: @@ -109,8 +66,8 @@ def log_request(method, path, headers): { "method": method, "path": path, - "accept": headers.get("accept"), - "userAgent": headers.get("user-agent"), + "accept": headers.get("Accept"), + "userAgent": headers.get("User-Agent"), }, sort_keys=True, ) @@ -127,7 +84,7 @@ def reason_phrase(status): def main(): port_file = Path(sys.argv[1]) - with ThreadingTCPServer(("127.0.0.1", 0), ReleaseHandler) as server: + with ThreadingHTTPServer(("127.0.0.1", 0), ReleaseHandler) as server: port_file.write_text(str(server.server_address[1]), encoding="utf-8") server.serve_forever() diff --git a/test/inputs/notifications/run_with_stderr_pty.py b/test/inputs/notifications/run_with_stderr_pty.py index 55ecb853..810acfc9 100644 --- a/test/inputs/notifications/run_with_stderr_pty.py +++ b/test/inputs/notifications/run_with_stderr_pty.py @@ -4,11 +4,11 @@ import fcntl import os import pty -import struct -import termios import selectors +import struct import subprocess import sys +import termios def main(): @@ -24,10 +24,13 @@ def main(): termios.TIOCSWINSZ, struct.pack("HHHH", 24, int(columns), 0, 0), ) - stdin = subprocess.DEVNULL input_text = os.environ.get("SLOCTL_TEST_TTY_INPUT") + stdin = subprocess.DEVNULL if input_text is not None: - stdin = subprocess.PIPE + attrs = termios.tcgetattr(slave_fd) + attrs[3] &= ~termios.ECHO + termios.tcsetattr(slave_fd, termios.TCSANOW, attrs) + stdin = slave_fd process = subprocess.Popen( sys.argv[1:], @@ -37,8 +40,7 @@ def main(): close_fds=True, ) if input_text is not None: - process.stdin.write(input_text.encode()) - process.stdin.close() + os.write(master_fd, input_text.encode()) os.close(slave_fd) selector = selectors.DefaultSelector() diff --git a/test/notifications.bats b/test/notifications.bats index c33c73f0..2373fec8 100644 --- a/test/notifications.bats +++ b/test/notifications.bats @@ -2,6 +2,10 @@ # bats file_tags=unit setup_file() { + load "test_helper/load" + + ensure_installed python3 + export TEST_INPUTS="$BATS_TEST_DIRNAME/inputs/notifications" export TEST_OUTPUTS="$BATS_TEST_DIRNAME/outputs/notifications" } @@ -11,8 +15,6 @@ setup() { load_lib "bats-support" load_lib "bats-assert" - ensure_installed python3 - unset CI unset ALL_PROXY unset HTTPS_PROXY @@ -27,7 +29,6 @@ setup() { unset SLOCTL_NOTIFICATIONS_RELEASE_URL unset SLOCTL_TEST_TTY_COLUMNS unset SLOCTL_TEST_TTY_INPUT - unset RELEASE_SERVER_BODY unset RELEASE_SERVER_BODY_FILE unset RELEASE_SERVER_HTML_URL unset RELEASE_SERVER_RAW_RESPONSE @@ -38,14 +39,12 @@ setup() { export SLOCTL_ACCESSIBLE_MODE=1 export XDG_CACHE_HOME="$BATS_TMPDIR/cache-$BATS_TEST_NUMBER" export RELEASE_SERVER_LOG="$BATS_TMPDIR/release-server-$BATS_TEST_NUMBER.log" + RELEASE_SERVER_START_COUNT=0 select_update_action skip } teardown() { - if [ -n "${RELEASE_SERVER_PID:-}" ]; then - kill "$RELEASE_SERVER_PID" - wait "$RELEASE_SERVER_PID" 2> /dev/null || true - fi + stop_release_server } @test "sloctl shows a feature notification on TTY stderr and caches it" { @@ -58,7 +57,7 @@ teardown() { run_sloctl_with_tty_stderr version assert_success_joined_output - refute_stderr + assert_stderr "" assert_release_requests 1 } @@ -82,13 +81,23 @@ teardown() { run_sloctl_with_tty_stderr version assert_success_joined_output - refute_stderr + assert_stderr "" assert_release_requests 1 + + expire_notification_cache + stop_release_server + use_release_body feature-without-author + start_release_server + + run_sloctl_with_tty_stderr version + assert_success_joined_output + assert_stderr "" + assert_release_requests 2 } -@test "sloctl runs upgrade and exits without running the command" { +@test "sloctl defaults to update action and exits without running the command" { use_release_body maintenance - select_update_action run-upgrade + select_default_update_action export SLOCTL_TEST_UPGRADE_MARKER="$BATS_TEST_TMPDIR/upgrade-ran" local tools_dir="$BATS_TEST_TMPDIR/tools" mkdir -p "$tools_dir" @@ -113,7 +122,7 @@ teardown() { run_sloctl_with_tty_stderr version assert_success_joined_output - refute_stderr + assert_stderr "" assert_release_requests 0 } @@ -123,7 +132,7 @@ teardown() { run_sloctl_with_tty_stderr version assert_success_joined_output - refute_stderr + assert_stderr "" assert_release_requests 0 } @@ -132,7 +141,7 @@ teardown() { run_sloctl version assert_success_joined_output - refute_stderr + assert_stderr "" assert_release_requests 0 } @@ -146,7 +155,7 @@ teardown() { assert_release_requests 1 } -@test "sloctl uses the first non-empty release notes section" { +@test "sloctl skips empty release notes sections" { use_release_body empty-features-then-bug-fixes start_release_server @@ -156,7 +165,17 @@ teardown() { assert_release_requests 1 } -@test "sloctl keeps nested release-note details from the selected section" { +@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 keeps nested details and additional release-note sections" { use_release_body features-with-details start_release_server @@ -183,7 +202,7 @@ teardown() { run_sloctl_with_tty_stderr version assert_success_joined_output - refute_stderr + assert_stderr "" assert_release_requests 1 } @@ -194,11 +213,11 @@ teardown() { run_sloctl_with_tty_stderr version assert_success_joined_output - refute_stderr + assert_stderr "" run_sloctl_with_tty_stderr version assert_success_joined_output - refute_stderr + assert_stderr "" assert_release_requests 1 } @@ -208,7 +227,7 @@ teardown() { run_sloctl_with_tty_stderr version assert_success_joined_output - refute_stderr + assert_stderr "" assert_release_requests 1 } @@ -292,8 +311,8 @@ assert_notification_stderr() { local name="$1" local expected expected="$(normalize_tty_output < "$TEST_OUTPUTS/$name.stderr")" - output="$(normalize_tty_output <<< "$stderr")" - assert_output "$expected" + stderr="$(normalize_tty_output <<< "$stderr")" + assert_stderr "$expected" } normalize_tty_output() { @@ -336,6 +355,10 @@ select_update_action_without_update() { esac } +select_default_update_action() { + export SLOCTL_TEST_TTY_INPUT=$'\n' +} + run_sloctl_with_tty_stderr() { run_sloctl_binary_with_tty_stderr sloctl "$@" } @@ -371,7 +394,8 @@ copy_sloctl_binary() { } start_release_server() { - local port_file="$BATS_TMPDIR/release-server-$BATS_TEST_NUMBER.port" + RELEASE_SERVER_START_COUNT=$((RELEASE_SERVER_START_COUNT + 1)) + local port_file="$BATS_TMPDIR/release-server-$BATS_TEST_NUMBER-$RELEASE_SERVER_START_COUNT.port" python3 "$TEST_INPUTS/release_server.py" "$port_file" & RELEASE_SERVER_PID="$!" @@ -388,6 +412,19 @@ start_release_server() { fail "release server did not start" } +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() { + local cache_file="$XDG_CACHE_HOME/nobl9/sloctl/notifications.json" + sed -i 's/"lastCheckedAt": "[^"]*"/"lastCheckedAt": "2000-01-01T00:00:00Z"/' "$cache_file" +} + assert_release_requests() { local expected="$1" local actual=0 diff --git a/test/outputs/notifications/breaking-prompt-skip.stderr b/test/outputs/notifications/breaking-prompt-skip.stderr new file mode 100644 index 00000000..fe7f5e58 --- /dev/null +++ b/test/outputs/notifications/breaking-prompt-skip.stderr @@ -0,0 +1,15 @@ +# Changes in version v1.1.0 + +## Breaking Changes + +• Drop deprecated v1 config + +📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 + +──────────────────────────────────────────────────────────────────────────────────────────── + +Choose update action +1. Update (runs wget -O - -q https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash) +2. Skip +3. Skip until next version +Enter a number between 1 and 3: diff --git a/test/outputs/notifications/bug-fix-prompt-skip.stderr b/test/outputs/notifications/bug-fix-prompt-skip.stderr index a5d923bc..6bf9e3dc 100644 --- a/test/outputs/notifications/bug-fix-prompt-skip.stderr +++ b/test/outputs/notifications/bug-fix-prompt-skip.stderr @@ -1,10 +1,13 @@ -### New sloctl updates in v1.1.0! +# Changes in version v1.1.0 - ## Bug Fixes +## Bug Fixes - • Fix output formatting (#125) @octocat +• Fix output formatting + +📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 + +──────────────────────────────────────────────────────────────────────────────────────────── - 📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 Choose update action 1. Update (runs wget -O - -q https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash) 2. Skip diff --git a/test/outputs/notifications/failed-command-after-skip.stderr b/test/outputs/notifications/failed-command-after-skip.stderr index fa42c33f..fbf2ec5f 100644 --- a/test/outputs/notifications/failed-command-after-skip.stderr +++ b/test/outputs/notifications/failed-command-after-skip.stderr @@ -1,10 +1,13 @@ -### New sloctl updates in v1.1.0! +# Changes in version v1.1.0 - ## Features +## Features - • feat: Add notification tests (#321) @octocat +• Add notification tests + +📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 + +──────────────────────────────────────────────────────────────────────────────────────────── - 📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 Choose update action 1. Update (runs wget -O - -q https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash) 2. Skip diff --git a/test/outputs/notifications/feature-prompt-skip-until-next-version.stderr b/test/outputs/notifications/feature-prompt-skip-until-next-version.stderr index 24bdd8c6..3e134983 100644 --- a/test/outputs/notifications/feature-prompt-skip-until-next-version.stderr +++ b/test/outputs/notifications/feature-prompt-skip-until-next-version.stderr @@ -1,10 +1,13 @@ -### New sloctl updates in v1.1.0! +# Changes in version v1.1.0 - ## Features +## Features - • feat: Add notification tests (#321) @octocat +• Add notification tests + +📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 + +──────────────────────────────────────────────────────────────────────────────────────────── - 📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 Choose update action 1. Update (runs wget -O - -q https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash) 2. Skip diff --git a/test/outputs/notifications/feature-prompt-skip.stderr b/test/outputs/notifications/feature-prompt-skip.stderr index 24bdd8c6..3e134983 100644 --- a/test/outputs/notifications/feature-prompt-skip.stderr +++ b/test/outputs/notifications/feature-prompt-skip.stderr @@ -1,10 +1,13 @@ -### New sloctl updates in v1.1.0! +# Changes in version v1.1.0 - ## Features +## Features - • feat: Add notification tests (#321) @octocat +• Add notification tests + +📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 + +──────────────────────────────────────────────────────────────────────────────────────────── - 📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 Choose update action 1. Update (runs wget -O - -q https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash) 2. Skip diff --git a/test/outputs/notifications/feature-without-author-prompt-skip.stderr b/test/outputs/notifications/feature-without-author-prompt-skip.stderr index b6efc120..c3ac1e71 100644 --- a/test/outputs/notifications/feature-without-author-prompt-skip.stderr +++ b/test/outputs/notifications/feature-without-author-prompt-skip.stderr @@ -1,10 +1,13 @@ -### New sloctl updates in v1.1.0! +# Changes in version v1.1.0 - ## Features +## Features - • Add direct upload (#333) +• Add direct upload + +📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 + +──────────────────────────────────────────────────────────────────────────────────────────── - 📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 Choose update action 1. Update (runs wget -O - -q https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash) 2. Skip diff --git a/test/outputs/notifications/features-with-details-prompt-skip.stderr b/test/outputs/notifications/features-with-details-prompt-skip.stderr index 328480e0..3f4b31a6 100644 --- a/test/outputs/notifications/features-with-details-prompt-skip.stderr +++ b/test/outputs/notifications/features-with-details-prompt-skip.stderr @@ -1,16 +1,23 @@ -### New sloctl updates in v1.1.0! +# Changes in version v1.1.0 - ## 🚀 Features +## 🚀 Features - • Add workflow insights (#123) @octocat - | Extra release-note detail. +• Add workflow insights +| Extra release-note detail. - ### Details +### Details - • Preserves nested feature details. +• Preserves nested feature details. + +## 🐞 Bug Fixes + +• Fix output formatting + +📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 + +──────────────────────────────────────────────────────────────────────────────────────────── - 📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 Choose update action 1. Update (runs wget -O - -q https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash) 2. Skip diff --git a/test/outputs/notifications/install-curl-wide-prompt.stderr b/test/outputs/notifications/install-curl-wide-prompt.stderr index 3c7ac6e3..dff74c3d 100644 --- a/test/outputs/notifications/install-curl-wide-prompt.stderr +++ b/test/outputs/notifications/install-curl-wide-prompt.stderr @@ -1,6 +1,9 @@ New sloctl version v1.1.0 is available! 📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 + +──────────────────────────────────────────────────────────────────────────────────────────── + Choose update action 1. Update (runs curl -fsSL https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash) 2. Skip diff --git a/test/outputs/notifications/install-go-prompt.stderr b/test/outputs/notifications/install-go-prompt.stderr index a7413363..1a8b8499 100644 --- a/test/outputs/notifications/install-go-prompt.stderr +++ b/test/outputs/notifications/install-go-prompt.stderr @@ -1,6 +1,9 @@ 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 diff --git a/test/outputs/notifications/install-homebrew-prompt.stderr b/test/outputs/notifications/install-homebrew-prompt.stderr index 91aaedff..314a73ae 100644 --- a/test/outputs/notifications/install-homebrew-prompt.stderr +++ b/test/outputs/notifications/install-homebrew-prompt.stderr @@ -1,6 +1,9 @@ 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 diff --git a/test/outputs/notifications/install-wget-prompt.stderr b/test/outputs/notifications/install-wget-prompt.stderr index 07ff3720..529130b6 100644 --- a/test/outputs/notifications/install-wget-prompt.stderr +++ b/test/outputs/notifications/install-wget-prompt.stderr @@ -1,6 +1,9 @@ New sloctl version v1.1.0 is available! 📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 + +──────────────────────────────────────────────────────────────────────────────────────────── + Choose update action 1. Update (runs wget -O - -q https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash) 2. Skip diff --git a/test/outputs/notifications/no-install-command-prompt.stderr b/test/outputs/notifications/no-install-command-prompt.stderr index eafad01f..910bf23e 100644 --- a/test/outputs/notifications/no-install-command-prompt.stderr +++ b/test/outputs/notifications/no-install-command-prompt.stderr @@ -1,6 +1,9 @@ New sloctl version v1.1.0 is available! 📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 + +──────────────────────────────────────────────────────────────────────────────────────────── + Choose update action 1. Skip 2. Skip until next version diff --git a/test/outputs/notifications/version-prompt-run-upgrade.stderr b/test/outputs/notifications/version-prompt-run-upgrade.stderr index 3c7ac6e3..dff74c3d 100644 --- a/test/outputs/notifications/version-prompt-run-upgrade.stderr +++ b/test/outputs/notifications/version-prompt-run-upgrade.stderr @@ -1,6 +1,9 @@ New sloctl version v1.1.0 is available! 📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 + +──────────────────────────────────────────────────────────────────────────────────────────── + Choose update action 1. Update (runs curl -fsSL https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash) 2. Skip diff --git a/test/outputs/notifications/version-prompt-skip.stderr b/test/outputs/notifications/version-prompt-skip.stderr index 07ff3720..529130b6 100644 --- a/test/outputs/notifications/version-prompt-skip.stderr +++ b/test/outputs/notifications/version-prompt-skip.stderr @@ -1,6 +1,9 @@ New sloctl version v1.1.0 is available! 📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 + +──────────────────────────────────────────────────────────────────────────────────────────── + Choose update action 1. Update (runs wget -O - -q https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash) 2. Skip diff --git a/test/test_helper/load.bash b/test/test_helper/load.bash index 5e1ff558..ecab5f82 100644 --- a/test/test_helper/load.bash +++ b/test/test_helper/load.bash @@ -251,8 +251,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,22 +283,16 @@ $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 "$@" } -refute_stderr() { - output="$stderr" - refute_output "$@" -} - # run_mcp_inspector # ========== # From a050dc6d3d031aa1eb3957967131970701ff5aee Mon Sep 17 00:00:00 2001 From: Mateusz Hawrus Date: Wed, 1 Jul 2026 13:59:00 +0200 Subject: [PATCH 07/20] docs: add update prompt screenshots --- docs/assets/sloctl-update-prompt-dark.svg | 170 +++++++++++++++++++++ docs/assets/sloctl-update-prompt-light.svg | 170 +++++++++++++++++++++ 2 files changed, 340 insertions(+) create mode 100644 docs/assets/sloctl-update-prompt-dark.svg create mode 100644 docs/assets/sloctl-update-prompt-light.svg diff --git a/docs/assets/sloctl-update-prompt-dark.svg b/docs/assets/sloctl-update-prompt-dark.svg new file mode 100644 index 00000000..7e8834d6 --- /dev/null +++ b/docs/assets/sloctl-update-prompt-dark.svg @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + sloctl-update-prompt-dark.svg + + + + + + + + + + Changes in version v0.24.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/v0.24.0 + +──────────────────────────────────────────────────────────────────────────────────────────── + + +Choose update action +Update (runs curl -fsSL https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash) +Skip +Skip until next version + +up • down • /filter • entersubmit + + + + diff --git a/docs/assets/sloctl-update-prompt-light.svg b/docs/assets/sloctl-update-prompt-light.svg new file mode 100644 index 00000000..7194a645 --- /dev/null +++ b/docs/assets/sloctl-update-prompt-light.svg @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + sloctl-update-prompt-light.svg + + + + + + + + + + Changes in version v0.24.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/v0.24.0 + +──────────────────────────────────────────────────────────────────────────────────────────── + + +Choose update action +Update (runs curl -fsSL https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash) +Skip +Skip until next version + +up • down • /filter • entersubmit + + + + From 44c2cd2bc4b82e6500256f2d72b44f11a454c2eb Mon Sep 17 00:00:00 2001 From: Mateusz Hawrus Date: Wed, 1 Jul 2026 18:18:44 +0200 Subject: [PATCH 08/20] docs: remove update prompt screenshots --- docs/assets/sloctl-update-prompt-dark.svg | 170 --------------------- docs/assets/sloctl-update-prompt-light.svg | 170 --------------------- 2 files changed, 340 deletions(-) delete mode 100644 docs/assets/sloctl-update-prompt-dark.svg delete mode 100644 docs/assets/sloctl-update-prompt-light.svg diff --git a/docs/assets/sloctl-update-prompt-dark.svg b/docs/assets/sloctl-update-prompt-dark.svg deleted file mode 100644 index 7e8834d6..00000000 --- a/docs/assets/sloctl-update-prompt-dark.svg +++ /dev/null @@ -1,170 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - sloctl-update-prompt-dark.svg - - - - - - - - - - Changes in version v0.24.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/v0.24.0 - -──────────────────────────────────────────────────────────────────────────────────────────── - - -Choose update action -Update (runs curl -fsSL https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash) -Skip -Skip until next version - -up • down • /filter • entersubmit - - - - diff --git a/docs/assets/sloctl-update-prompt-light.svg b/docs/assets/sloctl-update-prompt-light.svg deleted file mode 100644 index 7194a645..00000000 --- a/docs/assets/sloctl-update-prompt-light.svg +++ /dev/null @@ -1,170 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - sloctl-update-prompt-light.svg - - - - - - - - - - Changes in version v0.24.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/v0.24.0 - -──────────────────────────────────────────────────────────────────────────────────────────── - - -Choose update action -Update (runs curl -fsSL https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash) -Skip -Skip until next version - -up • down • /filter • entersubmit - - - - From 4e806ae675b398c1842575196b9ce9407e4ef27f Mon Sep 17 00:00:00 2001 From: Mateusz Hawrus Date: Tue, 14 Jul 2026 15:18:33 +0200 Subject: [PATCH 09/20] fix: skip update prompt form on unsupported Windows shells Detect MinGW and Cygwin before showing the interactive update form. Fall back to notification-only behavior for unsupported Windows shells. --- internal/notifications/notifications.go | 25 +++++- internal/notifications/notifications_test.go | 92 ++++++++++++++++++++ internal/notifications/prompt.go | 4 + 3 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 internal/notifications/notifications_test.go diff --git a/internal/notifications/notifications.go b/internal/notifications/notifications.go index b0659dac..1116123e 100644 --- a/internal/notifications/notifications.go +++ b/internal/notifications/notifications.go @@ -10,6 +10,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" "time" @@ -102,7 +103,12 @@ func (n notifier) notify() Result { releaseNotesMarkdown := extractReleaseNotesMarkdown(release.Body) updateCommand := n.updateCommand() - action, err := n.promptUpdate(release, releaseNotesMarkdown, updateCommand) + action, err := n.promptUpdate( + release, + releaseNotesMarkdown, + updateCommand, + isUpdateFormSupported(runtime.GOOS, systemName), + ) if err != nil { n.saveState(currentState) return ResultContinue @@ -139,6 +145,23 @@ func (n notifier) canNotify() bool { !isDevelopmentVersion(n.currentVersion) } +func isUpdateFormSupported(goOS string, readSystemName func() (string, error)) bool { + if goOS != "windows" { + return true + } + systemName, err := readSystemName() + if err != nil { + return false + } + systemName = strings.ToLower(strings.TrimSpace(systemName)) + return strings.HasPrefix(systemName, "mingw") || strings.HasPrefix(systemName, "cygwin") +} + +func systemName() (string, error) { + output, err := exec.Command("uname").Output() + return string(output), err +} + func (n notifier) runCommand(command string) error { //nolint:gosec // The command is assembled from fixed sloctl update templates. cmd := exec.Command("sh", "-c", command) diff --git a/internal/notifications/notifications_test.go b/internal/notifications/notifications_test.go new file mode 100644 index 00000000..f3ea7997 --- /dev/null +++ b/internal/notifications/notifications_test.go @@ -0,0 +1,92 @@ +package notifications + +import ( + "errors" + "io" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNotifier_promptUpdate_WithoutForm(t *testing.T) { + t.Parallel() + 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", + }, + "", + "sloctl update", + false, + ) + 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) + assert.Contains(t, string(output), "New sloctl version v1.2.3 is available!") + assert.Contains(t, string(output), "https://github.com/nobl9/sloctl/releases/tag/v1.2.3") + assert.NotContains(t, string(output), "Choose update action") +} + +func Test_isUpdateFormSupported(t *testing.T) { + t.Parallel() + tests := map[string]struct { + goOS string + systemName string + systemNameErr error + expectedIsSupported bool + }{ + "Linux": { + goOS: "linux", + systemNameErr: errors.New("uname unavailable"), + expectedIsSupported: true, + }, + "Windows MinGW": { + goOS: "windows", + systemName: "MINGW64_NT-10.0-26100\n", + expectedIsSupported: true, + }, + "Windows Cygwin": { + goOS: "windows", + systemName: "CYGWIN_NT-10.0-26100\n", + expectedIsSupported: true, + }, + "Windows MSYS": { + goOS: "windows", + systemName: "MSYS_NT-10.0-26100\n", + expectedIsSupported: false, + }, + "Windows native shell": { + goOS: "windows", + systemName: "Windows_NT\n", + expectedIsSupported: false, + }, + "Windows without uname": { + goOS: "windows", + systemNameErr: errors.New("uname unavailable"), + expectedIsSupported: false, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + readSystemName := func() (string, error) { + return tt.systemName, tt.systemNameErr + } + assert.Equal(t, tt.expectedIsSupported, isUpdateFormSupported(tt.goOS, readSystemName)) + }) + } +} diff --git a/internal/notifications/prompt.go b/internal/notifications/prompt.go index 5e4ee6b2..b3548d32 100644 --- a/internal/notifications/prompt.go +++ b/internal/notifications/prompt.go @@ -37,9 +37,13 @@ func (n notifier) promptUpdate( release githubRelease, releaseNotesMarkdown string, updateCommand string, + showUpdateForm bool, ) (updateAction, error) { terminal := n.terminalInfo() n.printNotification(release, releaseNotesMarkdown, terminal) + if !showUpdateForm { + return updateActionSkip, nil + } action := defaultUpdateAction(updateCommand) form := huhform.NewWithTheme( From 1335fb3f60e435c6b28799025bcff5f7732aac44 Mon Sep 17 00:00:00 2001 From: Mateusz Hawrus Date: Wed, 15 Jul 2026 12:42:52 +0200 Subject: [PATCH 10/20] fix: support update notifications across Windows terminals Recognize Cygwin and MinGW terminals while suppressing the update form in native Windows consoles. Add platform Bats coverage for Linux, macOS Homebrew installs, and Windows. --- .github/workflows/unit-tests.yml | 56 ++++++++++++++ Makefile | 15 +++- cspell.json | 2 + internal/notifications/notifications.go | 9 ++- .../notifications/run_with_stderr_pty.py | 30 +++++--- test/notifications.bats | 75 ++++++++++++++++++- test/setup_platform_suite.bash | 3 + test/test_helper/load.bash | 4 + 8 files changed, 175 insertions(+), 19 deletions(-) create mode 100644 test/setup_platform_suite.bash diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 235a6345..1cad7836 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -19,3 +19,59 @@ 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.0 + - uses: actions/setup-go@v6.5.0 + with: + go-version-file: go.mod + cache: false + - 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 + steps: + - name: Configure Git line endings + run: git config --global core.autocrlf input + - name: Check out code + uses: actions/checkout@v7.0.0 + - uses: actions/setup-go@v6.5.0 + with: + go-version-file: go.mod + cache: false + - 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 winpty + 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 efe5b508..29c23d25 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,7 @@ 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" VERSION ?= 1.0.0-test @@ -63,7 +64,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. @@ -85,7 +86,7 @@ test: test/unit test/e2e ## Run all unit 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 @@ -105,7 +106,15 @@ test/bats/unit: $(call _build_docker,sloctl-unit-test-bin,v1.0.0,PC-123-test,e2602ddc) 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)/* + sloctl-bats-unit -F pretty --filter-tags unit,!platform $(TEST_DIR)/* + +## Run native platform notification tests. +test/bats/platform: + $(MAKE) VERSION=v1.0.0 build + $(call _print_step,Running native platform notification tests) + bats -F pretty \ + --setup-suite-file $(TEST_DIR)/setup_platform_suite.bash \ + --filter-tags platform $(TEST_DIR)/notifications.bats ## Run bats e2e tests. test/bats/e2e: diff --git a/cspell.json b/cspell.json index 57f42059..394b7cfb 100644 --- a/cspell.json +++ b/cspell.json @@ -39,6 +39,7 @@ "dynatrace", "endef", "gobin", + "goexe", "gofmt", "goimports", "gojq", @@ -46,6 +47,7 @@ "gosec", "govulncheck", "ldflags", + "msys", "nobl", "openslo", "openslosdk", diff --git a/internal/notifications/notifications.go b/internal/notifications/notifications.go index 1116123e..d4dc3cb2 100644 --- a/internal/notifications/notifications.go +++ b/internal/notifications/notifications.go @@ -137,14 +137,19 @@ func (n notifier) fetchLatestReleaseWithTimeout() (githubRelease, error) { } func (n notifier) canNotify() bool { - return isatty.IsTerminal(n.stdin.Fd()) && - isatty.IsTerminal(n.stderr.Fd()) && + 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 isUpdateFormSupported(goOS string, readSystemName func() (string, error)) bool { if goOS != "windows" { return true diff --git a/test/inputs/notifications/run_with_stderr_pty.py b/test/inputs/notifications/run_with_stderr_pty.py index 810acfc9..bb857e8f 100644 --- a/test/inputs/notifications/run_with_stderr_pty.py +++ b/test/inputs/notifications/run_with_stderr_pty.py @@ -16,36 +16,42 @@ def main(): print("usage: run_with_stderr_pty.py COMMAND [ARG...]", file=sys.stderr) return 2 - master_fd, slave_fd = pty.openpty() + controller_fd, terminal_fd = pty.openpty() + join_output = os.environ.get("SLOCTL_TEST_TTY_JOIN_OUTPUT") == "1" columns = os.environ.get("SLOCTL_TEST_TTY_COLUMNS") if columns: fcntl.ioctl( - slave_fd, + terminal_fd, termios.TIOCSWINSZ, struct.pack("HHHH", 24, int(columns), 0, 0), ) input_text = os.environ.get("SLOCTL_TEST_TTY_INPUT") - stdin = subprocess.DEVNULL + stdin = terminal_fd if join_output else subprocess.DEVNULL if input_text is not None: - attrs = termios.tcgetattr(slave_fd) + attrs = termios.tcgetattr(terminal_fd) attrs[3] &= ~termios.ECHO - termios.tcsetattr(slave_fd, termios.TCSANOW, attrs) - stdin = slave_fd + termios.tcsetattr(terminal_fd, termios.TCSANOW, attrs) + stdin = terminal_fd process = subprocess.Popen( sys.argv[1:], stdin=stdin, - stdout=subprocess.PIPE, - stderr=slave_fd, + stdout=terminal_fd if join_output else subprocess.PIPE, + stderr=terminal_fd, close_fds=True, ) if input_text is not None: - os.write(master_fd, input_text.encode()) - os.close(slave_fd) + os.write(controller_fd, input_text.encode()) + os.close(terminal_fd) selector = selectors.DefaultSelector() - selector.register(process.stdout, selectors.EVENT_READ, sys.stdout.buffer) - selector.register(master_fd, selectors.EVENT_READ, sys.stderr.buffer) + if not join_output: + selector.register(process.stdout, selectors.EVENT_READ, sys.stdout.buffer) + selector.register( + controller_fd, + selectors.EVENT_READ, + sys.stdout.buffer if join_output else sys.stderr.buffer, + ) while selector.get_map(): for key, _ in selector.select(): diff --git a/test/notifications.bats b/test/notifications.bats index 2373fec8..c5c37a4a 100644 --- a/test/notifications.bats +++ b/test/notifications.bats @@ -29,6 +29,7 @@ setup() { unset SLOCTL_NOTIFICATIONS_RELEASE_URL unset SLOCTL_TEST_TTY_COLUMNS unset SLOCTL_TEST_TTY_INPUT + unset SLOCTL_TEST_TTY_JOIN_OUTPUT unset RELEASE_SERVER_BODY_FILE unset RELEASE_SERVER_HTML_URL unset RELEASE_SERVER_RAW_RESPONSE @@ -38,6 +39,7 @@ setup() { export NO_COLOR=1 export SLOCTL_ACCESSIBLE_MODE=1 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" RELEASE_SERVER_START_COUNT=0 select_update_action skip @@ -155,6 +157,38 @@ teardown() { assert_release_requests 1 } +# bats test_tags=platform +@test "sloctl shows the new version notification and update form on supported terminals" { + use_release_body maintenance + start_release_server + + run_sloctl_with_tty_stderr version + assert_success_joined_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 +@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 tools_dir="$BATS_TEST_TMPDIR/tools-without-uname" + mkdir -p "$tools_dir" + start_release_server + + run_sloctl_binary_in_windows_console_with_path "$(native_sloctl_binary)" "$tools_dir" version + assert_success_joined_output + # winpty combines native console streams, so assertions use its joined terminal output. + assert_output --partial "New sloctl version v1.1.0 is available!" + 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 @@ -256,7 +290,12 @@ teardown() { assert_notification_stderr install-curl-wide-prompt } +# bats test_tags=platform @test "sloctl suggests Homebrew upgrade for Homebrew installs" { + 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" @@ -360,7 +399,11 @@ select_default_update_action() { } run_sloctl_with_tty_stderr() { - run_sloctl_binary_with_tty_stderr sloctl "$@" + 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() { @@ -378,6 +421,17 @@ run_sloctl_binary_with_path() { run --separate-stderr env PATH="$path" /usr/bin/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 + run --separate-stderr env \ + PATH="$path" \ + SLOCTL_TEST_TTY_JOIN_OUTPUT=1 \ + /usr/bin/python3 "$TEST_INPUTS/run_with_stderr_pty.py" /usr/bin/winpty "$binary" "$@" +} + run_sloctl_binary_with_prefixed_path() { local binary="$1" local path="$2" @@ -388,11 +442,28 @@ run_sloctl_binary_with_prefixed_path() { copy_sloctl_binary() { local target="$1" + local source="/usr/bin/sloctl" + if has_bats_tag platform; then + source="$(native_sloctl_binary)" + fi mkdir -p "$(dirname "$target")" - cp /usr/bin/sloctl "$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_TMPDIR/release-server-$BATS_TEST_NUMBER-$RELEASE_SERVER_START_COUNT.port" 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 ecab5f82..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" } From ee3d1090216092521d02eaf5484d49a167ad9280 Mon Sep 17 00:00:00 2001 From: Mateusz Hawrus Date: Mon, 20 Jul 2026 15:55:45 +0200 Subject: [PATCH 11/20] chore: update go.mod --- go.mod | 43 ++++++++++++++++++++++--------------------- go.sum | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 21 deletions(-) diff --git a/go.mod b/go.mod index 15245abf..5cc4e474 100644 --- a/go.mod +++ b/go.mod @@ -11,10 +11,10 @@ require ( 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.22 + 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.0-rc1 + github.com/nobl9/nobl9-go v0.133.1 github.com/nobl9/nobl9-openslo v0.1.6 github.com/pkg/errors v0.9.1 github.com/schollz/progressbar/v3 v3.19.1 @@ -23,13 +23,13 @@ require ( github.com/stretchr/testify v1.11.1 github.com/tidwall/sjson v1.2.5 golang.org/x/sync v0.22.0 - golang.org/x/term v0.44.0 + golang.org/x/term v0.45.0 ) require ( - charm.land/bubbles/v2 v2.0.0 // indirect - charm.land/bubbletea/v2 v2.0.2 // indirect - github.com/alecthomas/chroma/v2 v2.20.0 // 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 @@ -37,19 +37,20 @@ require ( github.com/catppuccin/go v0.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect - github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260720091822-7cc6674724ac // indirect github.com/charmbracelet/x/ansi v0.11.7 // 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-20250327172914-2fdc97757edf // indirect - github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // 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 github.com/charmbracelet/x/windows v0.2.2 // indirect 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 v1.11.5 // indirect + github.com/dlclark/regexp2 v1.12.0 // 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 @@ -64,8 +65,8 @@ 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-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 @@ -75,24 +76,24 @@ require ( 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 + github.com/prometheus/client_golang v1.24.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.5 // indirect + github.com/prometheus/common v0.70.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/teambition/rrule-go v1.8.2 // indirect github.com/tidwall/gjson v1.19.0 // indirect 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.7.13 // 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/net v0.54.0 // indirect - golang.org/x/sys v0.46.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/mod v0.38.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 e9a444f2..42111a4d 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,11 @@ charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s= charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI= +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.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= +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= @@ -16,8 +20,11 @@ github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8v github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= +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.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg= github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= 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= @@ -42,6 +49,8 @@ github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0r github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= +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= @@ -56,8 +65,12 @@ github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sB 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-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= +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.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +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= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= @@ -81,6 +94,10 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= +github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +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= @@ -134,11 +151,17 @@ github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW 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-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.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +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.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +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= @@ -166,6 +189,8 @@ github.com/nobl9/govy v0.26.0 h1:pjHXreO+3Rl+Uz6/rFX7L54zH4LW/SaTjb6YX3GQGs4= github.com/nobl9/govy v0.26.0/go.mod h1:fExiIzXORe0ktwg2bWasOAmCZtEFMQkR4PpgzhHcZSA= github.com/nobl9/nobl9-go v0.133.0-rc1 h1:sNLLfL7ag445KctymKzDP2MpHuyvMeGDeKZwXqJtvts= github.com/nobl9/nobl9-go v0.133.0-rc1/go.mod h1:XUE+S6kcSkelfyXIuXxi6ep50LefQqVUUsn/Sqb5/2s= +github.com/nobl9/nobl9-go v0.133.1 h1:WpzfXSj+ckEGQ5DquMaR1AGxVO/yjg1TeJbaWvypUvY= +github.com/nobl9/nobl9-go v0.133.1/go.mod h1:XUE+S6kcSkelfyXIuXxi6ep50LefQqVUUsn/Sqb5/2s= github.com/nobl9/nobl9-openslo v0.1.6 h1:0VJscyj0va+DMYKhzKskI1w5Q9ZvqnowlvaHqjtCJP0= github.com/nobl9/nobl9-openslo v0.1.6/go.mod h1:A5GxukjS31tsIp0Raseh9eyEcxCZWsqi9drWW6aAEg0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -175,12 +200,17 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_golang v1.24.0 h1:5XStIklKuAtJSNpdD3s8XJj/Yv78IQmE1kbNk87JrAI= +github.com/prometheus/client_golang v1.24.0/go.mod h1:QcsNdotprC2nS4BTM2ucbcqxd2CeXTEa9jW7zHO9iDE= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/common v0.70.0 h1:bcpru3tWPVnxGnETLgOV5jbp/JRXgYEyv65CuBLAMMI= +github.com/prometheus/common v0.70.0/go.mod h1:S/SFasQmgGiYH6C81LKCtYa8QACgthGg5zxL2udV7SY= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= 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= @@ -216,6 +246,8 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +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= @@ -224,28 +256,44 @@ 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/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= 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/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/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= 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/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.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/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.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +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.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +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= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= From 45f0896304a4a628441248d518cc29a0817441eb Mon Sep 17 00:00:00 2001 From: Mateusz Hawrus Date: Mon, 20 Jul 2026 16:29:04 +0200 Subject: [PATCH 12/20] test: strip ANSI codes in notification output assertions --- go.mod | 7 ++- go.sum | 50 +------------------- internal/notifications/notifications_test.go | 8 ++-- 3 files changed, 9 insertions(+), 56 deletions(-) diff --git a/go.mod b/go.mod index 5cc4e474..20eaaccd 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( 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 @@ -38,7 +39,6 @@ require ( github.com/charmbracelet/colorprofile v0.4.3 // 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/ansi v0.11.7 // 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 @@ -49,7 +49,6 @@ 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 v1.12.0 // 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 @@ -76,9 +75,9 @@ require ( 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.24.0 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.70.0 // indirect + github.com/prometheus/common v0.67.5 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/teambition/rrule-go v1.8.2 // indirect github.com/tidwall/gjson v1.19.0 // indirect diff --git a/go.sum b/go.sum index 42111a4d..486447b3 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,5 @@ -charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s= -charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI= 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.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= -charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= 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= @@ -18,13 +14,10 @@ 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.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= -github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= 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.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg= -github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 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= @@ -47,8 +40,6 @@ github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WK 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-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= -github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= 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= @@ -63,12 +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-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= -github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= 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.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= -github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= 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= @@ -92,10 +79,6 @@ 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 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= -github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= -github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 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= @@ -149,17 +132,11 @@ 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-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.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= -github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= 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.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= -github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= 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= @@ -187,8 +164,6 @@ github.com/nobl9/go-yaml v1.0.1 h1:Aj1kSaYdRQTKlvS6ihvXzQJhCpoHhtf9nfA95zqWH4Q= github.com/nobl9/go-yaml v1.0.1/go.mod h1:t7vCO8ctYdBweZxU5lUgxzAw31+ZcqJYeqRtrv+5RHI= github.com/nobl9/govy v0.26.0 h1:pjHXreO+3Rl+Uz6/rFX7L54zH4LW/SaTjb6YX3GQGs4= github.com/nobl9/govy v0.26.0/go.mod h1:fExiIzXORe0ktwg2bWasOAmCZtEFMQkR4PpgzhHcZSA= -github.com/nobl9/nobl9-go v0.133.0-rc1 h1:sNLLfL7ag445KctymKzDP2MpHuyvMeGDeKZwXqJtvts= -github.com/nobl9/nobl9-go v0.133.0-rc1/go.mod h1:XUE+S6kcSkelfyXIuXxi6ep50LefQqVUUsn/Sqb5/2s= github.com/nobl9/nobl9-go v0.133.1 h1:WpzfXSj+ckEGQ5DquMaR1AGxVO/yjg1TeJbaWvypUvY= github.com/nobl9/nobl9-go v0.133.1/go.mod h1:XUE+S6kcSkelfyXIuXxi6ep50LefQqVUUsn/Sqb5/2s= github.com/nobl9/nobl9-openslo v0.1.6 h1:0VJscyj0va+DMYKhzKskI1w5Q9ZvqnowlvaHqjtCJP0= @@ -200,17 +175,12 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= -github.com/prometheus/client_golang v1.24.0 h1:5XStIklKuAtJSNpdD3s8XJj/Yv78IQmE1kbNk87JrAI= -github.com/prometheus/client_golang v1.24.0/go.mod h1:QcsNdotprC2nS4BTM2ucbcqxd2CeXTEa9jW7zHO9iDE= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= -github.com/prometheus/common v0.70.0 h1:bcpru3tWPVnxGnETLgOV5jbp/JRXgYEyv65CuBLAMMI= -github.com/prometheus/common v0.70.0/go.mod h1:S/SFasQmgGiYH6C81LKCtYa8QACgthGg5zxL2udV7SY= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= -github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= 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= @@ -244,8 +214,6 @@ 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.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= -github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 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= @@ -254,46 +222,30 @@ 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/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= -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/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/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= 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/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.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/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.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= 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.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= -golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= 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= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/notifications/notifications_test.go b/internal/notifications/notifications_test.go index f3ea7997..7ace7619 100644 --- a/internal/notifications/notifications_test.go +++ b/internal/notifications/notifications_test.go @@ -6,6 +6,7 @@ import ( "os" "testing" + "github.com/charmbracelet/x/ansi" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -36,9 +37,10 @@ func TestNotifier_promptUpdate_WithoutForm(t *testing.T) { require.NoError(t, err) output, err := io.ReadAll(stderr) require.NoError(t, err) - assert.Contains(t, string(output), "New sloctl version v1.2.3 is available!") - assert.Contains(t, string(output), "https://github.com/nobl9/sloctl/releases/tag/v1.2.3") - assert.NotContains(t, string(output), "Choose update action") + 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.NotContains(t, plainOutput, "Choose update action") } func Test_isUpdateFormSupported(t *testing.T) { From 3e66b8d53da8b921a1b37024b3c506e3ab3d7427 Mon Sep 17 00:00:00 2001 From: Mateusz Hawrus Date: Tue, 21 Jul 2026 10:47:10 +0200 Subject: [PATCH 13/20] ci: fix native platform notification jobs --- .github/workflows/unit-tests.yml | 4 ++++ Makefile | 7 ++++++- test/notifications.bats | 24 ++++++++++++++++-------- 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 57e0a6da..0afca582 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -35,6 +35,9 @@ jobs: 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 @@ -48,6 +51,7 @@ jobs: 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 diff --git a/Makefile b/Makefile index 370f6144..2802962c 100644 --- a/Makefile +++ b/Makefile @@ -112,9 +112,14 @@ test/bats/unit: test/bats/platform: $(MAKE) VERSION=v1.0.0 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; \ bats -F pretty \ --setup-suite-file $(TEST_DIR)/setup_platform_suite.bash \ - --filter-tags platform $(TEST_DIR)/notifications.bats + "$$@" $(TEST_DIR)/notifications.bats ## Run bats e2e tests. test/bats/e2e: diff --git a/test/notifications.bats b/test/notifications.bats index c5c37a4a..6f9961b8 100644 --- a/test/notifications.bats +++ b/test/notifications.bats @@ -157,7 +157,7 @@ teardown() { assert_release_requests 1 } -# bats test_tags=platform +# bats test_tags=platform,platform:unix @test "sloctl shows the new version notification and update form on supported terminals" { use_release_body maintenance start_release_server @@ -170,7 +170,7 @@ teardown() { assert_release_requests 1 } -# bats test_tags=platform +# 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" @@ -290,7 +290,7 @@ teardown() { assert_notification_stderr install-curl-wide-prompt } -# bats test_tags=platform +# bats test_tags=platform,platform:macos @test "sloctl suggests Homebrew upgrade for Homebrew installs" { if [ "$(uname -s)" != "Darwin" ]; then skip "native Homebrew compatibility is tested on macOS" @@ -466,21 +466,29 @@ native_sloctl_binary() { start_release_server() { RELEASE_SERVER_START_COUNT=$((RELEASE_SERVER_START_COUNT + 1)) - local port_file="$BATS_TMPDIR/release-server-$BATS_TEST_NUMBER-$RELEASE_SERVER_START_COUNT.port" - python3 "$TEST_INPUTS/release_server.py" "$port_file" & + 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" 2> "$error_file" & RELEASE_SERVER_PID="$!" - for _ in {1..50}; do - if [ -s "$port_file" ]; then + for _ in {1..300}; do + if [[ -s "$port_file" ]]; then local port port="$(cat "$port_file")" export SLOCTL_NOTIFICATIONS_RELEASE_URL="http://127.0.0.1:$port/repos/nobl9/sloctl/releases/latest" 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" + fail "release server did not start within 30 seconds" } stop_release_server() { From f6b3b02b30a9425630b5ce7e460dd82ae6729ccc Mon Sep 17 00:00:00 2001 From: Mateusz Hawrus Date: Tue, 21 Jul 2026 11:06:57 +0200 Subject: [PATCH 14/20] test: fix native notification platform isolation --- test/notifications.bats | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/notifications.bats b/test/notifications.bats index 6f9961b8..13dd5aa7 100644 --- a/test/notifications.bats +++ b/test/notifications.bats @@ -38,6 +38,7 @@ setup() { 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" @@ -183,7 +184,7 @@ teardown() { run_sloctl_binary_in_windows_console_with_path "$(native_sloctl_binary)" "$tools_dir" version assert_success_joined_output - # winpty combines native console streams, so assertions use its joined terminal output. + # winpty combines native console streams, so assertions use its joined output. assert_output --partial "New sloctl version v1.1.0 is available!" refute_output --partial "Choose update action" assert_release_requests 1 @@ -428,8 +429,7 @@ run_sloctl_binary_in_windows_console_with_path() { bats_require_minimum_version 1.5.0 run --separate-stderr env \ PATH="$path" \ - SLOCTL_TEST_TTY_JOIN_OUTPUT=1 \ - /usr/bin/python3 "$TEST_INPUTS/run_with_stderr_pty.py" /usr/bin/winpty "$binary" "$@" + /usr/bin/winpty -Xallow-non-tty "$binary" "$@" } run_sloctl_binary_with_prefixed_path() { From fd924030058b0f92cf47a852dc898c30f57dc78d Mon Sep 17 00:00:00 2001 From: Mateusz Hawrus Date: Tue, 21 Jul 2026 11:20:42 +0200 Subject: [PATCH 15/20] test: assert joined Windows console output --- test/notifications.bats | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/notifications.bats b/test/notifications.bats index 13dd5aa7..21beeb49 100644 --- a/test/notifications.bats +++ b/test/notifications.bats @@ -184,7 +184,7 @@ teardown() { run_sloctl_binary_in_windows_console_with_path "$(native_sloctl_binary)" "$tools_dir" version assert_success_joined_output - # winpty combines native console streams, so assertions use its joined output. + output+=$'\n'"${stderr}" assert_output --partial "New sloctl version v1.1.0 is available!" refute_output --partial "Choose update action" assert_release_requests 1 From 6951dcaf0d2d9c97a2b8eee0ba52ab9a400fded1 Mon Sep 17 00:00:00 2001 From: Mateusz Hawrus Date: Tue, 21 Jul 2026 11:44:29 +0200 Subject: [PATCH 16/20] test: set Windows PTY dimensions --- test/notifications.bats | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/notifications.bats b/test/notifications.bats index 21beeb49..7a8a740c 100644 --- a/test/notifications.bats +++ b/test/notifications.bats @@ -184,7 +184,6 @@ teardown() { run_sloctl_binary_in_windows_console_with_path "$(native_sloctl_binary)" "$tools_dir" version assert_success_joined_output - output+=$'\n'"${stderr}" assert_output --partial "New sloctl version v1.1.0 is available!" refute_output --partial "Choose update action" assert_release_requests 1 @@ -429,7 +428,9 @@ run_sloctl_binary_in_windows_console_with_path() { bats_require_minimum_version 1.5.0 run --separate-stderr env \ PATH="$path" \ - /usr/bin/winpty -Xallow-non-tty "$binary" "$@" + SLOCTL_TEST_TTY_COLUMNS=80 \ + SLOCTL_TEST_TTY_JOIN_OUTPUT=1 \ + /usr/bin/python3 "$TEST_INPUTS/run_with_stderr_pty.py" /usr/bin/winpty "$binary" "$@" } run_sloctl_binary_with_prefixed_path() { From e17c72562a44349fda23275a04539cf03d96a0d9 Mon Sep 17 00:00:00 2001 From: Mateusz Hawrus Date: Tue, 21 Jul 2026 12:08:23 +0200 Subject: [PATCH 17/20] test: run Windows notification test with ConPTY --- .github/workflows/unit-tests.yml | 7 +++- .../notifications/run_with_windows_pty.py | 35 +++++++++++++++++++ test/notifications.bats | 10 ++++-- 3 files changed, 48 insertions(+), 4 deletions(-) create mode 100644 test/inputs/notifications/run_with_windows_pty.py diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 0afca582..852c3d3a 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -61,6 +61,11 @@ jobs: 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 @@ -69,7 +74,7 @@ jobs: - name: Set up MinGW uses: msys2/setup-msys2@v2 with: - install: make python winpty + install: make python msystem: MINGW64 path-type: inherit update: true 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..1761832e --- /dev/null +++ b/test/inputs/notifications/run_with_windows_pty.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 + +import os +import sys + +from winpty.enums import Backend +from winpty.ptyprocess import PtyProcess + + +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, + ) + chunks = [] + while True: + try: + chunks.append(process.read()) + except EOFError: + break + + output = "".join(chunks).replace("\r\n", "\n").replace("\r", "") + sys.stdout.buffer.write(output.encode("utf-8")) + sys.stdout.buffer.flush() + return process.wait() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/notifications.bats b/test/notifications.bats index 7a8a740c..cbbf4e3e 100644 --- a/test/notifications.bats +++ b/test/notifications.bats @@ -426,11 +426,15 @@ run_sloctl_binary_in_windows_console_with_path() { 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" \ - SLOCTL_TEST_TTY_COLUMNS=80 \ - SLOCTL_TEST_TTY_JOIN_OUTPUT=1 \ - /usr/bin/python3 "$TEST_INPUTS/run_with_stderr_pty.py" /usr/bin/winpty "$binary" "$@" + "$python" "$helper" "$binary" "$@" } run_sloctl_binary_with_prefixed_path() { From eda1e2541798848ad9de285294b57cfef1777ae0 Mon Sep 17 00:00:00 2001 From: Mateusz Hawrus Date: Fri, 24 Jul 2026 15:27:36 +0200 Subject: [PATCH 18/20] fix: make notification release URL build-time only --- Makefile | 23 ++++++++++++++------- internal/notifications/notifications.go | 21 ++++++++----------- test/inputs/notifications/release_server.py | 7 ++++++- test/notifications.bats | 6 +----- 4 files changed, 32 insertions(+), 25 deletions(-) diff --git a/Makefile b/Makefile index 2802962c..9c4cfa37 100644 --- a/Makefile +++ b/Makefile @@ -6,16 +6,24 @@ 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 @@ -54,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 @@ -76,7 +85,7 @@ 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. @@ -103,28 +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 \ + 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 build + $(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; \ - bats -F pretty \ + 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/internal/notifications/notifications.go b/internal/notifications/notifications.go index d4dc3cb2..b387026f 100644 --- a/internal/notifications/notifications.go +++ b/internal/notifications/notifications.go @@ -17,14 +17,15 @@ import ( "github.com/mattn/go-isatty" ) +// latestReleaseURL can be replaced at link time for deterministic notification tests. +var latestReleaseURL = "https://api.github.com/repos/nobl9/sloctl/releases/latest" + const ( - defaultReleaseURL = "https://api.github.com/repos/nobl9/sloctl/releases/latest" - releaseURLEnv = "SLOCTL_NOTIFICATIONS_RELEASE_URL" - optOutEnv = "SLOCTL_NO_NOTIFICATIONS" - ciEnv = "CI" - checkInterval = 24 * time.Hour - checkTimeout = 750 * time.Millisecond - maxResponseSize = 1 << 20 + 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 checking notifications. @@ -62,16 +63,12 @@ type githubRelease struct { } func newNotifier(currentVersion string) notifier { - releaseURL := strings.TrimSpace(os.Getenv(releaseURLEnv)) - if releaseURL == "" { - releaseURL = defaultReleaseURL - } return notifier{ currentVersion: strings.TrimSpace(currentVersion), stdin: os.Stdin, stdout: os.Stdout, stderr: os.Stderr, - releaseURL: releaseURL, + releaseURL: latestReleaseURL, cachePath: defaultCachePath(), } } diff --git a/test/inputs/notifications/release_server.py b/test/inputs/notifications/release_server.py index 9b0e8efb..1afc7913 100644 --- a/test/inputs/notifications/release_server.py +++ b/test/inputs/notifications/release_server.py @@ -49,6 +49,10 @@ 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: @@ -84,7 +88,8 @@ def reason_phrase(status): def main(): port_file = Path(sys.argv[1]) - with ThreadingHTTPServer(("127.0.0.1", 0), ReleaseHandler) as server: + 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() diff --git a/test/notifications.bats b/test/notifications.bats index cbbf4e3e..3271a628 100644 --- a/test/notifications.bats +++ b/test/notifications.bats @@ -26,7 +26,6 @@ setup() { unset http_proxy unset no_proxy unset SLOCTL_NO_NOTIFICATIONS - unset SLOCTL_NOTIFICATIONS_RELEASE_URL unset SLOCTL_TEST_TTY_COLUMNS unset SLOCTL_TEST_TTY_INPUT unset SLOCTL_TEST_TTY_JOIN_OUTPUT @@ -473,14 +472,11 @@ 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" 2> "$error_file" & + 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 - local port - port="$(cat "$port_file")" - export SLOCTL_NOTIFICATIONS_RELEASE_URL="http://127.0.0.1:$port/repos/nobl9/sloctl/releases/latest" return 0 fi if ! kill -0 "$RELEASE_SERVER_PID" 2> /dev/null; then From a8c84d3e9b6ff647f1c06820abbacdd4c591d045 Mon Sep 17 00:00:00 2001 From: Mateusz Hawrus Date: Mon, 27 Jul 2026 15:56:12 +0200 Subject: [PATCH 19/20] fix: harden release notification handling Use semantic version ordering, preserve prompt interruption, and execute detected updater commands safely. Expand release-note and CLI regression coverage, and align test documentation and CI configuration. --- .github/workflows/unit-tests.yml | 8 +- Makefile | 4 +- docs/DEVELOPMENT.md | 15 +- go.mod | 2 +- internal/notifications/install.go | 71 +++++++-- internal/notifications/notifications.go | 67 ++++++--- internal/notifications/notifications_test.go | 139 ++++++++++++++++++ internal/notifications/release_notes.go | 22 ++- internal/root.go | 4 +- .../release-bodies/fixed-vulnerabilities.md | 5 + .../notifications/run_with_stderr_pty.py | 135 ++++++++++------- test/notifications.bats | 102 ++++++++++++- .../fixed-vulnerabilities-prompt-skip.stderr | 15 ++ ...sion-prompt-skip-until-next-version.stderr | 15 ++ .../version-prompt-failed-upgrade.stderr | 12 ++ 15 files changed, 506 insertions(+), 110 deletions(-) create mode 100644 test/inputs/notifications/release-bodies/fixed-vulnerabilities.md create mode 100644 test/outputs/notifications/fixed-vulnerabilities-prompt-skip.stderr create mode 100644 test/outputs/notifications/next-version-prompt-skip-until-next-version.stderr create mode 100644 test/outputs/notifications/version-prompt-failed-upgrade.stderr diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 54b6f24c..c5623176 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -30,8 +30,8 @@ jobs: - macos-latest steps: - name: Check out code - uses: actions/checkout@v7.0.0 - - uses: actions/setup-go@v6.5.0 + uses: actions/checkout@v7.0.1 + - uses: actions/setup-go@v7.0.0 with: go-version-file: go.mod cache: false @@ -56,8 +56,8 @@ jobs: - name: Configure Git line endings run: git config --global core.autocrlf input - name: Check out code - uses: actions/checkout@v7.0.0 - - uses: actions/setup-go@v6.5.0 + uses: actions/checkout@v7.0.1 + - uses: actions/setup-go@v7.0.0 with: go-version-file: go.mod cache: false diff --git a/Makefile b/Makefile index 9c4cfa37..202d6976 100644 --- a/Makefile +++ b/Makefile @@ -88,11 +88,11 @@ docker: $(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/platform test/bats/e2e test/go/e2e-docker diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 5f33185d..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,9 @@ 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 diff --git a/go.mod b/go.mod index 20eaaccd..8dfbef85 100644 --- a/go.mod +++ b/go.mod @@ -23,6 +23,7 @@ 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 ) @@ -88,7 +89,6 @@ require ( github.com/yuin/goldmark-emoji v1.0.6 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect golang.org/x/crypto v0.54.0 // indirect - golang.org/x/mod v0.38.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 diff --git a/internal/notifications/install.go b/internal/notifications/install.go index 1a8a586b..3cd35a15 100644 --- a/internal/notifications/install.go +++ b/internal/notifications/install.go @@ -10,28 +10,47 @@ import ( type installChannel string const ( + installChannelUnknown installChannel = "" installChannelScript installChannel = "script" installChannelHomebrew installChannel = "homebrew" installChannelGo installChannel = "go-install" ) -func (n notifier) updateCommand() string { - switch n.installChannel() { +type updateCommand struct { + display string + executable string + args []string +} + +func (c updateCommand) available() bool { + return c.executable != "" +} + +func detectUpdateCommand() updateCommand { + switch detectInstallChannel() { case installChannelHomebrew: - return "brew upgrade sloctl" + return updateCommand{ + display: "brew upgrade sloctl", + executable: "brew", + args: []string{"upgrade", "sloctl"}, + } case installChannelGo: - return "go install github.com/nobl9/sloctl/cmd/sloctl@latest" + return updateCommand{ + display: "go install github.com/nobl9/sloctl/cmd/sloctl@latest", + executable: "go", + args: []string{"install", "github.com/nobl9/sloctl/cmd/sloctl@latest"}, + } case installChannelScript: - return n.scriptUpdateCommand() + return scriptUpdateCommand() default: - return "" + return updateCommand{} } } -func (n notifier) installChannel() installChannel { +func detectInstallChannel() installChannel { executablePath, err := os.Executable() if err != nil { - return installChannelScript + return installChannelUnknown } resolvedPath, err := filepath.EvalSymlinks(executablePath) if err != nil { @@ -43,24 +62,50 @@ func (n notifier) installChannel() installChannel { if isGoInstallExecutable(resolvedPath) { return installChannelGo } - return installChannelScript + if isInstallScriptExecutable(resolvedPath) { + return installChannelScript + } + return installChannelUnknown } -func (n notifier) scriptUpdateCommand() string { +func scriptUpdateCommand() updateCommand { const scriptURL = "https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash" if _, err := exec.LookPath("curl"); err == nil { - return "curl -fsSL " + scriptURL + " | bash" + return bashPipelineUpdateCommand("curl -fsSL " + scriptURL + " | bash") } if _, err := exec.LookPath("wget"); err == nil { - return "wget -O - -q " + scriptURL + " | bash" + return bashPipelineUpdateCommand("wget -O - -q " + scriptURL + " | bash") + } + return updateCommand{} +} + +func bashPipelineUpdateCommand(display string) updateCommand { + return updateCommand{ + display: display, + executable: "bash", + args: []string{"-o", "pipefail", "-c", display}, } - return "" +} + +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 isHomebrewExecutable(path string) bool { return strings.Contains(filepath.ToSlash(path), "/Cellar/sloctl/") } +func isInstallScriptExecutable(path string) bool { + path = strings.ReplaceAll(filepath.Clean(path), `\`, "/") + return path == "/usr/local/bin/sloctl" || + strings.HasSuffix(path, "/usr/local/bin/sloctl.exe") +} + func isGoInstallExecutable(path string) bool { path = filepath.Clean(path) for _, binDir := range goBinDirs() { diff --git a/internal/notifications/notifications.go b/internal/notifications/notifications.go index b387026f..8b16ba1a 100644 --- a/internal/notifications/notifications.go +++ b/internal/notifications/notifications.go @@ -1,9 +1,10 @@ -// Package notifications checks for new sloctl releases and prompts for updates. +// Package notifications shows release notifications and prompts for updates on supported terminals. package notifications import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -14,7 +15,9 @@ import ( "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. @@ -28,16 +31,22 @@ const ( maxResponseSize = 1 << 20 ) -// Result describes what the caller should do after checking notifications. +// 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 and displays an interactive update prompt when configured to do so. +// Notify checks for a newer release in eligible interactive sessions. +// It may display an update prompt when the terminal supports one. func Notify(currentVersion string) Result { return newNotifier(currentVersion).notify() } @@ -89,7 +98,7 @@ func (n notifier) notify() Result { n.saveState(currentState) return ResultContinue } - if isCurrentRelease(n.currentVersion, release.TagName) { + if !isReleaseNewer(n.currentVersion, release.TagName) { n.saveState(currentState) return ResultContinue } @@ -99,16 +108,19 @@ func (n notifier) notify() Result { } releaseNotesMarkdown := extractReleaseNotesMarkdown(release.Body) - updateCommand := n.updateCommand() + updateCommand := detectUpdateCommand() action, err := n.promptUpdate( release, releaseNotesMarkdown, - updateCommand, + updateCommand.display, isUpdateFormSupported(runtime.GOOS, systemName), ) if err != nil { - n.saveState(currentState) - return ResultContinue + result := n.handlePromptError(err) + if result != ResultInterrupted { + n.saveState(currentState) + } + return result } if action == updateActionSkipUntilNextVersion { currentState.LastShownReleaseTag = release.TagName @@ -117,7 +129,7 @@ func (n notifier) notify() Result { if action != updateActionRunUpgrade { return ResultContinue } - if updateCommand == "" { + if !updateCommand.available() { return ResultContinue } if err := n.runCommand(updateCommand); err != nil { @@ -147,6 +159,18 @@ func isTerminal(file *os.File) bool { 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 string, readSystemName func() (string, error)) bool { if goOS != "windows" { return true @@ -164,15 +188,6 @@ func systemName() (string, error) { return string(output), err } -func (n notifier) runCommand(command string) error { - //nolint:gosec // The command is assembled from fixed sloctl update templates. - cmd := exec.Command("sh", "-c", command) - cmd.Stdin = n.stdin - cmd.Stdout = n.stdout - cmd.Stderr = n.stderr - return cmd.Run() -} - func (n notifier) fetchLatestRelease(ctx context.Context) (githubRelease, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, n.releaseURL, nil) if err != nil { @@ -239,10 +254,18 @@ func isDevelopmentVersion(version string) bool { strings.Contains(version, "devel") } -func isCurrentRelease(currentVersion, releaseTag string) bool { - return normalizeVersion(currentVersion) == normalizeVersion(releaseTag) +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 normalizeVersion(version string) string { - return strings.TrimPrefix(strings.TrimSpace(version), "v") +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 index 7ace7619..3d5510de 100644 --- a/internal/notifications/notifications_test.go +++ b/internal/notifications/notifications_test.go @@ -6,6 +6,7 @@ import ( "os" "testing" + huh "charm.land/huh/v2" "github.com/charmbracelet/x/ansi" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -92,3 +93,141 @@ func Test_isUpdateFormSupported(t *testing.T) { }) } } + +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_isInstallScriptExecutable(t *testing.T) { + t.Parallel() + tests := map[string]struct { + path string + expected bool + }{ + "default Unix install": { + path: "/usr/local/bin/sloctl", + expected: true, + }, + "default MinGW install": { + path: `C:\msys64\usr\local\bin\sloctl.exe`, + expected: true, + }, + "manual Unix install": { + path: "/opt/bin/sloctl", + }, + "system package install": { + path: "/usr/bin/sloctl", + }, + "similar suffix outside MinGW": { + path: "/opt/usr/local/bin/sloctl", + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.expected, isInstallScriptExecutable(tt.path)) + }) + } +} + +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)) + }) + } +} diff --git a/internal/notifications/release_notes.go b/internal/notifications/release_notes.go index 6ac539d1..adbe57db 100644 --- a/internal/notifications/release_notes.go +++ b/internal/notifications/release_notes.go @@ -96,10 +96,23 @@ func displayReleaseNotesMarkdown(markdown string, highlightTitles bool) string { } func isReleaseNotesHeading(line string) bool { - heading := strings.ToLower(line) - return strings.Contains(heading, "features") || - strings.Contains(heading, "fixes") || - strings.Contains(heading, "breaking") + 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 { @@ -114,6 +127,7 @@ func trimReleaseNotePrefix(title string) string { "feat:", "fix:", "breaking:", + "sec:", } { if strings.HasPrefix(lowerTitle, prefix) { return strings.TrimSpace(title[len(prefix):]) diff --git a/internal/root.go b/internal/root.go index 4ae8abc7..4bc8678f 100644 --- a/internal/root.go +++ b/internal/root.go @@ -20,7 +20,7 @@ import ( const programName = "sloctl" -// Execute checks for updates and then runs the requested command. +// 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() { @@ -29,6 +29,8 @@ func Execute() { return case notifications.ResultExitFailure: os.Exit(1) + case notifications.ResultInterrupted: + os.Exit(130) case notifications.ResultContinue: } 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/run_with_stderr_pty.py b/test/inputs/notifications/run_with_stderr_pty.py index bb857e8f..68ab41a8 100644 --- a/test/inputs/notifications/run_with_stderr_pty.py +++ b/test/inputs/notifications/run_with_stderr_pty.py @@ -9,6 +9,7 @@ import subprocess import sys import termios +from contextlib import suppress def main(): @@ -16,65 +17,97 @@ def main(): print("usage: run_with_stderr_pty.py COMMAND [ARG...]", file=sys.stderr) return 2 - controller_fd, terminal_fd = pty.openpty() - join_output = os.environ.get("SLOCTL_TEST_TTY_JOIN_OUTPUT") == "1" - columns = os.environ.get("SLOCTL_TEST_TTY_COLUMNS") - if columns: - fcntl.ioctl( - terminal_fd, - termios.TIOCSWINSZ, - struct.pack("HHHH", 24, int(columns), 0, 0), + controller_fd = None + terminal_fd = None + process = None + selector = selectors.DefaultSelector() + try: + controller_fd, terminal_fd = pty.openpty() + columns = os.environ.get("SLOCTL_TEST_TTY_COLUMNS") + if columns: + fcntl.ioctl( + terminal_fd, + termios.TIOCSWINSZ, + struct.pack("HHHH", 24, int(columns), 0, 0), + ) + input_text = os.environ.get("SLOCTL_TEST_TTY_INPUT") + wait_for_raw_mode = ( + os.environ.get("SLOCTL_TEST_TTY_INPUT_WHEN_RAW") == "1" ) - input_text = os.environ.get("SLOCTL_TEST_TTY_INPUT") - stdin = terminal_fd if join_output else subprocess.DEVNULL - if input_text is not None: - attrs = termios.tcgetattr(terminal_fd) - attrs[3] &= ~termios.ECHO - termios.tcsetattr(terminal_fd, termios.TCSANOW, attrs) - stdin = terminal_fd + stdin = subprocess.DEVNULL + if input_text is not None: + attrs = termios.tcgetattr(terminal_fd) + attrs[3] &= ~termios.ECHO + termios.tcsetattr(terminal_fd, termios.TCSANOW, attrs) + stdin = terminal_fd - process = subprocess.Popen( - sys.argv[1:], - stdin=stdin, - stdout=terminal_fd if join_output else subprocess.PIPE, - stderr=terminal_fd, - close_fds=True, - ) - if input_text is not None: - os.write(controller_fd, input_text.encode()) - os.close(terminal_fd) + process = subprocess.Popen( + sys.argv[1:], + stdin=stdin, + stdout=subprocess.PIPE, + stderr=terminal_fd, + close_fds=True, + ) + 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 = selectors.DefaultSelector() - if not join_output: selector.register(process.stdout, selectors.EVENT_READ, sys.stdout.buffer) - selector.register( - controller_fd, - selectors.EVENT_READ, - sys.stdout.buffer if join_output else sys.stderr.buffer, - ) + selector.register( + controller_fd, + selectors.EVENT_READ, + sys.stderr.buffer, + ) + while selector.get_map(): + timeout = 0.1 if terminal_fd is not None else None + 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"" - while selector.get_map(): - for key, _ in selector.select(): - fd = key.fileobj if isinstance(key.fileobj, int) else key.fileobj.fileno() - try: - data = os.read(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 - if not data: - selector.unregister(key.fileobj) - if isinstance(key.fileobj, int): - os.close(key.fileobj) - 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 - key.data.write(data.replace(b"\r\n", b"\n").replace(b"\r", b"")) - key.data.flush() + if terminal_fd is not None and process.poll() is not None: + os.close(terminal_fd) + terminal_fd = None - return process.wait() + return process.wait() + finally: + selector.close() + if process is not None and process.poll() is None: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + 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__": diff --git a/test/notifications.bats b/test/notifications.bats index 3271a628..4adc65ba 100644 --- a/test/notifications.bats +++ b/test/notifications.bats @@ -5,6 +5,9 @@ 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" @@ -28,7 +31,7 @@ setup() { unset SLOCTL_NO_NOTIFICATIONS unset SLOCTL_TEST_TTY_COLUMNS unset SLOCTL_TEST_TTY_INPUT - unset SLOCTL_TEST_TTY_JOIN_OUTPUT + unset SLOCTL_TEST_TTY_INPUT_WHEN_RAW unset RELEASE_SERVER_BODY_FILE unset RELEASE_SERVER_HTML_URL unset RELEASE_SERVER_RAW_RESPONSE @@ -95,6 +98,18 @@ teardown() { 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_with_tty_stderr version + assert_success_joined_output + assert_notification_stderr next-version-prompt-skip-until-next-version + assert_release_requests 3 } @test "sloctl defaults to update action and exits without running the command" { @@ -110,7 +125,7 @@ teardown() { chmod +x "$tools_dir/curl" start_release_server - run_sloctl_binary_with_prefixed_path /usr/bin/sloctl "$tools_dir" version + run_sloctl_binary_with_prefixed_path /usr/local/bin/sloctl "$tools_dir" version assert_success_joined_output assert_output "" assert_notification_stderr version-prompt-run-upgrade @@ -118,6 +133,47 @@ teardown() { assert_release_requests 1 } +@test "sloctl reports a failed script update and exits without running the command" { + use_release_body maintenance + select_default_update_action + local tools_dir="$BATS_TEST_TMPDIR/tools" + mkdir -p "$tools_dir" + printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'exit 22' \ + > "$tools_dir/curl" + chmod +x "$tools_dir/curl" + start_release_server + + run_sloctl_binary_with_prefixed_path /usr/local/bin/sloctl "$tools_dir" 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 + unset SLOCTL_ACCESSIBLE_MODE + export SLOCTL_TEST_TTY_INPUT=$'\x03' + export SLOCTL_TEST_TTY_INPUT_WHEN_RAW=1 + start_release_server + + run_sloctl_with_tty_stderr 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_with_tty_stderr version + assert_success_joined_output + assert_notification_stderr version-prompt-skip + assert_release_requests 2 +} + @test "sloctl does not show feature notification when opted out" { start_release_server export SLOCTL_NO_NOTIFICATIONS=1 @@ -208,6 +264,16 @@ teardown() { 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 @@ -239,6 +305,17 @@ teardown() { 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" @@ -284,7 +361,7 @@ teardown() { chmod +x "$tools_dir/curl" start_release_server - run_sloctl_binary_with_path /usr/bin/sloctl "$tools_dir" version + run_sloctl_binary_with_path /usr/local/bin/sloctl "$tools_dir" version assert_success_joined_output assert_notification_stderr install-curl-wide-prompt } @@ -320,6 +397,18 @@ teardown() { assert_notification_stderr install-go-prompt } +@test "sloctl omits update command for unrecognized installs" { + use_release_body maintenance + select_update_action_without_update skip + local manual_binary="$BATS_TEST_TMPDIR/manual/sloctl" + copy_sloctl_binary "$manual_binary" + start_release_server + + run_sloctl_binary_with_tty_stderr "$manual_binary" version + assert_success_joined_output + assert_notification_stderr no-install-command-prompt +} + @test "sloctl falls back to wget when curl is unavailable" { use_release_body maintenance local tools_dir="$BATS_TEST_TMPDIR/tools" @@ -328,7 +417,7 @@ teardown() { chmod +x "$tools_dir/wget" start_release_server - run_sloctl_binary_with_path /usr/bin/sloctl "$tools_dir" version + run_sloctl_binary_with_path /usr/local/bin/sloctl "$tools_dir" version assert_success_joined_output assert_notification_stderr install-wget-prompt } @@ -340,7 +429,7 @@ teardown() { mkdir -p "$tools_dir" start_release_server - run_sloctl_binary_with_path /usr/bin/sloctl "$tools_dir" version + run_sloctl_binary_with_path /usr/local/bin/sloctl "$tools_dir" version assert_success_joined_output assert_notification_stderr no-install-command-prompt } @@ -446,7 +535,7 @@ run_sloctl_binary_with_prefixed_path() { copy_sloctl_binary() { local target="$1" - local source="/usr/bin/sloctl" + local source="/usr/local/bin/sloctl" if has_bats_tag platform; then source="$(native_sloctl_binary)" fi @@ -503,6 +592,7 @@ stop_release_server() { expire_notification_cache() { local cache_file="$XDG_CACHE_HOME/nobl9/sloctl/notifications.json" sed -i 's/"lastCheckedAt": "[^"]*"/"lastCheckedAt": "2000-01-01T00:00:00Z"/' "$cache_file" + assert_equal "$(jq -r '.lastCheckedAt' "$cache_file")" "2000-01-01T00:00:00Z" } assert_release_requests() { 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..d42d08ca --- /dev/null +++ b/test/outputs/notifications/fixed-vulnerabilities-prompt-skip.stderr @@ -0,0 +1,15 @@ +# Changes in version v1.1.0 + +## 💻 Fixed Vulnerabilities + +• Patch vulnerable dependency + +📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 + +──────────────────────────────────────────────────────────────────────────────────────────── + +Choose update action +1. Update (runs wget -O - -q https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash) +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..8a600a13 --- /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 wget -O - -q https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash) +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..74326b5f --- /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 curl -fsSL https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash) +2. Skip +3. Skip until next version +Enter a number between 1 and 3: +failed to update sloctl: exit status 22 From fff27aea37b9c2320a01edd7898d4fe9504be705 Mon Sep 17 00:00:00 2001 From: Mateusz Hawrus Date: Fri, 7 Aug 2026 15:19:54 +0200 Subject: [PATCH 20/20] feat: improve update notifications and installation detection Detect Homebrew and Go installations reliably, persist skip preferences safely, and add installation guidance for unsupported setups. Extend notification tests and PTY helpers for update flows and timeout handling. --- README.md | 22 ++ cspell.json | 1 + internal/notifications/install.go | 172 ++++++----- internal/notifications/notifications.go | 104 +++++-- internal/notifications/notifications_test.go | 237 +++++++++++----- internal/notifications/prompt.go | 38 +-- .../notifications/run_with_stderr_pty.py | 53 ++-- .../notifications/run_with_windows_pty.py | 32 ++- test/notifications.bats | 267 +++++++++++------- .../notifications/breaking-prompt-skip.stderr | 8 +- .../notifications/bug-fix-prompt-skip.stderr | 8 +- .../failed-command-after-skip.stderr | 7 +- ...ture-prompt-skip-until-next-version.stderr | 2 +- .../notifications/feature-prompt-skip.stderr | 8 +- .../feature-without-author-prompt-skip.stderr | 8 +- .../features-with-details-prompt-skip.stderr | 8 +- .../fixed-vulnerabilities-prompt-skip.stderr | 8 +- .../notifications/install-wget-prompt.stderr | 11 - ...sion-prompt-skip-until-next-version.stderr | 2 +- .../no-install-command-prompt.stderr | 10 - .../version-prompt-failed-upgrade.stderr | 2 +- .../version-prompt-run-upgrade.stderr | 2 +- ...sion-prompt-skip-until-cache-error.stderr} | 3 +- .../notifications/version-prompt-skip.stderr | 8 +- 24 files changed, 608 insertions(+), 413 deletions(-) delete mode 100644 test/outputs/notifications/install-wget-prompt.stderr delete mode 100644 test/outputs/notifications/no-install-command-prompt.stderr rename test/outputs/notifications/{install-curl-wide-prompt.stderr => version-prompt-skip-until-cache-error.stderr} (66%) 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 394b7cfb..c0fb89b9 100644 --- a/cspell.json +++ b/cspell.json @@ -48,6 +48,7 @@ "govulncheck", "ldflags", "msys", + "msystem", "nobl", "openslo", "openslosdk", diff --git a/internal/notifications/install.go b/internal/notifications/install.go index 3cd35a15..3b38e8e6 100644 --- a/internal/notifications/install.go +++ b/internal/notifications/install.go @@ -1,20 +1,17 @@ package notifications import ( + "context" + "encoding/json" "os" "os/exec" "path/filepath" + "runtime" "strings" + "time" ) -type installChannel string - -const ( - installChannelUnknown installChannel = "" - installChannelScript installChannel = "script" - installChannelHomebrew installChannel = "homebrew" - installChannelGo installChannel = "go-install" -) +const goEnvTimeout = 2 * time.Second type updateCommand struct { display string @@ -23,68 +20,23 @@ type updateCommand struct { } func (c updateCommand) available() bool { - return c.executable != "" + return c.display != "" && c.executable != "" } func detectUpdateCommand() updateCommand { - switch detectInstallChannel() { - case installChannelHomebrew: - return updateCommand{ - display: "brew upgrade sloctl", - executable: "brew", - args: []string{"upgrade", "sloctl"}, - } - case installChannelGo: - return updateCommand{ - display: "go install github.com/nobl9/sloctl/cmd/sloctl@latest", - executable: "go", - args: []string{"install", "github.com/nobl9/sloctl/cmd/sloctl@latest"}, - } - case installChannelScript: - return scriptUpdateCommand() - default: - return updateCommand{} - } -} - -func detectInstallChannel() installChannel { executablePath, err := os.Executable() if err != nil { - return installChannelUnknown + return updateCommand{} } resolvedPath, err := filepath.EvalSymlinks(executablePath) if err != nil { resolvedPath = executablePath } - if isHomebrewExecutable(resolvedPath) { - return installChannelHomebrew - } - if isGoInstallExecutable(resolvedPath) { - return installChannelGo - } - if isInstallScriptExecutable(resolvedPath) { - return installChannelScript - } - return installChannelUnknown -} -func scriptUpdateCommand() updateCommand { - const scriptURL = "https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash" - if _, err := exec.LookPath("curl"); err == nil { - return bashPipelineUpdateCommand("curl -fsSL " + scriptURL + " | bash") - } - if _, err := exec.LookPath("wget"); err == nil { - return bashPipelineUpdateCommand("wget -O - -q " + scriptURL + " | bash") - } - return updateCommand{} -} - -func bashPipelineUpdateCommand(display string) updateCommand { - return updateCommand{ - display: display, - executable: "bash", - args: []string{"-o", "pipefail", "-c", display}, + if command := homebrewUpdateCommand(resolvedPath); command.available() { + return command } + return goInstallUpdateCommand(resolvedPath) } func (n notifier) runCommand(command updateCommand) error { @@ -96,48 +48,84 @@ func (n notifier) runCommand(command updateCommand) error { return cmd.Run() } -func isHomebrewExecutable(path string) bool { - return strings.Contains(filepath.ToSlash(path), "/Cellar/sloctl/") +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 isInstallScriptExecutable(path string) bool { - path = strings.ReplaceAll(filepath.Clean(path), `\`, "/") - return path == "/usr/local/bin/sloctl" || - strings.HasSuffix(path, "/usr/local/bin/sloctl.exe") +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(path string) bool { - path = filepath.Clean(path) - for _, binDir := range goBinDirs() { - if path == filepath.Clean(filepath.Join(binDir, "sloctl")) || - path == filepath.Clean(filepath.Join(binDir, "sloctl.exe")) { - return true - } +func isGoInstallExecutable(sloctlPath, goExecutable string) bool { + binDir := goBinDir(goExecutable) + if binDir == "" { + return false + } + executableName := "sloctl" + if runtime.GOOS == "windows" { + executableName += ".exe" } - return false + return isSameFile(sloctlPath, filepath.Join(binDir, executableName)) } -func goBinDirs() []string { - if goBin := strings.TrimSpace(os.Getenv("GOBIN")); goBin != "" { - return []string{goBin} - } - goPath := strings.TrimSpace(os.Getenv("GOPATH")) - if goPath == "" { - homeDir := strings.TrimSpace(os.Getenv("HOME")) - if homeDir == "" { - homeDir = strings.TrimSpace(os.Getenv("USERPROFILE")) - } - if homeDir == "" { - return nil - } - goPath = filepath.Join(homeDir, "go") +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 "" } - goPaths := filepath.SplitList(goPath) - binDirs := make([]string, 0, len(goPaths)) - for _, path := range goPaths { - if path != "" { - binDirs = append(binDirs, filepath.Join(path, "bin")) + 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 binDirs + return os.SameFile(first, second) } diff --git a/internal/notifications/notifications.go b/internal/notifications/notifications.go index 8b16ba1a..5d3e3482 100644 --- a/internal/notifications/notifications.go +++ b/internal/notifications/notifications.go @@ -1,4 +1,5 @@ -// Package notifications shows release notifications and prompts for updates on supported terminals. +// Package notifications displays release notices in eligible interactive sessions. +// It offers update actions for recognized Homebrew and Go installations. package notifications import ( @@ -9,7 +10,6 @@ import ( "io" "net/http" "os" - "os/exec" "path/filepath" "runtime" "strings" @@ -45,8 +45,9 @@ const ( ResultInterrupted ) -// Notify checks for a newer release in eligible interactive sessions. -// It may display an update prompt when the terminal supports one. +// 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() } @@ -61,8 +62,7 @@ type notifier struct { } type state struct { - LastCheckedAt time.Time `json:"lastCheckedAt"` - LastShownReleaseTag string `json:"lastShownReleaseTag"` + LastCheckedAt time.Time `json:"lastCheckedAt"` } type githubRelease struct { @@ -88,22 +88,23 @@ func (n notifier) notify() Result { } currentState := n.readState() now := time.Now() - if !currentState.LastCheckedAt.IsZero() && now.Sub(currentState.LastCheckedAt) < checkInterval { + 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) + _ = n.saveState(currentState) return ResultContinue } if !isReleaseNewer(n.currentVersion, release.TagName) { - n.saveState(currentState) + _ = n.saveState(currentState) return ResultContinue } - if currentState.LastShownReleaseTag == release.TagName { - n.saveState(currentState) + if n.hasSkippedRelease(release.TagName) { + _ = n.saveState(currentState) return ResultContinue } @@ -112,20 +113,30 @@ func (n notifier) notify() Result { action, err := n.promptUpdate( release, releaseNotesMarkdown, - updateCommand.display, - isUpdateFormSupported(runtime.GOOS, systemName), + 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) + _ = n.saveState(currentState) } return result } + _ = n.saveState(currentState) if action == updateActionSkipUntilNextVersion { - currentState.LastShownReleaseTag = release.TagName + 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, + ) + } } - n.saveState(currentState) if action != updateActionRunUpgrade { return ResultContinue } @@ -171,21 +182,14 @@ func (n notifier) handlePromptError(err error) Result { return ResultContinue } -func isUpdateFormSupported(goOS string, readSystemName func() (string, error)) bool { +func isUpdateFormSupported(goOS, msysEnvironment string, isCygwinTerminal bool) bool { if goOS != "windows" { return true } - systemName, err := readSystemName() - if err != nil { + if !isCygwinTerminal { return false } - systemName = strings.ToLower(strings.TrimSpace(systemName)) - return strings.HasPrefix(systemName, "mingw") || strings.HasPrefix(systemName, "cygwin") -} - -func systemName() (string, error) { - output, err := exec.Command("uname").Output() - return string(output), err + return !strings.EqualFold(strings.TrimSpace(msysEnvironment), "MSYS") } func (n notifier) fetchLatestRelease(ctx context.Context) (githubRelease, error) { @@ -227,15 +231,55 @@ func (n notifier) readState() state { return currentState } -func (n notifier) saveState(currentState state) { +func (n notifier) saveState(currentState state) error { if err := os.MkdirAll(filepath.Dir(n.cachePath), 0o700); err != nil { - return + return fmt.Errorf("create notification cache directory: %w", err) } data, err := json.MarshalIndent(currentState, "", " ") if err != nil { - return + 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 } - _ = os.WriteFile(n.cachePath, data, 0o600) + return os.Rename(temporaryPath, path) } func defaultCachePath() string { diff --git a/internal/notifications/notifications_test.go b/internal/notifications/notifications_test.go index 3d5510de..e3fc6224 100644 --- a/internal/notifications/notifications_test.go +++ b/internal/notifications/notifications_test.go @@ -4,7 +4,12 @@ import ( "errors" "io" "os" + "os/exec" + "path/filepath" + "runtime" + "strings" "testing" + "time" huh "charm.land/huh/v2" "github.com/charmbracelet/x/ansi" @@ -13,83 +18,171 @@ import ( ) func TestNotifier_promptUpdate_WithoutForm(t *testing.T) { - t.Parallel() - 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", - }, - "", - "sloctl update", - false, - ) + 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) - assert.Equal(t, updateActionSkip, action) + t.Setenv("GOENV", "off") + + t.Run("GOBIN", func(t *testing.T) { + goBin := t.TempDir() + t.Setenv("GOBIN", goBin) + t.Setenv("GOPATH", t.TempDir()) - _, err = stderr.Seek(0, io.SeekStart) + 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) - output, err := io.ReadAll(stderr) + 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) - 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.NotContains(t, plainOutput, "Choose update action") + 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 - systemName string - systemNameErr error + msysEnvironment string + isCygwinTerminal bool expectedIsSupported bool }{ "Linux": { goOS: "linux", - systemNameErr: errors.New("uname unavailable"), expectedIsSupported: true, }, "Windows MinGW": { goOS: "windows", - systemName: "MINGW64_NT-10.0-26100\n", + msysEnvironment: "MINGW64", + isCygwinTerminal: true, expectedIsSupported: true, }, "Windows Cygwin": { goOS: "windows", - systemName: "CYGWIN_NT-10.0-26100\n", + isCygwinTerminal: true, expectedIsSupported: true, }, "Windows MSYS": { - goOS: "windows", - systemName: "MSYS_NT-10.0-26100\n", - expectedIsSupported: false, + goOS: "windows", + msysEnvironment: "MSYS", + isCygwinTerminal: true, }, "Windows native shell": { - goOS: "windows", - systemName: "Windows_NT\n", - expectedIsSupported: false, + goOS: "windows", }, - "Windows without uname": { - goOS: "windows", - systemNameErr: errors.New("uname unavailable"), - expectedIsSupported: false, + "Windows native shell launched from MinGW": { + goOS: "windows", + msysEnvironment: "MINGW64", }, } for name, tt := range tests { t.Run(name, func(t *testing.T) { t.Parallel() - readSystemName := func() (string, error) { - return tt.systemName, tt.systemNameErr - } - assert.Equal(t, tt.expectedIsSupported, isUpdateFormSupported(tt.goOS, readSystemName)) + assert.Equal( + t, + tt.expectedIsSupported, + isUpdateFormSupported(tt.goOS, tt.msysEnvironment, tt.isCygwinTerminal), + ) }) } } @@ -159,38 +252,6 @@ func Test_isReleaseNotesHeading(t *testing.T) { } } -func Test_isInstallScriptExecutable(t *testing.T) { - t.Parallel() - tests := map[string]struct { - path string - expected bool - }{ - "default Unix install": { - path: "/usr/local/bin/sloctl", - expected: true, - }, - "default MinGW install": { - path: `C:\msys64\usr\local\bin\sloctl.exe`, - expected: true, - }, - "manual Unix install": { - path: "/opt/bin/sloctl", - }, - "system package install": { - path: "/usr/bin/sloctl", - }, - "similar suffix outside MinGW": { - path: "/opt/usr/local/bin/sloctl", - }, - } - for name, tt := range tests { - t.Run(name, func(t *testing.T) { - t.Parallel() - assert.Equal(t, tt.expected, isInstallScriptExecutable(tt.path)) - }) - } -} - func Test_notifier_handlePromptError(t *testing.T) { tests := map[string]struct { promptErr error @@ -231,3 +292,27 @@ func Test_notifier_handlePromptError(t *testing.T) { }) } } + +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 index b3548d32..d22305ba 100644 --- a/internal/notifications/prompt.go +++ b/internal/notifications/prompt.go @@ -16,8 +16,9 @@ import ( ) const ( - defaultPromptWidth = 92 - minPromptWidth = 48 + defaultPromptWidth = 92 + minPromptWidth = 48 + installationGuideURL = "https://github.com/nobl9/sloctl#install" ) type updateAction string @@ -36,16 +37,17 @@ type terminalInfo struct { func (n notifier) promptUpdate( release githubRelease, releaseNotesMarkdown string, - updateCommand string, + command updateCommand, showUpdateForm bool, ) (updateAction, error) { terminal := n.terminalInfo() - n.printNotification(release, releaseNotesMarkdown, terminal) + showUpdateForm = showUpdateForm && command.available() + n.printNotification(release, releaseNotesMarkdown, command, showUpdateForm, terminal) if !showUpdateForm { return updateActionSkip, nil } - action := defaultUpdateAction(updateCommand) + action := updateActionRunUpgrade form := huhform.NewWithTheme( huh.ThemeFunc(func(bool) *huh.Styles { return style.HuhTheme(terminal.dark) @@ -53,7 +55,7 @@ func (n notifier) promptUpdate( huh.NewGroup( huh.NewSelect[updateAction](). Title("Choose update action"). - Options(updateActionOptions(updateCommand)...). + Options(updateActionOptions(command.display)...). Value(&action), ), ). @@ -65,9 +67,20 @@ func (n notifier) promptUpdate( 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) @@ -96,12 +109,6 @@ func (n notifier) terminalWidth() int { } func updateActionOptions(updateCommand string) []huh.Option[updateAction] { - if updateCommand == "" { - return []huh.Option[updateAction]{ - huh.NewOption("Skip", updateActionSkip), - huh.NewOption("Skip until next version", updateActionSkipUntilNextVersion), - } - } return []huh.Option[updateAction]{ huh.NewOption(fmt.Sprintf("Update (runs %s)", updateCommand), updateActionRunUpgrade), huh.NewOption("Skip", updateActionSkip), @@ -109,13 +116,6 @@ func updateActionOptions(updateCommand string) []huh.Option[updateAction] { } } -func defaultUpdateAction(updateCommand string) updateAction { - if updateCommand == "" { - return updateActionSkip - } - return updateActionRunUpgrade -} - func notificationWidth(terminalWidth int) int { if terminalWidth <= 0 { return defaultPromptWidth diff --git a/test/inputs/notifications/run_with_stderr_pty.py b/test/inputs/notifications/run_with_stderr_pty.py index 68ab41a8..36252fb2 100644 --- a/test/inputs/notifications/run_with_stderr_pty.py +++ b/test/inputs/notifications/run_with_stderr_pty.py @@ -1,17 +1,20 @@ #!/usr/bin/env python3 import errno -import fcntl import os import pty import selectors -import struct +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) @@ -20,26 +23,19 @@ def main(): controller_fd = None terminal_fd = None process = None + process_completed = False selector = selectors.DefaultSelector() try: controller_fd, terminal_fd = pty.openpty() - columns = os.environ.get("SLOCTL_TEST_TTY_COLUMNS") - if columns: - fcntl.ioctl( - terminal_fd, - termios.TIOCSWINSZ, - struct.pack("HHHH", 24, int(columns), 0, 0), - ) input_text = os.environ.get("SLOCTL_TEST_TTY_INPUT") wait_for_raw_mode = ( os.environ.get("SLOCTL_TEST_TTY_INPUT_WHEN_RAW") == "1" ) - stdin = subprocess.DEVNULL + 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) - stdin = terminal_fd process = subprocess.Popen( sys.argv[1:], @@ -47,7 +43,9 @@ def main(): 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: @@ -61,7 +59,15 @@ def main(): sys.stderr.buffer, ) while selector.get_map(): - timeout = 0.1 if terminal_fd is not None else None + 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) @@ -92,16 +98,29 @@ def main(): os.close(terminal_fd) terminal_fd = None - return process.wait() + 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 process.poll() is None: - process.terminate() + 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: - process.kill() - process.wait() + 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): diff --git a/test/inputs/notifications/run_with_windows_pty.py b/test/inputs/notifications/run_with_windows_pty.py index 1761832e..b074494a 100644 --- a/test/inputs/notifications/run_with_windows_pty.py +++ b/test/inputs/notifications/run_with_windows_pty.py @@ -1,12 +1,18 @@ #!/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) @@ -18,17 +24,41 @@ def main(): 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() - return process.wait() + if timed_out: + print( + f"command timed out after {COMMAND_TIMEOUT_SECONDS} seconds", + file=sys.stderr, + ) + return 124 + return process.exitstatus if __name__ == "__main__": diff --git a/test/notifications.bats b/test/notifications.bats index 4adc65ba..66a31f1e 100644 --- a/test/notifications.bats +++ b/test/notifications.bats @@ -18,25 +18,29 @@ setup() { load_lib "bats-support" load_lib "bats-assert" - unset CI - unset ALL_PROXY - unset HTTPS_PROXY - unset HTTP_PROXY - unset NO_PROXY - unset SSL_CERT_FILE - unset all_proxy - unset https_proxy - unset http_proxy - unset no_proxy - unset SLOCTL_NO_NOTIFICATIONS - unset SLOCTL_TEST_TTY_COLUMNS - unset SLOCTL_TEST_TTY_INPUT - unset SLOCTL_TEST_TTY_INPUT_WHEN_RAW - unset RELEASE_SERVER_BODY_FILE - unset RELEASE_SERVER_HTML_URL - unset RELEASE_SERVER_RAW_RESPONSE - unset RELEASE_SERVER_STATUS - unset RELEASE_SERVER_TAG + 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 @@ -44,8 +48,23 @@ setup() { 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 - select_update_action skip } teardown() { @@ -66,7 +85,7 @@ teardown() { assert_release_requests 1 } -@test "sloctl prompts before command validation and then runs the command after skip" { +@test "sloctl shows installation guidance before command validation" { start_release_server run_sloctl_with_tty_stderr config rename-context old @@ -76,15 +95,18 @@ teardown() { } @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_with_tty_stderr version + 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_with_tty_stderr version + run_sloctl_binary_with_tty_stderr "$go_binary" version assert_success_joined_output assert_stderr "" assert_release_requests 1 @@ -94,7 +116,7 @@ teardown() { use_release_body feature-without-author start_release_server - run_sloctl_with_tty_stderr version + run_sloctl_binary_with_tty_stderr "$go_binary" version assert_success_joined_output assert_stderr "" assert_release_requests 2 @@ -106,46 +128,40 @@ teardown() { export RELEASE_SERVER_HTML_URL=https://github.com/nobl9/sloctl/releases/tag/v1.2.0 start_release_server - run_sloctl_with_tty_stderr version + 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 update action and exits without running the command" { +@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 tools_dir="$BATS_TEST_TMPDIR/tools" - mkdir -p "$tools_dir" - printf '%s\n' \ - '#!/usr/bin/env bash' \ - 'printf "%s\n" "touch \"$SLOCTL_TEST_UPGRADE_MARKER\""' \ - > "$tools_dir/curl" - chmod +x "$tools_dir/curl" + local go_binary="$HOME/go/bin/sloctl" + copy_sloctl_binary "$go_binary" start_release_server - run_sloctl_binary_with_prefixed_path /usr/local/bin/sloctl "$tools_dir" version + 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 script update and exits without running the command" { +@test "sloctl reports a failed Go update and exits without running the command" { use_release_body maintenance select_default_update_action - local tools_dir="$BATS_TEST_TMPDIR/tools" - mkdir -p "$tools_dir" - printf '%s\n' \ - '#!/usr/bin/env bash' \ - 'exit 22' \ - > "$tools_dir/curl" - chmod +x "$tools_dir/curl" + 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_prefixed_path /usr/local/bin/sloctl "$tools_dir" version + run_sloctl_binary_with_tty_stderr "$go_binary" version assert_failure 1 assert_output "" assert_notification_stderr version-prompt-failed-upgrade @@ -154,12 +170,14 @@ teardown() { @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_with_tty_stderr version + 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!" @@ -168,9 +186,10 @@ teardown() { unset SLOCTL_TEST_TTY_INPUT_WHEN_RAW export SLOCTL_ACCESSIBLE_MODE=1 select_update_action skip - run_sloctl_with_tty_stderr version + run_sloctl_binary_with_tty_stderr "$go_binary" version assert_success_joined_output - assert_notification_stderr version-prompt-skip + assert_sloctl_version_output + assert_notification_stderr version-prompt-run-upgrade assert_release_requests 2 } @@ -216,10 +235,14 @@ teardown() { # 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_with_tty_stderr version + 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" @@ -233,13 +256,16 @@ teardown() { fi use_release_body maintenance - local tools_dir="$BATS_TEST_TMPDIR/tools-without-uname" - mkdir -p "$tools_dir" + 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 "$(native_sloctl_binary)" "$tools_dir" version + 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 } @@ -352,22 +378,45 @@ teardown() { assert_release_requests 1 } -@test "sloctl keeps install command on one line when terminal is wide" { +@test "sloctl warns when skip until next version cannot be saved" { use_release_body maintenance - export SLOCTL_TEST_TTY_COLUMNS=140 - local tools_dir="$BATS_TEST_TMPDIR/tools" - mkdir -p "$tools_dir" - touch "$tools_dir/curl" - chmod +x "$tools_dir/curl" + 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_binary_with_path /usr/local/bin/sloctl "$tools_dir" version + 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 install-curl-wide-prompt + assert_notification_stderr feature-prompt-skip + assert_release_requests 2 } # bats test_tags=platform,platform:macos -@test "sloctl suggests Homebrew upgrade for Homebrew installs" { +@test "sloctl runs Homebrew upgrade with the matching Homebrew executable" { if [ "$(uname -s)" != "Darwin" ]; then skip "native Homebrew compatibility is tested on macOS" fi @@ -375,14 +424,25 @@ teardown() { 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" { @@ -390,48 +450,54 @@ teardown() { 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 omits update command for unrecognized installs" { +@test "sloctl shows the installation guide when Go is unavailable" { use_release_body maintenance - select_update_action_without_update skip - local manual_binary="$BATS_TEST_TMPDIR/manual/sloctl" - copy_sloctl_binary "$manual_binary" + 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_tty_stderr "$manual_binary" version + run_sloctl_binary_with_path "$go_binary" "$empty_path" version assert_success_joined_output - assert_notification_stderr no-install-command-prompt + assert_sloctl_version_output + assert_notification_stderr version-prompt-skip } -@test "sloctl falls back to wget when curl is unavailable" { +@test "sloctl shows the installation guide when the matching Homebrew is unavailable" { use_release_body maintenance - local tools_dir="$BATS_TEST_TMPDIR/tools" - mkdir -p "$tools_dir" - touch "$tools_dir/wget" - chmod +x "$tools_dir/wget" + 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_path /usr/local/bin/sloctl "$tools_dir" version + run_sloctl_binary_with_tty_stderr "$cellar_binary" version assert_success_joined_output - assert_notification_stderr install-wget-prompt + assert_sloctl_version_output + assert_notification_stderr version-prompt-skip } -@test "sloctl omits update command when no downloader is available" { +@test "sloctl shows installation guide for unrecognized installs" { use_release_body maintenance - select_update_action_without_update skip - local tools_dir="$BATS_TEST_TMPDIR/tools" - mkdir -p "$tools_dir" + 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_path /usr/local/bin/sloctl "$tools_dir" version + run_sloctl_binary_with_tty_stderr "$manual_binary" version assert_success_joined_output - assert_notification_stderr no-install-command-prompt + assert_sloctl_version_output + assert_notification_stderr version-prompt-skip } assert_notification_stderr() { @@ -443,7 +509,15 @@ assert_notification_stderr() { } normalize_tty_output() { - sed -e 's/\r//g' -e 's/[[:blank:]]$//' + 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() { @@ -468,20 +542,6 @@ select_update_action() { esac } -select_update_action_without_update() { - case "$1" in - skip) - export SLOCTL_TEST_TTY_INPUT=$'1\n' - ;; - skip-until-next-version) - export SLOCTL_TEST_TTY_INPUT=$'2\n' - ;; - *) - fail "unknown update action without update command: $1" - ;; - esac -} - select_default_update_action() { export SLOCTL_TEST_TTY_INPUT=$'\n' } @@ -501,14 +561,6 @@ run_sloctl_binary_with_tty_stderr() { run --separate-stderr python3 "$TEST_INPUTS/run_with_stderr_pty.py" "$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" "$@" -} - run_sloctl_binary_in_windows_console_with_path() { local binary="$1" local path="$2" @@ -525,12 +577,12 @@ run_sloctl_binary_in_windows_console_with_path() { "$python" "$helper" "$binary" "$@" } -run_sloctl_binary_with_prefixed_path() { +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:$PATH" /usr/bin/python3 "$TEST_INPUTS/run_with_stderr_pty.py" "$binary" "$@" + run --separate-stderr env PATH="$path" /usr/bin/python3 "$TEST_INPUTS/run_with_stderr_pty.py" "$binary" "$@" } copy_sloctl_binary() { @@ -590,9 +642,14 @@ stop_release_server() { } 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": "2000-01-01T00:00:00Z"/' "$cache_file" - assert_equal "$(jq -r '.lastCheckedAt' "$cache_file")" "2000-01-01T00:00:00Z" + sed -i 's/"lastCheckedAt": "[^"]*"/"lastCheckedAt": "'"$timestamp"'"/' "$cache_file" + assert_equal "$(jq -r '.lastCheckedAt' "$cache_file")" "$timestamp" } assert_release_requests() { diff --git a/test/outputs/notifications/breaking-prompt-skip.stderr b/test/outputs/notifications/breaking-prompt-skip.stderr index fe7f5e58..6aa8d343 100644 --- a/test/outputs/notifications/breaking-prompt-skip.stderr +++ b/test/outputs/notifications/breaking-prompt-skip.stderr @@ -6,10 +6,6 @@ 📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 -──────────────────────────────────────────────────────────────────────────────────────────── +Installation options: https://github.com/nobl9/sloctl#install -Choose update action -1. Update (runs wget -O - -q https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash) -2. Skip -3. Skip until next version -Enter a number between 1 and 3: +──────────────────────────────────────────────────────────────────────────────────────────── diff --git a/test/outputs/notifications/bug-fix-prompt-skip.stderr b/test/outputs/notifications/bug-fix-prompt-skip.stderr index 6bf9e3dc..44271745 100644 --- a/test/outputs/notifications/bug-fix-prompt-skip.stderr +++ b/test/outputs/notifications/bug-fix-prompt-skip.stderr @@ -6,10 +6,6 @@ 📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 -──────────────────────────────────────────────────────────────────────────────────────────── +Installation options: https://github.com/nobl9/sloctl#install -Choose update action -1. Update (runs wget -O - -q https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash) -2. Skip -3. Skip until next version -Enter a number between 1 and 3: +──────────────────────────────────────────────────────────────────────────────────────────── diff --git a/test/outputs/notifications/failed-command-after-skip.stderr b/test/outputs/notifications/failed-command-after-skip.stderr index fbf2ec5f..1a2cd83f 100644 --- a/test/outputs/notifications/failed-command-after-skip.stderr +++ b/test/outputs/notifications/failed-command-after-skip.stderr @@ -6,11 +6,8 @@ 📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 +Installation options: https://github.com/nobl9/sloctl#install + ──────────────────────────────────────────────────────────────────────────────────────────── -Choose update action -1. Update (runs wget -O - -q https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash) -2. Skip -3. Skip until next version -Enter a number between 1 and 3: 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 index 3e134983..391c50e2 100644 --- a/test/outputs/notifications/feature-prompt-skip-until-next-version.stderr +++ b/test/outputs/notifications/feature-prompt-skip-until-next-version.stderr @@ -9,7 +9,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────── Choose update action -1. Update (runs wget -O - -q https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash) +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 index 3e134983..585187ab 100644 --- a/test/outputs/notifications/feature-prompt-skip.stderr +++ b/test/outputs/notifications/feature-prompt-skip.stderr @@ -6,10 +6,6 @@ 📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 -──────────────────────────────────────────────────────────────────────────────────────────── +Installation options: https://github.com/nobl9/sloctl#install -Choose update action -1. Update (runs wget -O - -q https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash) -2. Skip -3. Skip until next version -Enter a number between 1 and 3: +──────────────────────────────────────────────────────────────────────────────────────────── diff --git a/test/outputs/notifications/feature-without-author-prompt-skip.stderr b/test/outputs/notifications/feature-without-author-prompt-skip.stderr index c3ac1e71..1af142ba 100644 --- a/test/outputs/notifications/feature-without-author-prompt-skip.stderr +++ b/test/outputs/notifications/feature-without-author-prompt-skip.stderr @@ -6,10 +6,6 @@ 📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 -──────────────────────────────────────────────────────────────────────────────────────────── +Installation options: https://github.com/nobl9/sloctl#install -Choose update action -1. Update (runs wget -O - -q https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash) -2. Skip -3. Skip until next version -Enter a number between 1 and 3: +──────────────────────────────────────────────────────────────────────────────────────────── diff --git a/test/outputs/notifications/features-with-details-prompt-skip.stderr b/test/outputs/notifications/features-with-details-prompt-skip.stderr index 3f4b31a6..1fbc9dfa 100644 --- a/test/outputs/notifications/features-with-details-prompt-skip.stderr +++ b/test/outputs/notifications/features-with-details-prompt-skip.stderr @@ -16,10 +16,6 @@ 📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 -──────────────────────────────────────────────────────────────────────────────────────────── +Installation options: https://github.com/nobl9/sloctl#install -Choose update action -1. Update (runs wget -O - -q https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash) -2. Skip -3. Skip until next version -Enter a number between 1 and 3: +──────────────────────────────────────────────────────────────────────────────────────────── diff --git a/test/outputs/notifications/fixed-vulnerabilities-prompt-skip.stderr b/test/outputs/notifications/fixed-vulnerabilities-prompt-skip.stderr index d42d08ca..7234ed8f 100644 --- a/test/outputs/notifications/fixed-vulnerabilities-prompt-skip.stderr +++ b/test/outputs/notifications/fixed-vulnerabilities-prompt-skip.stderr @@ -6,10 +6,6 @@ 📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 -──────────────────────────────────────────────────────────────────────────────────────────── +Installation options: https://github.com/nobl9/sloctl#install -Choose update action -1. Update (runs wget -O - -q https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash) -2. Skip -3. Skip until next version -Enter a number between 1 and 3: +──────────────────────────────────────────────────────────────────────────────────────────── diff --git a/test/outputs/notifications/install-wget-prompt.stderr b/test/outputs/notifications/install-wget-prompt.stderr deleted file mode 100644 index 529130b6..00000000 --- a/test/outputs/notifications/install-wget-prompt.stderr +++ /dev/null @@ -1,11 +0,0 @@ -New sloctl version v1.1.0 is available! - -📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 - -──────────────────────────────────────────────────────────────────────────────────────────── - -Choose update action -1. Update (runs wget -O - -q https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash) -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 index 8a600a13..477290e0 100644 --- a/test/outputs/notifications/next-version-prompt-skip-until-next-version.stderr +++ b/test/outputs/notifications/next-version-prompt-skip-until-next-version.stderr @@ -9,7 +9,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────── Choose update action -1. Update (runs wget -O - -q https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash) +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/no-install-command-prompt.stderr b/test/outputs/notifications/no-install-command-prompt.stderr deleted file mode 100644 index 910bf23e..00000000 --- a/test/outputs/notifications/no-install-command-prompt.stderr +++ /dev/null @@ -1,10 +0,0 @@ -New sloctl version v1.1.0 is available! - -📜 https://github.com/nobl9/sloctl/releases/tag/v1.1.0 - -──────────────────────────────────────────────────────────────────────────────────────────── - -Choose update action -1. Skip -2. Skip until next version -Enter a number between 1 and 2: diff --git a/test/outputs/notifications/version-prompt-failed-upgrade.stderr b/test/outputs/notifications/version-prompt-failed-upgrade.stderr index 74326b5f..ea870631 100644 --- a/test/outputs/notifications/version-prompt-failed-upgrade.stderr +++ b/test/outputs/notifications/version-prompt-failed-upgrade.stderr @@ -5,7 +5,7 @@ New sloctl version v1.1.0 is available! ──────────────────────────────────────────────────────────────────────────────────────────── Choose update action -1. Update (runs curl -fsSL https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash) +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-run-upgrade.stderr b/test/outputs/notifications/version-prompt-run-upgrade.stderr index dff74c3d..1a8b8499 100644 --- a/test/outputs/notifications/version-prompt-run-upgrade.stderr +++ b/test/outputs/notifications/version-prompt-run-upgrade.stderr @@ -5,7 +5,7 @@ New sloctl version v1.1.0 is available! ──────────────────────────────────────────────────────────────────────────────────────────── Choose update action -1. Update (runs curl -fsSL https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash) +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-curl-wide-prompt.stderr b/test/outputs/notifications/version-prompt-skip-until-cache-error.stderr similarity index 66% rename from test/outputs/notifications/install-curl-wide-prompt.stderr rename to test/outputs/notifications/version-prompt-skip-until-cache-error.stderr index dff74c3d..b226cfb8 100644 --- a/test/outputs/notifications/install-curl-wide-prompt.stderr +++ b/test/outputs/notifications/version-prompt-skip-until-cache-error.stderr @@ -5,7 +5,8 @@ New sloctl version v1.1.0 is available! ──────────────────────────────────────────────────────────────────────────────────────────── Choose update action -1. Update (runs curl -fsSL https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash) +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 index 529130b6..7b6c4652 100644 --- a/test/outputs/notifications/version-prompt-skip.stderr +++ b/test/outputs/notifications/version-prompt-skip.stderr @@ -2,10 +2,6 @@ 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 -Choose update action -1. Update (runs wget -O - -q https://raw.githubusercontent.com/nobl9/sloctl/main/install.bash | bash) -2. Skip -3. Skip until next version -Enter a number between 1 and 3: +────────────────────────────────────────────────────────────────────────────────────────────