diff --git a/.actrc b/.actrc new file mode 100644 index 0000000..f10cea7 --- /dev/null +++ b/.actrc @@ -0,0 +1,2 @@ +-P ubuntu-latest=catthehacker/ubuntu:act-latest +-P macos-latest=-self-hosted diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 44a850a..e390198 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,22 +1,101 @@ name: Test gh-install on: - push: - branches: [ main ] pull_request: - branches: [ main ] + branches: [main] workflow_dispatch: jobs: test: strategy: + fail-fast: false matrix: - os: [ubuntu-latest, macos-latest] + os: [ubuntu-latest, macos-latest, windows-latest] + tool: + # Basic tarball + - repo: orf/gping + verify: gping --version + desc: "simple tarball" + + # Test --pattern flag (force musl variant) + - repo: BurntSushi/ripgrep + pattern: "*musl*" + verify: rg --version + desc: "pattern filter" + + # Test --name flag + - repo: sharkdp/fd + name: fdfind + verify: fdfind --version + desc: "custom name" + + # Test --version flag + - repo: sharkdp/bat + version: v0.24.0 + verify: bat --version | grep "0.24.0" + desc: "pinned version" + + # Simple binary (no gnu/musl variants) + - repo: junegunn/fzf + verify: fzf --version + desc: "simple binary" + + # Multiple file types (.tar.gz + .sha256) + - repo: astral-sh/uv + verify: uv --version + desc: "uv package manager" + + # Go binary + - repo: jesseduffield/lazygit + verify: lazygit --version + desc: "go binary" + + # Rust binary + - repo: sharkdp/hyperfine + verify: hyperfine --version + desc: "rust binary" + + # Nested archive structure + - repo: dandavison/delta + verify: delta --version + desc: "nested archive" + runs-on: ${{ matrix.os }} + name: "${{ matrix.tool.desc }} (${{ matrix.os }})" + + defaults: + run: + shell: bash + + env: + GH_TOKEN: ${{ github.token }} steps: - name: Checkout repository uses: actions/checkout@v4 + - name: Install GitHub CLI (for act) + if: ${{ env.ACT }} + run: | + curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null + sudo apt-get update + sudo apt-get install -y gh + - name: Install extension locally run: gh extension install . + + - name: "Test: ${{ matrix.tool.desc }}" + run: | + args="--auto" + [ -n "${{ matrix.tool.pattern }}" ] && args="$args --pattern '${{ matrix.tool.pattern }}'" + [ -n "${{ matrix.tool.name }}" ] && args="$args --name ${{ matrix.tool.name }}" + [ -n "${{ matrix.tool.version }}" ] && args="$args --version ${{ matrix.tool.version }}" + + echo "Running: gh install ${{ matrix.tool.repo }} $args" + eval "gh install ${{ matrix.tool.repo }} $args" + + - name: Verify installation + run: | + export PATH="$HOME/.local/bin:$PATH" + ${{ matrix.tool.verify }} diff --git a/README.md b/README.md index 8ccb29b..98790c6 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,67 @@ gh extension install redraw/gh-install ## Usage +### Interactive mode (default) + ```bash gh install / ``` +The interactive mode will prompt you to select: + +- Version to install +- Asset file to download +- Binary to extract (if archive) +- Name for the installed binary + Optional: For fuzzy search support, install [fzf](https://github.com/junegunn/fzf) +### Non-interactive mode: --auto (or -a) + +For scripts, CI/CD pipelines, and non-interactive use. + +```bash +gh install / --auto +``` + +Auto mode automatically: + +- Detects your platform (OS and architecture) +- Selects the latest version +- Prefers `gnu` over `musl` when both are available +- Excludes checksum files (.sha256, .sig, etc.) + +> [!NOTE] +> To prefer `musl` instead, use the pattern flag: +> +> ```bash +> gh install / --auto --pattern '*musl*' +> ``` + +**Examples:** + +```bash +# Auto-detect everything (version, OS, architecture, binary name) +gh install cli/cli --auto + +# Auto-detect with specific version +gh install cli/cli --auto --version v2.40.0 + +# Auto-detect with pattern to choose variant (musl vs gnu, etc) +gh install BurntSushi/ripgrep --auto --pattern '*.tar.gz$' + +# Auto-detect with custom binary name +gh install sharkdp/fd --auto --name fdfind +``` + +**Options:** + +- `-a, --auto` - Enable non-interactive mode with auto-detection +- `-v, --version ` - Version to install (default: latest in auto mode) +- `-p, --pattern ` - Asset filename pattern to narrow down matches +- `-n, --name ` - Binary name (default: auto-detect from filename) +- `-h, --help` - Show help message + ## Environment variables -- `$GH_BINPATH` path to install binaries, defaults to `$HOME/.local/bin` + +- `$GH_BINPATH` - Path to install binaries, defaults to `$HOME/.local/bin` diff --git a/gh-install b/gh-install index 5e0ffe1..3f718a0 100755 --- a/gh-install +++ b/gh-install @@ -3,38 +3,160 @@ set -eo pipefail TMP="/tmp/.gh-install" BINPATH="${GH_BINPATH:-$HOME/.local/bin}" -REPO=$1 -if [ -z $REPO ]; then - echo "usage: gh install user/repo" +# Colors (only if terminal supports it) +if [ -t 1 ] && [ -z "$NO_COLOR" ]; then + O='\033[38;5;208m' # orange (actions) + D='\033[90m' # dim/grey (auto-detected) + B='\033[1m' # bold + G='\033[32m' # green + M='\033[35m' # magenta + R='\033[0m' # reset +else + O='' D='' B='' G='' M='' R='' +fi + +log() { + echo -e "${D}$1${R}" +} + +action() { + echo -e "${O}$1${R}" +} + +# Flags +AUTO_MODE=false +FLAG_VERSION="" +FLAG_PATTERN="" +FLAG_NAME="" +REPO="" + +show_help() { + cat << EOF +usage: gh install [OPTIONS] + +Install GitHub CLI tools from releases with smart platform detection. + +OPTIONS: + -a, --auto Enable non-interactive mode with auto-detection + -v, --version Version to install (default: latest in auto mode) + -p, --pattern Asset filename pattern (e.g., '*linux*', '*musl*') + -n, --name Binary name (default: auto-detect from filename) + -h, --help Show this help message + +ENVIRONMENT: + GH_BINPATH Installation directory (default: ~/.local/bin) + +EXAMPLES: + # Interactive mode (current behavior) + gh install cli/cli + + # Auto-detect everything + gh install cli/cli --auto + + # Auto-detect with specific version + gh install cli/cli --auto --version v2.40.0 + + # Auto-detect with pattern override (for musl vs gnu, etc) + gh install BurntSushi/ripgrep --auto --pattern '*musl*' + + # Auto-detect with custom binary name + gh install sharkdp/fd --auto --name fdfind + +PLATFORM DETECTION: + Automatically detects OS (linux/darwin) and architecture (x86_64/arm64/etc) + Matches against common release patterns: linux_amd64, darwin_arm64, etc. + +EOF + exit 0 +} + +# Parse command line arguments +while [[ $# -gt 0 ]]; do + case $1 in + -a|--auto) + AUTO_MODE=true + shift + ;; + -v|--version) + FLAG_VERSION="$2" + shift 2 + ;; + -p|--pattern) + FLAG_PATTERN="$2" + shift 2 + ;; + -n|--name) + FLAG_NAME="$2" + shift 2 + ;; + -h|--help) + show_help + ;; + -*) + echo "Unknown option: $1" + echo "Run 'gh install --help' for usage" + exit 1 + ;; + *) + if [ -z "$REPO" ]; then + REPO="$1" + else + echo "Error: Too many arguments" + echo "Run 'gh install --help' for usage" + exit 1 + fi + shift + ;; + esac +done + +if [ -z "$REPO" ]; then + echo "usage: gh install [OPTIONS]" + echo "Run 'gh install --help' for more information" exit 1 fi -choose () { - if command -v fzf 2>&1> /dev/null; then - echo $@ | xargs -n 1 | fzf --height 10 --prompt "$PS3" -1 +# Normalize repo: strip GitHub URL prefix if present +REPO="${REPO#https://github.com/}" +REPO="${REPO#http://github.com/}" +REPO="${REPO#github.com/}" +REPO="${REPO%.git}" + +# Auto-enable auto mode if not a tty (piped/scripted) +if [ ! -t 0 ] && [ "$AUTO_MODE" = false ]; then + AUTO_MODE=true + log "[auto-detected mode] non-interactive (stdin is not a tty)" +fi + +choose() { + if [ "$AUTO_MODE" = true ]; then + # Auto mode: return first item (we expect exactly one from filtering) + echo "$@" | xargs -n 1 | head -n 1 + elif command -v fzf 2>&1> /dev/null; then + echo "$@" | xargs -n 1 | fzf --height 10 --prompt "$PS3" -1 else - select opt in $@; do break; done - echo $opt + select opt in "$@"; do break; done + echo "$opt" fi } -extract () { - for arg in $@ ; do - if [ -f $arg ] ; then - case $arg in - *.tar.bz2) tar xjf $arg ;; - *.tar.gz) tar xzf $arg ;; - *.tar.xz) tar xf $arg ;; - *.tar.zst) tar xf $arg ;; - *.bz2) bunzip2 $arg ;; - *.gz) gunzip $arg ;; - *.tar) tar xf $arg ;; - *.tbz2) tar xjf $arg ;; - *.tgz) tar xzf $arg ;; - *.zip) unzip $arg ;; - *.Z) uncompress $arg ;; - *.rar) rar x $arg ;; # 'rar' must to be installed +extract() { + for arg in "$@"; do + if [ -f "$arg" ]; then + case "$arg" in + *.tar.bz2) tar xjf "$arg" ;; + *.tar.gz) tar xzf "$arg" ;; + *.tar.xz) tar xf "$arg" ;; + *.tar.zst) tar xf "$arg" ;; + *.bz2) bunzip2 "$arg" ;; + *.gz) gunzip "$arg" ;; + *.tar) tar xf "$arg" ;; + *.tbz2) tar xjf "$arg" ;; + *.tgz) tar xzf "$arg" ;; + *.zip) unzip -q "$arg" ;; + *.Z) uncompress "$arg" ;; + *.rar) rar x "$arg" ;; *) echo "'$arg' cannot be extracted, assuming it's binary" && return 1;; esac else @@ -44,49 +166,129 @@ extract () { return 0 } -cleanup () { - rm -rf $TMP +cleanup() { + rm -rf "$TMP" } trap cleanup EXIT -PS3="> Select version: " -tag=$(choose `gh api "repos/$REPO/releases" -q ".[].tag_name"`) -echo "[version] $tag" +# Load auto-mode helpers if needed +if [ "$AUTO_MODE" = true ]; then + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + + if [ -f "$SCRIPT_DIR/gh-install-lib.sh" ]; then + source "$SCRIPT_DIR/gh-install-lib.sh" + else + echo "Error: Could not find gh-install-lib.sh" >&2 + echo "Auto mode requires gh-install-lib.sh in the same directory" >&2 + exit 1 + fi + + detect_platform + log "[auto-detected platform] ${B}$OS_NAME $ARCH_NAME${R}" +fi + +# Step 1: Select version +if [ "$AUTO_MODE" = true ]; then + tag=$(auto_select_version) + if [ -n "$FLAG_VERSION" ]; then + log "[using version] ${B}$tag${R} ${M}(from --version)${R}" + else + log "[auto-detected version] ${B}$tag${R} ${G}(latest)${R}" + fi +else + PS3="> Select version: " + tag=$(choose $(gh api "repos/$REPO/releases" -q ".[].tag_name")) + echo "[version] $tag" +fi -PS3="> Select file: " -filename=$(choose `gh api "repos/$REPO/releases" -q '.[] | select(.tag_name == "'$tag'") | .assets[].name'`) -echo "[filename] $filename" +# Step 2: Select file/asset +all_assets=$(gh api "repos/$REPO/releases" -q ".[] | select(.tag_name == \"$tag\") | .assets[].name") -echo "[*] Downloading... $filename" -gh release download $tag --repo "$REPO" --pattern "$filename" --dir "$TMP" +if [ "$AUTO_MODE" = true ]; then + # Auto-detect based on platform (with optional pattern to narrow down) + filename=$(auto_select_asset "$all_assets") + + if [ $? -ne 0 ]; then + exit 1 + fi + + if [ -n "$FLAG_PATTERN" ]; then + log "[auto-detected asset] ${B}$filename${R} ${M}(pattern: $FLAG_PATTERN)${R}" + else + log "[auto-detected asset] ${B}$filename${R}" + fi +elif [ -n "$FLAG_PATTERN" ]; then + # Non-auto mode with explicit pattern + # Convert glob pattern to grep pattern (* -> .*, ? -> .) + grep_pattern=$(echo "$FLAG_PATTERN" | sed 's/\*/.*/g' | sed 's/?/./g') + filename=$(echo "$all_assets" | grep -E "$grep_pattern" | head -n 1) + + if [ -z "$filename" ]; then + echo "Error: No assets match pattern: $FLAG_PATTERN" >&2 + echo "Available assets:" >&2 + echo "$all_assets" | sed 's/^/ /' >&2 + exit 1 + fi + + echo "[filename] $filename (matched pattern: $FLAG_PATTERN)" +else + # Interactive + PS3="> Select file: " + filename=$(choose $(echo "$all_assets")) + echo "[filename] $filename" +fi + +# Step 3: Download +action "downloading ${B}$filename${R}" +gh release download "$tag" --repo "$REPO" --pattern "$filename" --dir "$TMP" ( - cd $TMP + cd "$TMP" - if [[ $filename == *.deb ]]; then - echo "[*] Installing debian package..." - sudo apt install ./$filename + # Handle debian packages + if [[ "$filename" == *.deb ]]; then + action "installing debian package ${B}$filename${R}" + sudo apt install "./$filename" exit 0 fi - echo "[*] Extracting..." + action "extracting..." - if extract $filename; then - PS3="> Select binary: " - bin=$(choose `find * -type f -not -path "*$filename"`) + # Step 4: Extract and select binary + if extract "$filename"; then + if [ "$AUTO_MODE" = true ]; then + bin=$(auto_select_binary "$filename") + if [ $? -ne 0 ]; then + exit 1 + fi + log "[auto-detected binary] ${B}$bin${R}" + else + PS3="> Select binary: " + bin=$(choose $(find . -type f -not -path "*$filename")) + fi else - bin=$filename + bin="$filename" fi - # install + # Step 5: Install basename=$(basename "$bin") - read -p "> Choose a name (empty to leave: $basename): " name + + if [ "$AUTO_MODE" = true ]; then + name=$(auto_select_name "$bin") + if [ -n "$FLAG_NAME" ]; then + log "[using name] ${B}$name${R} ${M}(from --name)${R}" + else + log "[auto-detected name] ${B}$name${R}" + fi + else + read -p "> Choose a name (empty to leave: $basename): " name + fi + mkdir -p "$BINPATH" target="$BINPATH/${name:-$basename}" mv "$bin" "$target" chmod +x "$target" - echo "Success!" - echo "Saved in: $target" + echo -e "${G}installed ${B}$target${R}" ) diff --git a/gh-install-lib.sh b/gh-install-lib.sh new file mode 100644 index 0000000..cb7328c --- /dev/null +++ b/gh-install-lib.sh @@ -0,0 +1,172 @@ +#!/bin/bash +# gh-install auto-mode helper library +# Sourced by gh-install when --auto flag is used +# +# Expects these globals from main script: +# REPO, FLAG_VERSION, FLAG_PATTERN, FLAG_NAME + +# Platform detection variables +OS_NAME="" +OS_ALIASES="" +ARCH_NAME="" +ARCH_ALIASES="" + +detect_platform() { + local os=$(uname -s | tr '[:upper:]' '[:lower:]') + local arch=$(uname -m) + + # Normalize OS names + case "$os" in + linux) + OS_NAME="linux" + OS_ALIASES="linux" + ;; + darwin) + OS_NAME="darwin" + OS_ALIASES="darwin|macos|osx|mac|apple" + ;; + mingw*|msys*|cygwin*) + OS_NAME="windows" + OS_ALIASES="windows|win64|win32|win" + ;; + *) + OS_NAME="$os" + OS_ALIASES="$os" + ;; + esac + + # Normalize architecture names + case "$arch" in + x86_64|amd64) + ARCH_NAME="x86_64" + ARCH_ALIASES="amd64|x86_64|x64" + ;; + aarch64|arm64) + ARCH_NAME="arm64" + ARCH_ALIASES="arm64|aarch64" + ;; + armv7l|armv6l) + ARCH_NAME="arm" + ARCH_ALIASES="arm|armv7|armv6" + ;; + i386|i686) + ARCH_NAME="i386" + ARCH_ALIASES="i386|i686|x86" + ;; + *) + ARCH_NAME="$arch" + ARCH_ALIASES="$arch" + ;; + esac +} + +auto_select_version() { + # Uses globals: REPO, FLAG_VERSION + if [ -n "$FLAG_VERSION" ]; then + echo "$FLAG_VERSION" + else + gh api "repos/$REPO/releases/latest" -q ".tag_name" + fi +} + +auto_select_asset() { + # Uses globals: FLAG_PATTERN + local assets="$1" # Newline-separated list (must be passed) + + # Filter by OS first + local os_matches=$(echo "$assets" | grep -iE "$OS_ALIASES" || true) + + if [ -z "$os_matches" ]; then + echo "Error: No assets found for OS: $OS_NAME" >&2 + echo "Available assets:" >&2 + echo "$assets" | sed 's/^/ /' >&2 + return 1 + fi + + # Filter by architecture + local matched=$(echo "$os_matches" | grep -iE "$ARCH_ALIASES" || true) + + if [ -z "$matched" ]; then + echo "Error: No assets found for $OS_NAME $ARCH_NAME" >&2 + echo "Assets matching OS ($OS_NAME):" >&2 + echo "$os_matches" | sed 's/^/ /' >&2 + return 1 + fi + + # If pattern provided, narrow down further + if [ -n "$FLAG_PATTERN" ]; then + # Convert glob pattern to grep pattern (* -> .*, ? -> .) + local grep_pattern=$(echo "$FLAG_PATTERN" | sed 's/\*/.*/g' | sed 's/?/./g') + local narrowed=$(echo "$matched" | grep -E "$grep_pattern" || true) + + if [ -z "$narrowed" ]; then + echo "Error: No platform matches satisfy pattern: $FLAG_PATTERN" >&2 + echo "Platform matches:" >&2 + echo "$matched" | sed 's/^/ /' >&2 + return 1 + fi + + matched="$narrowed" + fi + + # Exclude checksum/signature files + matched=$(echo "$matched" | grep -vE '\.(sha256|sha512|sig|asc)$' || echo "$matched") + + # Count matches + local count=$(echo "$matched" | wc -l | tr -d ' ') + + if [ "$count" -eq 1 ]; then + echo "$matched" + return 0 + fi + + # Multiple matches: prefer gnu over musl (more common on standard Linux) + if [ "$count" -gt 1 ]; then + local gnu_match=$(echo "$matched" | grep -i 'gnu' | head -n 1) + if [ -n "$gnu_match" ]; then + echo "$gnu_match" + return 0 + fi + fi + + # Still multiple matches, error out + echo "Error: Multiple assets match platform ($count found):" >&2 + echo "$matched" | sed 's/^/ /' >&2 + echo "" >&2 + if [ -z "$FLAG_PATTERN" ]; then + echo "Use --pattern to narrow down (e.g., --pattern '*musl*' or --pattern '*.tar.gz')" >&2 + else + echo "Pattern '$FLAG_PATTERN' still matches multiple files. Be more specific." >&2 + fi + return 1 +} + +auto_select_binary() { + local filename="$1" + + # Find executable file automatically + local bin=$(find . -type f -not -path "*$filename" -executable | head -n 1) + + if [ -z "$bin" ]; then + # No executable found, try to find any binary file + bin=$(find . -type f -not -path "*$filename" | head -n 1) + fi + + if [ -z "$bin" ]; then + echo "Error: No binary found in archive" >&2 + return 1 + fi + + echo "$bin" +} + +auto_select_name() { + # Uses globals: FLAG_NAME + local binary_path="$1" # Must be passed (computed at runtime) + + if [ -n "$FLAG_NAME" ]; then + echo "$FLAG_NAME" + else + basename "$binary_path" + fi +}