Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 17 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
62 changes: 62 additions & 0 deletions cmd/update_prompt.go
Original file line number Diff line number Diff line change
@@ -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
}
17 changes: 17 additions & 0 deletions internal/update/restart_unix.go
Original file line number Diff line number Diff line change
@@ -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())
}
10 changes: 10 additions & 0 deletions internal/update/restart_windows.go
Original file line number Diff line number Diff line change
@@ -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")
}
156 changes: 156 additions & 0 deletions internal/update/update.go
Original file line number Diff line number Diff line change
@@ -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()
}
Loading
Loading