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
26 changes: 24 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,21 +197,43 @@ Drop into cron:

## Themes

Three themes pick the color palette for the dashboard, table, and prompts:
Three themes for the dashboard, table, and prompts:

| Theme | When to use |
|---|---|
| `dark` (default) | Dark-background terminals — bright cyan banner, vivid green/red/yellow status |
| `light` | Light-background terminals — bold blue banner, less dim grey |
| `neon` | Maximum pop — hot pink banner, bright cyan prompts, vivid greens/reds/oranges |

Three ways to set it, in priority order:

```bash
# 1. In the dashboard, press [t] to cycle dark → neon → light → dark
# (persists automatically)

# 2. CLI subcommand — persists to settings file
gh runner-status theme neon
gh runner-status theme next # cycle

# 3. Inline env var — wins over the settings file
GH_RUNNER_STATUS_THEME=neon gh runner-status
echo 'export GH_RUNNER_STATUS_THEME=neon' >> ~/.zshrc # make it stick
```

Settings file is `~/.config/gh-runner-status/settings` (key=value format).

Status icons (`✓`/`✗`/`⚠`) prefix each row by default. Set `NO_ICONS=1` to disable for ASCII-only environments or log viewers that don't render unicode.

## Debug logging

If the dashboard misbehaves (unexpected exit, refresh stalls, key presses ignored), enable the debug log to capture events:

```bash
GH_RUNNER_STATUS_DEBUG=1 gh runner-status
# events go to ~/.local/state/gh-runner-status/debug.log
```

Logs key presses, EOF events, refresh cycles, theme cycles, and quit transitions. Strip the env var to disable.

## Shell autocomplete

```bash
Expand Down
12 changes: 9 additions & 3 deletions completions/gh-runner-status.bash
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
# default — no mapfile/readarray usage).

_gh_runner_status_subcommands() {
echo "list status local start stop restart logs watch notify add remove stats --help --version --json --config --threshold"
echo "list status local start stop restart logs watch notify add remove stats setup info doctor update theme --help --version --json --config --threshold"
}

_gh_runner_status_runner_names() {
Expand Down Expand Up @@ -49,15 +49,21 @@ _gh_runner_status_complete() {
local subcmd="" i
for (( i = 1; i < COMP_CWORD; i++ )); do
case "${COMP_WORDS[i]}" in
list|status|local|start|stop|restart|logs|watch|notify|add|remove|stats)
list|status|local|start|stop|restart|logs|watch|notify|add|remove|stats|setup|info|doctor|update|theme)
subcmd="${COMP_WORDS[i]}"
break
;;
esac
done

case "$subcmd" in
start|stop|restart|logs)
theme)
# Complete the three theme values + `next`
# shellcheck disable=SC2207
COMPREPLY=( $(compgen -W "dark light neon next" -- "$cur") )
return 0
;;
start|stop|restart|logs|info|update)
local names
names=$(_gh_runner_status_runner_names)
# bash-3.2-safe: array-builtin compgen output via word-splitting.
Expand Down
187 changes: 171 additions & 16 deletions gh-runner-status
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,96 @@ else
BASH_HAS_FRACTIONAL_TIMEOUT=0
fi

# Debug log. Enable with GH_RUNNER_STATUS_DEBUG=1 to capture
# dashboard events (key presses, EOF, refreshes, quit transitions)
# to $XDG_STATE_HOME/gh-runner-status/debug.log. Capped at 1MB —
# when full we rotate to debug.log.1 and start fresh, so a
# long-running dashboard can't fill the disk.
DEBUG_LOG_ENABLED="${GH_RUNNER_STATUS_DEBUG:-0}"
DEBUG_LOG_PATH=""
DEBUG_LOG_MAX_BYTES="${GH_RUNNER_STATUS_DEBUG_MAX_BYTES:-1048576}"
if [[ "$DEBUG_LOG_ENABLED" == "1" ]]; then
_debug_dir="${XDG_STATE_HOME:-$HOME/.local/state}/gh-runner-status"
if mkdir -p "$_debug_dir" 2>/dev/null; then
DEBUG_LOG_PATH="$_debug_dir/debug.log"
fi
fi

dlog() {
[[ "$DEBUG_LOG_ENABLED" == "1" && -n "$DEBUG_LOG_PATH" ]] || return 0
# Rotate if oversized. Cheap stat; only on every call and only
# noticeable at startup of a fresh dashboard. Keep one rotation
# generation (debug.log → debug.log.1).
if [[ -f "$DEBUG_LOG_PATH" ]]; then
local sz
sz=$(stat -f %z "$DEBUG_LOG_PATH" 2>/dev/null || stat -c %s "$DEBUG_LOG_PATH" 2>/dev/null || echo 0)
if (( sz > DEBUG_LOG_MAX_BYTES )); then
mv -f "$DEBUG_LOG_PATH" "${DEBUG_LOG_PATH}.1" 2>/dev/null || true
fi
fi
printf '[%s] %s\n' "$(date '+%H:%M:%S')" "$*" >>"$DEBUG_LOG_PATH" 2>/dev/null || true
}
Comment thread
crgeee marked this conversation as resolved.

# User settings file. Persists theme + future preferences across
# invocations so the user doesn't have to put env vars in .bashrc.
# Env vars still win over the file (so `GH_RUNNER_STATUS_THEME=neon
# gh runner-status` always works).
_settings_path() {
echo "${XDG_CONFIG_HOME:-$HOME/.config}/gh-runner-status/settings"
}

