From 4760d454b53d224015c19f91be332b27abdd0167 Mon Sep 17 00:00:00 2001 From: Philipp Trentmann Date: Mon, 11 May 2026 11:16:38 +0200 Subject: [PATCH] feat(update): add installer and startup update prompt --- README.md | 39 +++---- cmd/root.go | 6 ++ cmd/update_prompt.go | 62 +++++++++++ internal/update/restart_unix.go | 17 +++ internal/update/restart_windows.go | 10 ++ internal/update/update.go | 156 ++++++++++++++++++++++++++++ internal/update/update_test.go | 68 ++++++++++++ scripts/install.sh | 161 +++++++++++++++++++++++++++++ 8 files changed, 497 insertions(+), 22 deletions(-) create mode 100644 cmd/update_prompt.go create mode 100644 internal/update/restart_unix.go create mode 100644 internal/update/restart_windows.go create mode 100644 internal/update/update.go create mode 100644 internal/update/update_test.go create mode 100755 scripts/install.sh diff --git a/README.md b/README.md index 350cea3..85aad79 100644 --- a/README.md +++ b/README.md @@ -21,38 +21,22 @@ A neon-themed terminal dashboard that polls GitHub via the local `gh` CLI and sh ## Requirements -- Go 1.25+ to build - [`gh`](https://cli.github.com/) CLI installed and authenticated (`gh auth login`) +- Go 1.25+ only if building from source ## Install -### From a release +### Linux / macOS -Pre-built binaries for Linux, macOS, and Windows are published on the [Releases](https://github.com/bluegardenproject/github-butler/releases) page. Grab the asset for your platform, mark it executable, and drop it on `$PATH`: +Install the latest release with one command: ```bash -curl -L -o github-butler https://github.com/bluegardenproject/github-butler/releases/latest/download/github-butler-darwin-arm64 -chmod +x github-butler -mv github-butler /usr/local/bin/ +curl -fsSL https://raw.githubusercontent.com/bluegardenproject/github-butler/main/scripts/install.sh | bash ``` -(Replace `darwin-arm64` with `linux-amd64`, `linux-arm64`, `darwin-amd64`, or `windows-amd64.exe` as appropriate.) +The installer downloads the matching release asset into `~/.github-butler/` and adds that directory to your shell `PATH` if needed. -### From source - -Clone the repo and build a local binary: - -```bash -git clone https://github.com/bluegardenproject/github-butler.git -cd github-butler -make build -``` - -Or install straight into `$GOBIN` (usually `~/go/bin`): - -```bash -go install github.com/bluegardenproject/github-butler@latest -``` +Pre-built binaries are also published on the [Releases](https://github.com/bluegardenproject/github-butler/releases) page. Verify with `github-butler --version`. @@ -71,6 +55,14 @@ Then run the binary: github-butler ``` +If a newer release is available, `github-butler` prompts before opening the dashboard: + +```text +There is a new version v0.3.0 available. Update now Y/N +``` + +Answer `Y` to rerun the same install script, replace the local binary, and restart `github-butler` automatically. Answer `N` to continue with the current version. + ### First launch On the very first run there's no config file yet, so the dashboard opens empty. Add repos from inside the app: @@ -176,6 +168,7 @@ cmd/root.go # flag parsing + wiring, including --version internal/ config/ # YAML load/save/validate, defaults github/ # gh CLI wrapper, GraphQL query, pure derivation logic + update/ # latest-release check and install-script self-update ui/ app.go # root Bubble Tea model + screen routing messages.go # shared tea.Msg types @@ -188,6 +181,8 @@ internal/ settings.go # settings list + interval editor theme/ # neon palette, styles, gradient helper components/ # small reusable widgets (banner, countdown, toast, confirm) +scripts/ + install.sh # curl | bash release installer ``` ## Development diff --git a/cmd/root.go b/cmd/root.go index 9bee90c..ccfad2c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -60,6 +60,12 @@ func Run(ctx context.Context, args []string) error { _ = os.Setenv("NO_COLOR", "1") } + if updated, err := maybePromptForUpdate(ctx); err != nil { + return fmt.Errorf("update github-butler: %w", err) + } else if updated { + return nil + } + if _, err := exec.LookPath("gh"); err != nil { return fmt.Errorf("the GitHub CLI (`gh`) was not found in PATH; install it and run `gh auth login` first") } diff --git a/cmd/update_prompt.go b/cmd/update_prompt.go new file mode 100644 index 0000000..07337f0 --- /dev/null +++ b/cmd/update_prompt.go @@ -0,0 +1,62 @@ +package cmd + +import ( + "bufio" + "context" + "fmt" + "os" + "strings" + + "github.com/bluegardenproject/github-butler/internal/update" +) + +func maybePromptForUpdate(ctx context.Context) (bool, error) { + if version == "dev" || version == "unknown" { + return false, nil + } + if os.Getenv("NO_UPDATE_NOTIFIER") != "" { + return false, nil + } + if !update.Supported() { + return false, nil + } + if !isTerminal(os.Stdin) || !isTerminal(os.Stdout) { + return false, nil + } + + rel, err := update.LatestRelease(ctx) + if err != nil { + return false, nil + } + if update.Compare(version, rel.TagName) >= 0 { + return false, nil + } + + fmt.Fprintf(os.Stdout, "There is a new version %s available. Update now Y/N ", rel.TagName) + answer, err := bufio.NewReader(os.Stdin).ReadString('\n') + if err != nil { + fmt.Fprintln(os.Stdout) + return false, nil + } + + switch strings.ToLower(strings.TrimSpace(answer)) { + case "y", "yes": + default: + return false, nil + } + + fmt.Fprintln(os.Stdout, "Updating github-butler...") + if err := update.Run(ctx); err != nil { + return true, err + } + fmt.Fprintf(os.Stdout, "Update complete. Restarting github-butler %s...\n", rel.TagName) + if err := update.Restart(os.Args[1:]); err != nil { + return true, err + } + return true, nil +} + +func isTerminal(file *os.File) bool { + info, err := file.Stat() + return err == nil && (info.Mode()&os.ModeCharDevice) != 0 +} diff --git a/internal/update/restart_unix.go b/internal/update/restart_unix.go new file mode 100644 index 0000000..d1eb294 --- /dev/null +++ b/internal/update/restart_unix.go @@ -0,0 +1,17 @@ +//go:build !windows + +package update + +import ( + "os" + "syscall" +) + +// Restart replaces the current process with the installed github-butler binary. +func Restart(args []string) error { + path, err := InstalledBinaryPath() + if err != nil { + return err + } + return syscall.Exec(path, append([]string{path}, args...), os.Environ()) +} diff --git a/internal/update/restart_windows.go b/internal/update/restart_windows.go new file mode 100644 index 0000000..ac11107 --- /dev/null +++ b/internal/update/restart_windows.go @@ -0,0 +1,10 @@ +//go:build windows + +package update + +import "errors" + +// Restart is unsupported on Windows because the bundled installer is Unix-only. +func Restart(args []string) error { + return errors.New("self-update restart is not supported on Windows") +} diff --git a/internal/update/update.go b/internal/update/update.go new file mode 100644 index 0000000..ea57554 --- /dev/null +++ b/internal/update/update.go @@ -0,0 +1,156 @@ +// Package update checks GitHub releases and reruns the installer when the +// user accepts a self-update prompt. +package update + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" +) + +// Repo is the GitHub repo queried for releases. Tests may point it at a stub. +var Repo = "bluegardenproject/github-butler" + +// InstallScriptURL is the public one-line installer target. +const InstallScriptURL = "https://raw.githubusercontent.com/bluegardenproject/github-butler/main/scripts/install.sh" + +const ( + installDirName = ".github-butler" + binaryName = "github-butler" +) + +// Release is the subset of the GitHub releases API payload we need. +type Release struct { + TagName string `json:"tag_name"` + Name string `json:"name"` + HTMLURL string `json:"html_url"` +} + +// LatestRelease fetches the latest published release. +func LatestRelease(ctx context.Context) (*Release, error) { + url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", Repo) + + reqCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("fetch latest release: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, errors.New("no published releases found") + } + if resp.StatusCode/100 != 2 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return nil, fmt.Errorf("github api %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + var rel Release + if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { + return nil, fmt.Errorf("decode release: %w", err) + } + return &rel, nil +} + +// Compare returns -1, 0, or +1 like strings.Compare, treating both versions as +// dot-separated numeric versions. A leading "v" is ignored. +func Compare(a, b string) int { + if a == b { + return 0 + } + + aDev := isDev(a) + bDev := isDev(b) + switch { + case aDev && bDev: + return 0 + case aDev: + return -1 + case bDev: + return 1 + } + + aParts := strings.Split(strings.TrimPrefix(a, "v"), ".") + bParts := strings.Split(strings.TrimPrefix(b, "v"), ".") + + n := len(aParts) + if len(bParts) > n { + n = len(bParts) + } + for len(aParts) < n { + aParts = append(aParts, "0") + } + for len(bParts) < n { + bParts = append(bParts, "0") + } + + for i := 0; i < n; i++ { + ai, aErr := strconv.Atoi(aParts[i]) + bi, bErr := strconv.Atoi(bParts[i]) + if aErr == nil && bErr == nil { + switch { + case ai < bi: + return -1 + case ai > bi: + return 1 + } + continue + } + if c := strings.Compare(aParts[i], bParts[i]); c != 0 { + return c + } + } + return 0 +} + +func isDev(v string) bool { + v = strings.ToLower(strings.TrimSpace(v)) + return v == "" || v == "dev" || v == "unknown" +} + +// Supported reports whether the bundled installer can replace the current +// platform's binary. +func Supported() bool { + return runtime.GOOS != "windows" +} + +// InstalledBinaryPath returns the location written by scripts/install.sh. +func InstalledBinaryPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, installDirName, binaryName), nil +} + +// Run executes the install script for the current platform. +func Run(ctx context.Context) error { + if !Supported() { + return errors.New("self-update is not supported on Windows") + } + + cmd := exec.CommandContext(ctx, "bash", "-c", + fmt.Sprintf("curl -fsSL %s | bash", InstallScriptURL)) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + return cmd.Run() +} diff --git a/internal/update/update_test.go b/internal/update/update_test.go new file mode 100644 index 0000000..2f4cc0a --- /dev/null +++ b/internal/update/update_test.go @@ -0,0 +1,68 @@ +package update + +import ( + "path/filepath" + "testing" +) + +func TestCompare(t *testing.T) { + tests := []struct { + name string + a string + b string + want int + }{ + {"equal plain", "0.1.0", "0.1.0", 0}, + {"equal with v", "v0.1.0", "0.1.0", 0}, + {"a less patch", "0.1.0", "0.1.1", -1}, + {"a greater patch", "0.1.2", "0.1.1", 1}, + {"minor bump", "0.1.9", "0.2.0", -1}, + {"major bump", "0.9.0", "1.0.0", -1}, + {"different lengths", "1.0", "1.0.0", 0}, + {"different lengths newer", "1.0", "1.0.1", -1}, + {"v prefix both", "v1.2.3", "v1.2.4", -1}, + {"dev vs released", "dev", "0.1.0", -1}, + {"released vs dev", "v0.1.0", "dev", 1}, + {"both dev", "dev", "dev", 0}, + {"unknown vs released", "unknown", "0.1.0", -1}, + {"empty vs released", "", "0.1.0", -1}, + {"two-digit numeric", "0.10.0", "0.9.0", 1}, + {"two-digit lex would be wrong", "0.2.0", "0.10.0", -1}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := Compare(tt.a, tt.b); got != tt.want { + t.Errorf("Compare(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want) + } + }) + } +} + +func TestCompareSymmetry(t *testing.T) { + pairs := [][2]string{ + {"0.1.0", "0.2.0"}, + {"v1.0.0", "v1.0.1"}, + {"dev", "0.1.0"}, + } + for _, p := range pairs { + ab := Compare(p[0], p[1]) + ba := Compare(p[1], p[0]) + if ab != -ba { + t.Errorf("Compare(%q, %q)=%d but Compare(%q, %q)=%d (not symmetric)", + p[0], p[1], ab, p[1], p[0], ba) + } + } +} + +func TestInstalledBinaryPath(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + got, err := InstalledBinaryPath() + if err != nil { + t.Fatal(err) + } + if want := filepath.Join(home, ".github-butler", "github-butler"); got != want { + t.Errorf("InstalledBinaryPath = %q; want %q", got, want) + } +} diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..d59fa4b --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,161 @@ +#!/bin/bash + +set -e + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +BOLD='\033[1m' +NC='\033[0m' + +REPO="bluegardenproject/github-butler" +INSTALL_DIR="$HOME/.github-butler" +BINARY_NAME="github-butler" + +echo -e "${BOLD}${BLUE}github-butler Installer${NC}" +echo -e "Installing to: ${YELLOW}$INSTALL_DIR${NC}" +echo + +OS=$(uname -s | tr '[:upper:]' '[:lower:]') +ARCH=$(uname -m) + +case $ARCH in + x86_64) ARCH="amd64" ;; + arm64|aarch64) ARCH="arm64" ;; + *) + echo -e "${RED}Error: Unsupported architecture: $ARCH${NC}" + exit 1 + ;; +esac + +case $OS in + linux) OS="linux" ;; + darwin) OS="darwin" ;; + *) + echo -e "${RED}Error: Unsupported OS: $OS${NC}" + exit 1 + ;; +esac + +echo -e "Detected: ${GREEN}$OS-$ARCH${NC}" + +echo -e "${BLUE}Creating installation directory...${NC}" +mkdir -p "$INSTALL_DIR" + +echo -e "${BLUE}Fetching latest release...${NC}" +RELEASE_URL="https://api.github.com/repos/$REPO/releases/latest" +DOWNLOAD_URL=$(curl -fsSL "$RELEASE_URL" | grep -o "https://.*github-butler-$OS-$ARCH[^\"]*" | head -n 1 || true) + +if [ -z "$DOWNLOAD_URL" ]; then + echo -e "${RED}Error: Could not find binary for $OS-$ARCH${NC}" + echo -e "${YELLOW}Available releases: https://github.com/$REPO/releases${NC}" + exit 1 +fi + +echo -e "Download URL: ${GREEN}$DOWNLOAD_URL${NC}" + +echo -e "${BLUE}Downloading github-butler...${NC}" +TEMP_FILE=$(mktemp) +curl -L -o "$TEMP_FILE" "$DOWNLOAD_URL" + +echo -e "${BLUE}Installing binary...${NC}" +mv "$TEMP_FILE" "$INSTALL_DIR/$BINARY_NAME" +chmod +x "$INSTALL_DIR/$BINARY_NAME" + +echo -e "${BLUE}Adding to PATH...${NC}" + +add_path_posix() { + local rc="$1" + local marker="# github-butler (auto-added by install.sh)" + local line="export PATH=\"$INSTALL_DIR:\$PATH\"" + + mkdir -p "$(dirname "$rc")" + [ -f "$rc" ] || touch "$rc" + + if grep -Fq "$INSTALL_DIR" "$rc" 2>/dev/null; then + echo -e "${YELLOW}$INSTALL_DIR already referenced in $rc${NC}" + return + fi + + { + echo "" + echo "$marker" + echo "$line" + } >> "$rc" + echo -e "${GREEN}Added $INSTALL_DIR to PATH in $rc${NC}" +} + +add_path_fish() { + local conf_dir="${XDG_CONFIG_HOME:-$HOME/.config}/fish/conf.d" + local conf="$conf_dir/github-butler.fish" + + mkdir -p "$conf_dir" + if [ -f "$conf" ] && grep -Fq "$INSTALL_DIR" "$conf"; then + echo -e "${YELLOW}$INSTALL_DIR already referenced in $conf${NC}" + return + fi + + cat > "$conf" </dev/null 2>&1; then + echo -e "${GREEN}Installation successful!${NC}" +else + echo -e "${YELLOW}Installation completed, but verification failed${NC}" + echo -e "${YELLOW} You may need to restart your terminal${NC}" +fi + +echo +echo -e "${BOLD}${GREEN}Installation Complete!${NC}" +echo +echo -e "${BOLD}Usage:${NC}" +echo -e " ${GREEN}github-butler${NC} - Start the dashboard" +echo -e " ${GREEN}github-butler --version${NC} - Show version information" +echo +echo -e "${YELLOW}Note: You may need to restart your terminal.${NC}" +if [ -n "$SHELL_CONFIG" ]; then + if [ "$SHELL_NAME" = "fish" ]; then + echo -e " ${BLUE}source $SHELL_CONFIG${NC} # fish" + else + echo -e " ${BLUE}source $SHELL_CONFIG${NC}" + fi +fi +echo