From d743422d28f3898706065cc81beb2bb7cedf5008 Mon Sep 17 00:00:00 2001 From: Stefan Koelle Date: Sat, 22 Aug 2026 09:25:28 +0200 Subject: [PATCH 1/5] feat: add self-update command Add 'kctl-tui update' subcommand that checks GitHub Releases for new versions and replaces the running binary. Uses github.com/creativeprojects/go-selfupdate for safe binary replacement with automatic OS/arch detection. Supports --verbose flag for detailed update progress logging. --- cmd/kctl-tui/main.go | 8 ++++ cmd/kctl-tui/update.go | 72 +++++++++++++++++++++++++++++++ go.mod | 23 ++++++++-- go.sum | 98 +++++++++++++++++++++++++++++++----------- 4 files changed, 172 insertions(+), 29 deletions(-) create mode 100644 cmd/kctl-tui/update.go diff --git a/cmd/kctl-tui/main.go b/cmd/kctl-tui/main.go index e32691a..679e997 100644 --- a/cmd/kctl-tui/main.go +++ b/cmd/kctl-tui/main.go @@ -70,6 +70,12 @@ func main() { os.Exit(1) } return + case "update": + if err := runUpdate(verbose); err != nil { + fmt.Fprintln(os.Stderr, "kctl-tui update error:", err) + os.Exit(1) + } + return default: fmt.Fprintf(os.Stderr, "unknown command: %s\n\n", filtered[0]) printUsage() @@ -92,6 +98,7 @@ https://github.com/skoelle/kctl-tui Usage: kctl-tui [flags] start the TUI (full navigation mode) kctl-tui doctor check tools, config and connections + kctl-tui update update to the latest release kctl-tui config check validate ~/.kctl-tui/config.yaml kctl-tui panel [options] control pane (called internally by tmux) @@ -103,6 +110,7 @@ Flags: Examples: kctl-tui # start the TUI kctl-tui doctor # verify everything is installed + kctl-tui update # update to the latest version kctl-tui --verbose 2>debug.log # log commands to a file kctl-tui config check # validate config `) diff --git a/cmd/kctl-tui/update.go b/cmd/kctl-tui/update.go new file mode 100644 index 0000000..b7d519e --- /dev/null +++ b/cmd/kctl-tui/update.go @@ -0,0 +1,72 @@ +// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de) +// Licensed under the MIT License. See LICENSE file in project root for details. + +package main + +import ( + "context" + "fmt" + "log" + "os" + + "github.com/creativeprojects/go-selfupdate" +) + +const githubSlug = "skoelle/kctl-tui" + +func runUpdate(verbose bool) error { + if verbose { + selfupdate.SetLogger(&verboseLogger{}) + } + + current := version + if current == "dev" { + fmt.Fprintln(os.Stderr, "WARNING: running dev build — cannot compare versions") + fmt.Println("Skipping version check. Build from a tagged release to enable self-update.") + return nil + } + + fmt.Printf("Current version: %s\n", current) + fmt.Println("Checking for updates...") + + source, err := selfupdate.NewGitHubSource(selfupdate.GitHubConfig{}) + if err != nil { + return fmt.Errorf("failed to init GitHub source: %w", err) + } + + updater, err := selfupdate.NewUpdater(selfupdate.Config{ + Source: source, + }) + if err != nil { + return fmt.Errorf("failed to create updater: %w", err) + } + + repo := selfupdate.ParseSlug(githubSlug) + + rel, err := updater.UpdateSelf(context.Background(), current, repo) + if err != nil { + return fmt.Errorf("update failed: %w", err) + } + + if rel != nil && rel.Version() != current { + fmt.Printf("Updated from %s to %s\n", current, rel.Version()) + } else { + fmt.Println("Already up-to-date.") + } + + return nil +} + +type verboseLogger struct{} + +func (l *verboseLogger) Print(v ...any) { + fmt.Fprint(os.Stderr, v...) +} + +func (l *verboseLogger) Printf(format string, v ...any) { + fmt.Fprintf(os.Stderr, format, v...) +} + +func init() { + log.SetOutput(os.Stderr) +} diff --git a/go.mod b/go.mod index d9ac9da..360dc7d 100644 --- a/go.mod +++ b/go.mod @@ -1,14 +1,18 @@ module github.com/skoelle/kctl-tui -go 1.24.2 +go 1.25.12 require ( github.com/charmbracelet/bubbles v0.21.1 github.com/charmbracelet/bubbletea v1.3.10 + github.com/creativeprojects/go-selfupdate v1.6.0 gopkg.in/yaml.v3 v3.0.1 ) require ( + code.gitea.io/sdk/gitea v0.23.2 // indirect + github.com/42wim/httpsig v1.2.4 // indirect + github.com/Masterminds/semver/v3 v3.5.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect @@ -19,7 +23,14 @@ require ( github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/davidmz/go-pageant v1.0.2 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/go-fed/httpsig v1.1.0 // indirect + github.com/google/go-github/v86 v86.0.0 // indirect + github.com/google/go-querystring v1.2.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect + github.com/hashicorp/go-version v1.9.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect @@ -29,8 +40,12 @@ require ( github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect + github.com/ulikunitz/xz v0.5.15 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sync v0.8.0 // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/text v0.3.8 // indirect + gitlab.com/gitlab-org/api/client-go v1.46.0 // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.38.0 // indirect + golang.org/x/time v0.15.0 // indirect ) diff --git a/go.sum b/go.sum index 82c9a26..0dc5bed 100644 --- a/go.sum +++ b/go.sum @@ -1,29 +1,29 @@ +code.gitea.io/sdk/gitea v0.23.2 h1:iJB1FDmLegwfwjX8gotBDHdPSbk/ZR8V9VmEJaVsJYg= +code.gitea.io/sdk/gitea v0.23.2/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM= +github.com/42wim/httpsig v1.2.4 h1:mI5bH0nm4xn7K18fo1K3okNDRq8CCJ0KbBYWyA6r8lU= +github.com/42wim/httpsig v1.2.4/go.mod h1:yKsYfSyTBEohkPik224QPFylmzEBtda/kjyIAJjh3ps= +github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= +github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= 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/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= -github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/charmbracelet/bubbles v0.21.1 h1:nj0decPiixaZeL9diI4uzzQTkkz1kYY8+jgzCZXSmW0= github.com/charmbracelet/bubbles v0.21.1/go.mod h1:HHvIYRCpbkCJw2yo0vNX1O5loCwSr9/mWS8GYSg50Sk= -github.com/charmbracelet/bubbletea v1.1.1 h1:KJ2/DnmpfqFtDNVTvYZ6zpPFL9iRCRr0qqKOCvppbPY= -github.com/charmbracelet/bubbletea v1.1.1/go.mod h1:9Ogk0HrdbHolIKHdjfFpyXJmiCzGwy+FesYkZr7hYU4= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= -github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg= -github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.4.2 h1:0JM6Aj/g/KC154/gOP4vfxun0ff6itogDYk41kof+qk= -github.com/charmbracelet/x/ansi v0.4.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= github.com/charmbracelet/x/ansi v0.11.5 h1:NBWeBpj/lJPE3Q5l+Lusa4+mH6v7487OP8K0r1IhRg4= github.com/charmbracelet/x/ansi v0.11.5/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= 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/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0= -github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= 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/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= @@ -32,47 +32,95 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/creativeprojects/go-selfupdate v1.6.0 h1:Bu3cIgdyfI1Pg8XsL8nbaT2uMjfZ8HIoxnBmPJbN0sw= +github.com/creativeprojects/go-selfupdate v1.6.0/go.mod h1:Ids8O474XGQG0jZ5vpBIhWffcGYjUP6ccOI0mMcvQbI= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0= +github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI= +github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +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/go-github/v86 v86.0.0 h1:S/6aANJhwRm8EQmGKVML3j41yq0h2BsTP8FnDkO7kcA= +github.com/google/go-github/v86 v86.0.0/go.mod h1:zKv1l4SwDXNFMGByi2FWkq71KwSXqj/eQRZuqtmcot8= +github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= +github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= +github.com/graph-gophers/graphql-go v1.9.0 h1:yu0ucKHLc5qGpRwLYKIWtr9bOoxovkWasuBrPQwlHls= +github.com/graph-gophers/graphql-go v1.9.0/go.mod h1:23olKZ7duEvHlF/2ELEoSZaY1aNPfShjP782SOoNTyM= +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/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= +github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 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-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= -github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= 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/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +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/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= +github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= 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= -golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= -golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +gitlab.com/gitlab-org/api/client-go v1.46.0 h1:YxBWFZIFYKcGESCb9fpkwzouo+apyB9pr/XTWzNoL24= +gitlab.com/gitlab-org/api/client-go v1.46.0/go.mod h1:FtgyU6g2HS5+fMhw6nLK96GBEEBx5MzntOiJWfIaiN8= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE= +golang.org/x/exp v0.0.0-20250813145105-42675adae3e6/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= -golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +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.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 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/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= From d89efc55d999fde52ef6a9bbf6ae312dfe4bf96b Mon Sep 17 00:00:00 2001 From: Stefan Koelle Date: Sat, 22 Aug 2026 09:33:41 +0200 Subject: [PATCH 2/5] feat: add interactive update check on startup When starting kctl-tui without a subcommand, check GitHub Releases for a newer version and prompt the user to update. - Silent skip for dev builds and network errors - Prompt: 'Update now? [y/N]' - On 'y': apply update, then start TUI - On 'N'/Enter: start TUI immediately --- cmd/kctl-tui/main.go | 2 + cmd/kctl-tui/update.go | 115 ++++++++++++++++++++++++++++++++++++----- go.mod | 2 +- 3 files changed, 104 insertions(+), 15 deletions(-) diff --git a/cmd/kctl-tui/main.go b/cmd/kctl-tui/main.go index 679e997..a97520c 100644 --- a/cmd/kctl-tui/main.go +++ b/cmd/kctl-tui/main.go @@ -83,6 +83,8 @@ func main() { } } + checkForUpdateInteractive(verbose) + m := newFullModel() p := tea.NewProgram(m, tea.WithAltScreen()) if _, err := p.Run(); err != nil { diff --git a/cmd/kctl-tui/update.go b/cmd/kctl-tui/update.go index b7d519e..a4f4591 100644 --- a/cmd/kctl-tui/update.go +++ b/cmd/kctl-tui/update.go @@ -4,11 +4,14 @@ package main import ( + "bufio" "context" "fmt" "log" "os" + "strings" + "github.com/Masterminds/semver/v3" "github.com/creativeprojects/go-selfupdate" ) @@ -29,6 +32,103 @@ func runUpdate(verbose bool) error { fmt.Printf("Current version: %s\n", current) fmt.Println("Checking for updates...") + rel, err := detectLatest() + if err != nil { + return fmt.Errorf("failed to check for updates: %w", err) + } + + if rel == nil { + fmt.Println("Already up-to-date.") + return nil + } + + fmt.Printf("Found version %s. Updating...\n", rel.Version()) + + if err := applyUpdate(rel); err != nil { + return fmt.Errorf("update failed: %w", err) + } + + fmt.Printf("Updated from %s to %s\n", current, rel.Version()) + return nil +} + +// checkForUpdateInteractive checks for a new version and prompts the user to update. +// Returns true if an update was applied. +func checkForUpdateInteractive(verbose bool) bool { + if version == "dev" { + return false + } + + if verbose { + selfupdate.SetLogger(&verboseLogger{}) + } + + rel, err := detectLatest() + if err != nil { + // Silently ignore network errors — don't block startup + if verbose { + fmt.Fprintf(os.Stderr, "Update check failed: %v\n", err) + } + return false + } + + if rel == nil { + return false + } + + current, _ := semver.NewVersion(version) + newVersion := rel.Version() + + if current != nil && !rel.GreaterThan(current.String()) { + return false + } + + fmt.Printf("New version %s available (current: %s). Update now? [y/N] ", newVersion, version) + + reader := bufio.NewReader(os.Stdin) + answer, _ := reader.ReadString('\n') + answer = strings.TrimSpace(strings.ToLower(answer)) + + if answer != "y" && answer != "yes" { + return false + } + + fmt.Println("Updating...") + if err := applyUpdate(rel); err != nil { + fmt.Fprintf(os.Stderr, "Update failed: %v\n", err) + return false + } + + fmt.Printf("Updated to %s. Starting kctl-tui...\n", newVersion) + return true +} + +func detectLatest() (*selfupdate.Release, error) { + source, err := selfupdate.NewGitHubSource(selfupdate.GitHubConfig{}) + if err != nil { + return nil, fmt.Errorf("failed to init GitHub source: %w", err) + } + + updater, err := selfupdate.NewUpdater(selfupdate.Config{ + Source: source, + }) + if err != nil { + return nil, fmt.Errorf("failed to create updater: %w", err) + } + + repo := selfupdate.ParseSlug(githubSlug) + rel, found, err := updater.DetectLatest(context.Background(), repo) + if err != nil { + return nil, err + } + if !found { + return nil, nil + } + + return rel, nil +} + +func applyUpdate(rel *selfupdate.Release) error { source, err := selfupdate.NewGitHubSource(selfupdate.GitHubConfig{}) if err != nil { return fmt.Errorf("failed to init GitHub source: %w", err) @@ -41,20 +141,7 @@ func runUpdate(verbose bool) error { return fmt.Errorf("failed to create updater: %w", err) } - repo := selfupdate.ParseSlug(githubSlug) - - rel, err := updater.UpdateSelf(context.Background(), current, repo) - if err != nil { - return fmt.Errorf("update failed: %w", err) - } - - if rel != nil && rel.Version() != current { - fmt.Printf("Updated from %s to %s\n", current, rel.Version()) - } else { - fmt.Println("Already up-to-date.") - } - - return nil + return updater.UpdateTo(context.Background(), rel, "") } type verboseLogger struct{} diff --git a/go.mod b/go.mod index 360dc7d..1abc16a 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/skoelle/kctl-tui go 1.25.12 require ( + github.com/Masterminds/semver/v3 v3.5.0 github.com/charmbracelet/bubbles v0.21.1 github.com/charmbracelet/bubbletea v1.3.10 github.com/creativeprojects/go-selfupdate v1.6.0 @@ -12,7 +13,6 @@ require ( require ( code.gitea.io/sdk/gitea v0.23.2 // indirect github.com/42wim/httpsig v1.2.4 // indirect - github.com/Masterminds/semver/v3 v3.5.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect From fed2b44c288d8f8964c6a5b02e7ec8db8a988efa Mon Sep 17 00:00:00 2001 From: Stefan Koelle Date: Sat, 22 Aug 2026 09:50:27 +0200 Subject: [PATCH 3/5] fix: upgrade CI to Go 1.25 and document self-update feature - Upgrade go-version from 1.24 to 1.25 in build.yml (required by go-selfupdate v1.6.0) - Add kctl-tui update command and interactive startup check to README.md - Document new features in CHANGELOG.md - Update Go version requirement to 1.25+ in README.md and CONTRIBUTING.md - Add Phase 5 (Self-update) to PLAN.md roadmap --- .github/workflows/build.yml | 4 ++-- CHANGELOG.md | 6 ++++++ CONTRIBUTING.md | 2 +- PLAN.md | 8 ++++++++ README.md | 7 ++++++- 5 files changed, 23 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2a8cbcc..0d2b55b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,7 +19,7 @@ jobs: - uses: actions/setup-go@v7 with: - go-version: "1.24" + go-version: "1.25" cache: false - name: Tidy dependencies (generates/updates go.sum) @@ -44,7 +44,7 @@ jobs: - uses: actions/setup-go@v7 with: - go-version: "1.24" + go-version: "1.25" cache: false - name: Tidy dependencies (generates/updates go.sum) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d73060..37fcdb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +### Added +- `kctl-tui update` — self-update command that downloads the latest release + from GitHub and replaces the running binary +- Automatic update check on startup — prompts to update interactively when + a newer version is available + ## [0.2.0] - 2026-08-09 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5e5e03d..3bf4dce 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,7 +13,7 @@ go mod download ### Prerequisites -- Go 1.22+ +- Go 1.25+ - kubectl, k9s, tmux (or psmux on Windows) - An active Kubernetes cluster for integration testing diff --git a/PLAN.md b/PLAN.md index 1a72a2e..6c1cf13 100644 --- a/PLAN.md +++ b/PLAN.md @@ -77,6 +77,14 @@ is still open. For the full requirements, see [SPEC.md](SPEC.md). - [x] `--help` flag with full usage documentation. - [x] CHANGELOG.md, CONTRIBUTING.md, GitHub Issue/PR templates. +## Phase 5 — Self-update (done) + +- [x] `kctl-tui update` — self-update command using `go-selfupdate` library. + Downloads the matching OS/arch asset from GitHub Releases and replaces + the running binary atomically. +- [x] Interactive update check on startup — queries GitHub Releases in the + background, prompts the user to update when a newer version is found. + --- ## Bugfix Sprint — between v0.2.0 and v0.3.0 diff --git a/README.md b/README.md index 206e0b2..a36cda4 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ go build -o kctl-tui ./cmd/kctl-tui sudo mv kctl-tui /usr/local/bin/ ``` -Requires Go 1.22+. +Requires Go 1.25+. ### 📦 Prebuilt binaries @@ -203,11 +203,16 @@ kctl-tui # start the TUI (full navigation mode) kctl-tui --help # show all commands and flags kctl-tui --version # print version kctl-tui --verbose # enable debug logging to stderr +kctl-tui update # update to the latest release kctl-tui doctor # check if all tools, config and connections are OK kctl-tui config check # validate ~/.kctl-tui/config.yaml kctl-tui panel --context=... --ns=... --team=... # internal (called by tmux) ``` +When starting `kctl-tui` without a subcommand, a background check queries +GitHub Releases for a newer version. If one is found, you are prompted to +update interactively before the TUI starts. + ## 🛠️ Development ```bash From 7f2c9b2ba80c08af45e64079acf978c821fc7f49 Mon Sep 17 00:00:00 2001 From: Stefan Koelle Date: Sat, 22 Aug 2026 10:08:48 +0200 Subject: [PATCH 4/5] refactor: address code review findings for self-update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - go.mod: 1.25.12 → 1.25 (fix nonexistent patch version) - Remove init() log mutation, pass logger explicitly via initUpdater() - Add 10s HTTP timeout via context.WithTimeout on API calls - Fix version comparison using proper semver (current.LessThan(newVer)) - Extract shared initUpdater() helper to eliminate duplicate creation - Dev build warning now goes to stderr consistently - Exit with code 0 after successful interactive update (binary replaced) --- cmd/kctl-tui/main.go | 4 +- cmd/kctl-tui/update.go | 129 +++++++++++++++++++---------------------- 2 files changed, 63 insertions(+), 70 deletions(-) diff --git a/cmd/kctl-tui/main.go b/cmd/kctl-tui/main.go index a97520c..6c1e386 100644 --- a/cmd/kctl-tui/main.go +++ b/cmd/kctl-tui/main.go @@ -83,7 +83,9 @@ func main() { } } - checkForUpdateInteractive(verbose) + if checkForUpdateInteractive(verbose) { + os.Exit(0) + } m := newFullModel() p := tea.NewProgram(m, tea.WithAltScreen()) diff --git a/cmd/kctl-tui/update.go b/cmd/kctl-tui/update.go index a4f4591..7e5a940 100644 --- a/cmd/kctl-tui/update.go +++ b/cmd/kctl-tui/update.go @@ -7,48 +7,77 @@ import ( "bufio" "context" "fmt" - "log" "os" "strings" + "time" "github.com/Masterminds/semver/v3" "github.com/creativeprojects/go-selfupdate" ) -const githubSlug = "skoelle/kctl-tui" +const ( + githubSlug = "skoelle/kctl-tui" + updateTimeout = 10 * time.Second +) -func runUpdate(verbose bool) error { +func initUpdater(verbose bool) (*selfupdate.Updater, error) { if verbose { selfupdate.SetLogger(&verboseLogger{}) } - current := version - if current == "dev" { + source, err := selfupdate.NewGitHubSource(selfupdate.GitHubConfig{}) + if err != nil { + return nil, fmt.Errorf("failed to init GitHub source: %w", err) + } + + return selfupdate.NewUpdater(selfupdate.Config{ + Source: source, + }) +} + +func runUpdate(verbose bool) error { + if version == "dev" { fmt.Fprintln(os.Stderr, "WARNING: running dev build — cannot compare versions") - fmt.Println("Skipping version check. Build from a tagged release to enable self-update.") + fmt.Fprintln(os.Stderr, "Skipping version check. Build from a tagged release to enable self-update.") return nil } - fmt.Printf("Current version: %s\n", current) - fmt.Println("Checking for updates...") + updater, err := initUpdater(verbose) + if err != nil { + return err + } - rel, err := detectLatest() + ctx, cancel := context.WithTimeout(context.Background(), updateTimeout) + defer cancel() + + repo := selfupdate.ParseSlug(githubSlug) + rel, found, err := updater.DetectLatest(ctx, repo) if err != nil { return fmt.Errorf("failed to check for updates: %w", err) } - - if rel == nil { + if !found { fmt.Println("Already up-to-date.") return nil } - fmt.Printf("Found version %s. Updating...\n", rel.Version()) + current, _ := semver.NewVersion(version) + newVersion := rel.Version() - if err := applyUpdate(rel); err != nil { + newVer, _ := semver.NewVersion(newVersion) + + if current != nil && !current.LessThan(newVer) { + fmt.Println("Already up-to-date.") + return nil + } + + fmt.Printf("Current version: %s\n", version) + fmt.Printf("Found version %s. Updating...\n", newVersion) + + if err := updater.UpdateTo(ctx, rel, ""); err != nil { return fmt.Errorf("update failed: %w", err) } - fmt.Printf("Updated from %s to %s\n", current, rel.Version()) + fmt.Printf("Updated from %s to %s\n", current, newVersion) return nil } @@ -59,27 +88,34 @@ func checkForUpdateInteractive(verbose bool) bool { return false } - if verbose { - selfupdate.SetLogger(&verboseLogger{}) - } - - rel, err := detectLatest() + updater, err := initUpdater(verbose) if err != nil { - // Silently ignore network errors — don't block startup if verbose { fmt.Fprintf(os.Stderr, "Update check failed: %v\n", err) } return false } - if rel == nil { + ctx, cancel := context.WithTimeout(context.Background(), updateTimeout) + defer cancel() + + repo := selfupdate.ParseSlug(githubSlug) + rel, found, err := updater.DetectLatest(ctx, repo) + if err != nil { + if verbose { + fmt.Fprintf(os.Stderr, "Update check failed: %v\n", err) + } + return false + } + if !found { return false } current, _ := semver.NewVersion(version) newVersion := rel.Version() + newVer, _ := semver.NewVersion(newVersion) - if current != nil && !rel.GreaterThan(current.String()) { + if current != nil && !current.LessThan(newVer) { return false } @@ -94,56 +130,15 @@ func checkForUpdateInteractive(verbose bool) bool { } fmt.Println("Updating...") - if err := applyUpdate(rel); err != nil { + if err := updater.UpdateTo(ctx, rel, ""); err != nil { fmt.Fprintf(os.Stderr, "Update failed: %v\n", err) return false } - fmt.Printf("Updated to %s. Starting kctl-tui...\n", newVersion) + fmt.Printf("Updated to %s. Please restart kctl-tui.\n", newVersion) return true } -func detectLatest() (*selfupdate.Release, error) { - source, err := selfupdate.NewGitHubSource(selfupdate.GitHubConfig{}) - if err != nil { - return nil, fmt.Errorf("failed to init GitHub source: %w", err) - } - - updater, err := selfupdate.NewUpdater(selfupdate.Config{ - Source: source, - }) - if err != nil { - return nil, fmt.Errorf("failed to create updater: %w", err) - } - - repo := selfupdate.ParseSlug(githubSlug) - rel, found, err := updater.DetectLatest(context.Background(), repo) - if err != nil { - return nil, err - } - if !found { - return nil, nil - } - - return rel, nil -} - -func applyUpdate(rel *selfupdate.Release) error { - source, err := selfupdate.NewGitHubSource(selfupdate.GitHubConfig{}) - if err != nil { - return fmt.Errorf("failed to init GitHub source: %w", err) - } - - updater, err := selfupdate.NewUpdater(selfupdate.Config{ - Source: source, - }) - if err != nil { - return fmt.Errorf("failed to create updater: %w", err) - } - - return updater.UpdateTo(context.Background(), rel, "") -} - type verboseLogger struct{} func (l *verboseLogger) Print(v ...any) { @@ -153,7 +148,3 @@ func (l *verboseLogger) Print(v ...any) { func (l *verboseLogger) Printf(format string, v ...any) { fmt.Fprintf(os.Stderr, format, v...) } - -func init() { - log.SetOutput(os.Stderr) -} From c0ad7e803e211be790919b335fbc24216b285c08 Mon Sep 17 00:00:00 2001 From: Stefan Koelle Date: Sat, 22 Aug 2026 10:44:01 +0200 Subject: [PATCH 5/5] feat: add config opt-out, TTY check, and config-first update flow - Add auto_update_check config field (defaults to true) - Load config before update check, skip if auto_update_check is false - Add TTY check before interactive prompt (skip in non-TTY environments) - Update config.example.yaml with new option documentation --- cmd/kctl-tui/main.go | 14 ++++++++++++-- cmd/kctl-tui/update.go | 5 +++++ config.example.yaml | 4 ++++ go.mod | 1 + internal/config/config.go | 13 +++++++++++++ 5 files changed, 35 insertions(+), 2 deletions(-) diff --git a/cmd/kctl-tui/main.go b/cmd/kctl-tui/main.go index 6c1e386..e7745b4 100644 --- a/cmd/kctl-tui/main.go +++ b/cmd/kctl-tui/main.go @@ -83,8 +83,18 @@ func main() { } } - if checkForUpdateInteractive(verbose) { - os.Exit(0) + // Load config early to check auto_update_update and validate. + cfgPath, err := config.DefaultPath() + if err == nil { + cfg, cfgErr := config.Load(cfgPath) + if cfgErr != nil { + fmt.Fprintf(os.Stderr, "WARNING: failed to load config: %v\n", cfgErr) + } + if cfgErr == nil && cfg.IsAutoUpdateCheckEnabled() { + if checkForUpdateInteractive(verbose) { + os.Exit(0) + } + } } m := newFullModel() diff --git a/cmd/kctl-tui/update.go b/cmd/kctl-tui/update.go index 7e5a940..c9572e2 100644 --- a/cmd/kctl-tui/update.go +++ b/cmd/kctl-tui/update.go @@ -13,6 +13,7 @@ import ( "github.com/Masterminds/semver/v3" "github.com/creativeprojects/go-selfupdate" + "golang.org/x/term" ) const ( @@ -88,6 +89,10 @@ func checkForUpdateInteractive(verbose bool) bool { return false } + if !term.IsTerminal(int(os.Stdin.Fd())) { + return false + } + updater, err := initUpdater(verbose) if err != nil { if verbose { diff --git a/config.example.yaml b/config.example.yaml index 3c281d1..8b98a44 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -60,3 +60,7 @@ team_label_key: "example.org/team" # wraps SSO login in a custom script or needs a specific --profile, e.g.: # aws_sso_login_command: "aws sso login --profile my-profile" aws_sso_login_command: "aws sso login" + +# Check for updates on startup and prompt to update if a newer version is +# available. Set to false to disable. Defaults to true if omitted. +# auto_update_check: true diff --git a/go.mod b/go.mod index 1abc16a..0e7e45d 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/charmbracelet/bubbles v0.21.1 github.com/charmbracelet/bubbletea v1.3.10 github.com/creativeprojects/go-selfupdate v1.6.0 + golang.org/x/term v0.44.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/internal/config/config.go b/internal/config/config.go index 63a15f3..d2eb388 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -62,6 +62,19 @@ type Config struct { // AWSSSOLoginCommand is run interactively if an AWS auth check fails // before the secrets workflow (e.g. an expired SSO session). AWSSSOLoginCommand string `yaml:"aws_sso_login_command"` + + // AutoUpdateCheck controls whether kctl-tui checks for updates on + // startup. Defaults to true when omitted. + AutoUpdateCheck *bool `yaml:"auto_update_check"` +} + +// IsAutoUpdateCheckEnabled returns true unless the user has explicitly set +// auto_update_check to false in their config. +func (c Config) IsAutoUpdateCheckEnabled() bool { + if c.AutoUpdateCheck == nil { + return true + } + return *c.AutoUpdateCheck } // LoginCommand returns the configured AWS SSO login command, falling back