_load_settings() {
local cfg
cfg=$(_settings_path)
[[ -f "$cfg" ]] || return 0
# Parse safely: only known keys, no shell sourcing. Validate values
# against an allowlist so a malformed file (or CRLF from a Windows
# editor) can't pollute the environment.
while IFS='=' read -r key value; do
key="${key#"${key%%[![:space:]]*}"}"
key="${key%"${key##*[![:space:]]}"}"
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
value="${value%$'\r'}" # tolerate CRLF
case "$key" in
theme)
# Only accept the known themes; unknown values are dropped
# (a leftover dashboard would render with the dark fallback).
case "$value" in
dark|light|neon)
[[ -z "${GH_RUNNER_STATUS_THEME:-}" ]] && export GH_RUNNER_STATUS_THEME="$value"
;;
esac
;;
""|\#*) ;;
esac
done < <(grep -E '^(theme|#)' "$cfg" 2>/dev/null || true)
}

_save_setting() {
local key="$1" value="$2"
local cfg
cfg=$(_settings_path)
mkdir -p "$(dirname "$cfg")" || return 1
local tmp
tmp=$(mktemp "${cfg}.XXXXXX") || return 1
if [[ -f "$cfg" ]]; then
grep -v "^${key}=" "$cfg" >"$tmp" 2>/dev/null || true
fi
if ! printf '%s=%s\n' "$key" "$value" >>"$tmp"; then
rm -f "$tmp"
return 1
fi
mv -f "$tmp" "$cfg" || { rm -f "$tmp"; return 1; }
}

# Initialize settings + theme. Skip when source-loaded by tests
# (NO_MAIN) so the bats suite doesn't pull in the developer's real
# ~/.config and break hermeticity.
if [[ -z "${GH_RUNNER_STATUS_NO_MAIN:-}" ]]; then
_load_settings
fi

# Read up to 2 bytes with a short timeout into the named variable.
# Used to peek for follow-up bytes after reading `\e` so we can
# distinguish lone Esc from escape-sequence prefixes (arrow keys, etc.).
Expand Down Expand Up @@ -1262,6 +1352,39 @@ dispatch_subcommand() {
jq -r '.oses[] | " \(.count)x \(.os)"' <<<"$agg"
;;

theme)
# `theme [dark|light|neon|next]` — show or set the persistent
# theme. Settings file lives at $XDG_CONFIG_HOME/gh-runner-status/
# settings (defaults to ~/.config/gh-runner-status/settings).
if [[ ${#positional[@]} -eq 0 ]]; then
echo "current: ${GH_RUNNER_STATUS_THEME:-dark}"
echo "available: dark, light, neon"
echo "usage: gh runner-status theme dark|light|neon|next"
return 0
fi
local target="${positional[0]}"
if [[ "$target" == "next" ]]; then
case "${GH_RUNNER_STATUS_THEME:-dark}" in
dark) target="neon" ;;
neon) target="light" ;;
light) target="dark" ;;
*) target="dark" ;;
esac
fi
case "$target" in
dark|light|neon) ;;
*)
echo "error: theme must be one of: dark, light, neon, next" >&2
return 2 ;;
esac
if ! _save_setting theme "$target"; then
echo "error: failed to write settings file at $(_settings_path)" >&2
return 1
fi
echo "theme set to: $target"
echo "(takes effect on next \`gh runner-status\` invocation)"
;;
Comment thread
crgeee marked this conversation as resolved.

setup)
# `setup OWNER/REPO [--name NAME] [--labels L1,L2] [--dir PATH]`
# Zero-to-running runner installation. Mints token, downloads
Expand Down Expand Up @@ -2064,16 +2187,15 @@ render_repl_footer() {
local has_color="$1"
local refresh="${2:-30}"
if [[ $has_color -eq 1 ]]; then
# Each [key] is bright; the label after it is dimmer so the
# hotkey itself "pops" without making the whole line shouty.
printf '%s─ refreshing every %ss • %s' "$THEME_RULE" "$refresh" "$THEME_RESET"
printf '%s[r]%s%sefresh %s' "$THEME_HOTKEY" "$THEME_RESET" "$THEME_HOTKEY_LBL" "$THEME_RESET"
printf '%s[c]%s%sommand %s' "$THEME_HOTKEY" "$THEME_RESET" "$THEME_HOTKEY_LBL" "$THEME_RESET"
printf '%s[a]%s%sdd %s' "$THEME_HOTKEY" "$THEME_RESET" "$THEME_HOTKEY_LBL" "$THEME_RESET"
printf '%s[t]%s%sheme %s' "$THEME_HOTKEY" "$THEME_RESET" "$THEME_HOTKEY_LBL" "$THEME_RESET"
printf '%s[h]%s%selp %s' "$THEME_HOTKEY" "$THEME_RESET" "$THEME_HOTKEY_LBL" "$THEME_RESET"
printf '%s[q]%s%suit%s\n' "$THEME_HOTKEY" "$THEME_RESET" "$THEME_HOTKEY_LBL" "$THEME_RESET"
else
printf -- '- refreshing every %ss • [r]efresh [c]ommand [a]dd [h]elp [q]uit\n' "$refresh"
printf -- '- refreshing every %ss • [r]efresh [c]ommand [a]dd [t]heme [h]elp [q]uit\n' "$refresh"
fi
}

Expand Down Expand Up @@ -2203,7 +2325,7 @@ handle_repl_line() {
a) words[0]="add" ;;
esac
case "${words[0]}" in
list|status|local|start|stop|restart|logs|notify|watch|add|remove|stats|update|doctor|info|setup)
list|status|local|start|stop|restart|logs|notify|watch|add|remove|stats|update|doctor|info|setup|theme)
dispatch_subcommand "${words[@]}" || true
;;
*)
Expand Down Expand Up @@ -2289,6 +2411,9 @@ dashboard_loop() {
# and quits on a single press.
local quit_pending_at=0
local quit_pending_reason=""
# One-shot hint when read returns EOF — see the rc=1 branch below.
local eof_warned=0
dlog "dashboard_loop start refresh=${refresh}s tty_in=$( [[ -t 0 ]] && echo yes || echo no ) tty_out=$( [[ -t 1 ]] && echo yes || echo no )"

trap 'echo; DASHBOARD_QUIT=1' INT

Expand Down Expand Up @@ -2316,28 +2441,43 @@ dashboard_loop() {
local age=$(( SECONDS - DASHBOARD_CACHE_AT ))
local remaining=$(( refresh - age ))

# If we're at or past the refresh interval, refresh immediately
# without reading. Avoids `read -t 0.01` which works on bash 4+
# but errors on macOS bash 3.2 ("invalid timeout specification" —
# that bash only accepts integer timeouts).
if (( remaining <= 0 )); then
_dashboard_refresh_cache
quit_pending_at=0; quit_pending_reason=""
continue
fi

local key="" rc=0
local read_started=$SECONDS
read -rsn 1 -t "$remaining" key
rc=$?

local read_elapsed=$(( SECONDS - read_started ))

# `read -t` returns rc=1 on EOF/closed stdin. That can happen
# for many reasons besides a deliberate Ctrl-D press (terminal
# multiplexer reattaching, gh wrapper closing fd 0, etc.), so we
# don't tie it to the destructive quit action — too many false
# positives, including a deterministic ~60s exit in some shells.
# Show a one-time hint and ignore. `q` and Esc are the reliable
# quit paths.
if [[ $rc -eq 1 ]]; then
# EOF / Ctrl-D
if [[ "$quit_pending_reason" == "Ctrl-D" ]]; then
DASHBOARD_QUIT=1
continue
dlog "read rc=1 (EOF) elapsed=${read_elapsed}s — ignoring (use q or Esc to quit)"
# bash `read -t` returns rc=1 on EOF, but EOF can come from
# many sources besides a deliberate Ctrl-D press: closed pipe,
# terminal multiplexer reattaching, wrapper process churn.
# Treating that as "user pressed Ctrl-D" caused spurious
# exits after ~60s in some terminals. Don't auto-quit; show
# a one-time hint and continue. `q` and Esc still quit.
if [[ $eof_warned -ne 1 ]]; then
eof_warned=1
echo
if [[ $has_color -eq 1 ]]; then
printf '%s (stdin glitch; use [q] or Esc to quit cleanly)%s\n' "$THEME_WARN" "$THEME_RESET"
else
printf ' (stdin glitch; use [q] or Esc to quit cleanly)\n'
fi
sleep 1
fi
quit_pending_at=$SECONDS
quit_pending_reason="Ctrl-D"
continue
fi

Expand Down Expand Up @@ -2380,6 +2520,21 @@ dashboard_loop() {
quit_pending_at=0; quit_pending_reason=""
prompt_and_run "$has_color" "add "
;;
t|T)
# Cycle through themes: dark → neon → light → dark.
quit_pending_at=0; quit_pending_reason=""
local cur="${GH_RUNNER_STATUS_THEME:-dark}" next=""
case "$cur" in
dark) next="neon" ;;
neon) next="light" ;;
light) next="dark" ;;
*) next="dark" ;;
esac
export GH_RUNNER_STATUS_THEME="$next"
_theme_init
_save_setting theme "$next"
dlog "theme cycle: $cur -> $next"
;;
"")
# Timeout — refresh and clear any pending-quit so the hint
# doesn't linger across the auto-refresh cycle.
Expand Down Expand Up @@ -2469,7 +2624,7 @@ main() {
subcommand="list"
else
case "${args[0]}" in
list|status|local|start|stop|restart|logs|notify|watch|add|remove|stats|update|doctor|info|setup)
list|status|local|start|stop|restart|logs|notify|watch|add|remove|stats|update|doctor|info|setup|theme)
Comment thread
crgeee marked this conversation as resolved.
subcommand="${args[0]}"
positional=("${args[@]:1}")
;;
Expand Down
Loading
Loading