From f69d6398a774d88eb09e58c7e9c6cbd6e31ec830 Mon Sep 17 00:00:00 2001 From: Clay McGinnis Date: Mon, 6 Apr 2026 14:40:11 -0700 Subject: [PATCH 1/2] fix: resolve "latest" to actual release tag before downloading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gh release download does not support "latest" as a magic alias — it requires a real tag name. Both the install script and upgrade command now resolve "latest" via gh release view first. --- internal/commands/upgrade.go | 26 ++++++++++++++++++++++++++ scripts/install-release.sh | 9 +++++++++ 2 files changed, 35 insertions(+) diff --git a/internal/commands/upgrade.go b/internal/commands/upgrade.go index 88d2c10..0b5e9fc 100644 --- a/internal/commands/upgrade.go +++ b/internal/commands/upgrade.go @@ -79,6 +79,15 @@ func runUpgrade(cmd *cobra.Command, opts *upgradeOptions) error { asset := fmt.Sprintf("%s_%s_%s", upgradeBinary, osName, archName) tag := opts.tag + // "latest" is not a real tag — resolve it to the actual latest release tag. + if tag == "latest" { + resolved, err := resolveLatestTag() + if err != nil { + return fmt.Errorf("resolve latest release: %w", err) + } + tag = resolved + } + fmt.Fprintf(w, "Downloading %s from %s@%s...\n", asset, upgradeRepo, tag) tmpDir, err := os.MkdirTemp("", "wherobots-upgrade-*") @@ -150,6 +159,23 @@ func detectPlatform() (string, string, error) { return osName, archName, nil } +// resolveLatestTag queries gh for the latest non-prerelease release tag. +func resolveLatestTag() (string, error) { + out, err := exec.Command("gh", "release", "view", + "--repo", upgradeRepo, + "--json", "tagName", + "-q", ".tagName", + ).Output() + if err != nil { + return "", fmt.Errorf("no release found in %s", upgradeRepo) + } + tag := strings.TrimSpace(string(out)) + if tag == "" { + return "", fmt.Errorf("no release found in %s", upgradeRepo) + } + return tag, nil +} + func ghDownload(tag, pattern, dir string) error { out, err := exec.Command("gh", "release", "download", tag, "--repo", upgradeRepo, diff --git a/scripts/install-release.sh b/scripts/install-release.sh index ae01ec3..9792ae2 100755 --- a/scripts/install-release.sh +++ b/scripts/install-release.sh @@ -110,6 +110,15 @@ TMP_DIR="$(mktemp -d)" cleanup() { rm -rf "$TMP_DIR"; } trap cleanup EXIT +# "latest" is not a real tag — resolve it to the actual latest release tag. +if [[ "$TAG" == "latest" ]]; then + TAG="$(gh release view --repo "$REPO" --json tagName -q .tagName 2>/dev/null)" || true + if [[ -z "$TAG" ]]; then + echo "No release found in $REPO" >&2 + exit 1 + fi +fi + echo "Downloading ${ASSET} from ${REPO}@${TAG}..." gh release download "$TAG" --repo "$REPO" --pattern "$ASSET" --dir "$TMP_DIR" --clobber From 5c8a14ea112d50089ffb08a0d9c1f3a4ba0d2899 Mon Sep 17 00:00:00 2001 From: Clay McGinnis Date: Mon, 6 Apr 2026 14:46:01 -0700 Subject: [PATCH 2/2] fix: replace gh CLI dependency with direct GitHub API and HTTP calls The install script, upgrade command, and version check all previously shelled out to the gh CLI. Since the repo is public, no authentication is needed. This replaces every gh invocation with: - net/http calls to the GitHub releases API (for resolving latest tag) - Direct HTTP downloads from GitHub release asset URLs - curl in the install script (with grep/sed to parse JSON, no jq needed) --- internal/commands/upgrade.go | 88 ++++++++++++++++++++++-------------- internal/version/check.go | 37 ++++++++++----- scripts/install-release.sh | 28 +++++------- 3 files changed, 92 insertions(+), 61 deletions(-) diff --git a/internal/commands/upgrade.go b/internal/commands/upgrade.go index 0b5e9fc..10cb2b9 100644 --- a/internal/commands/upgrade.go +++ b/internal/commands/upgrade.go @@ -3,8 +3,10 @@ package commands import ( "crypto/sha256" "encoding/hex" + "encoding/json" "fmt" "io" + "net/http" "os" "os/exec" "path/filepath" @@ -66,11 +68,6 @@ func runUpgrade(cmd *cobra.Command, opts *upgradeOptions) error { installDir = filepath.Dir(exe) } - // Ensure gh is available and authenticated. - if err := requireGh(); err != nil { - return err - } - osName, archName, err := detectPlatform() if err != nil { return err @@ -96,7 +93,7 @@ func runUpgrade(cmd *cobra.Command, opts *upgradeOptions) error { } defer os.RemoveAll(tmpDir) - if err := ghDownload(tag, asset, tmpDir); err != nil { + if err := httpDownload(tag, asset, tmpDir); err != nil { return fmt.Errorf("download asset: %w", err) } @@ -104,7 +101,7 @@ func runUpgrade(cmd *cobra.Command, opts *upgradeOptions) error { if !opts.skipChecksum { fmt.Fprintln(w, "Verifying checksum...") - if err := ghDownload(tag, "checksums.txt", tmpDir); err != nil { + if err := httpDownload(tag, "checksums.txt", tmpDir); err != nil { return fmt.Errorf("download checksums: %w", err) } if err := verifyChecksum(assetPath, filepath.Join(tmpDir, "checksums.txt"), asset); err != nil { @@ -122,17 +119,6 @@ func runUpgrade(cmd *cobra.Command, opts *upgradeOptions) error { return nil } -// requireGh checks that the gh CLI is installed and authenticated. -func requireGh() error { - if _, err := exec.LookPath("gh"); err != nil { - return fmt.Errorf("gh CLI is required; install from https://cli.github.com/") - } - if err := exec.Command("gh", "auth", "status").Run(); err != nil { - return fmt.Errorf("gh is not authenticated; run: gh auth login") - } - return nil -} - func detectPlatform() (string, string, error) { var osName string switch runtime.GOOS { @@ -159,32 +145,66 @@ func detectPlatform() (string, string, error) { return osName, archName, nil } -// resolveLatestTag queries gh for the latest non-prerelease release tag. +// resolveLatestTag queries the GitHub API for the latest release tag. func resolveLatestTag() (string, error) { - out, err := exec.Command("gh", "release", "view", - "--repo", upgradeRepo, - "--json", "tagName", - "-q", ".tagName", - ).Output() + url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", upgradeRepo) + req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { - return "", fmt.Errorf("no release found in %s", upgradeRepo) + return "", err + } + req.Header.Set("User-Agent", "wherobots-cli") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("GitHub API request failed: %w", err) } - tag := strings.TrimSpace(string(out)) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("no release found in %s (HTTP %d)", upgradeRepo, resp.StatusCode) + } + + var release struct { + TagName string `json:"tag_name"` + } + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return "", fmt.Errorf("failed to parse GitHub API response: %w", err) + } + tag := strings.TrimSpace(release.TagName) if tag == "" { return "", fmt.Errorf("no release found in %s", upgradeRepo) } return tag, nil } -func ghDownload(tag, pattern, dir string) error { - out, err := exec.Command("gh", "release", "download", tag, - "--repo", upgradeRepo, - "--pattern", pattern, - "--dir", dir, - "--clobber", - ).CombinedOutput() +// httpDownload downloads a release asset from GitHub to the given directory. +func httpDownload(tag, filename, dir string) error { + url := fmt.Sprintf("https://github.com/%s/releases/download/%s/%s", upgradeRepo, tag, filename) + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return err + } + req.Header.Set("User-Agent", "wherobots-cli") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("download request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("failed to download %s (HTTP %d)", filename, resp.StatusCode) + } + + outPath := filepath.Join(dir, filename) + f, err := os.Create(outPath) if err != nil { - return fmt.Errorf("%s: %s", err, strings.TrimSpace(string(out))) + return fmt.Errorf("create file %s: %w", outPath, err) + } + defer f.Close() + + if _, err := io.Copy(f, resp.Body); err != nil { + return fmt.Errorf("write file %s: %w", outPath, err) } return nil } diff --git a/internal/version/check.go b/internal/version/check.go index a5ccb07..366f2d7 100644 --- a/internal/version/check.go +++ b/internal/version/check.go @@ -3,8 +3,9 @@ package version import ( "context" + "encoding/json" "fmt" - "os/exec" + "net/http" "strconv" "strings" "time" @@ -23,7 +24,7 @@ type Result struct { Outdated bool // true when current < latest } -// CheckInBackground spawns a goroutine that queries the GitHub CLI for the +// CheckInBackground spawns a goroutine that queries the GitHub API for the // latest release tag. Call Collect on the returned channel after the main // command has finished to retrieve the result (if any). // @@ -46,7 +47,7 @@ func CheckInBackground(ctx context.Context, currentVersion string) <-chan *Resul latest, err := fetchLatestTag(checkCtx) if err != nil || latest == "" { - return // silently skip; don't annoy users when gh is unavailable + return // silently skip; don't annoy users when the check fails } if !isNewer(currentVersion, latest) { @@ -91,18 +92,32 @@ func isDevVersion(v string) bool { return v == "" || v == "dev" || v == "latest-prerelease" || strings.HasPrefix(v, "dev-") } -// fetchLatestTag shells out to: gh release view --repo wherobots/wbc-cli --json tagName -q .tagName +// fetchLatestTag queries the GitHub API for the latest release tag. func fetchLatestTag(ctx context.Context) (string, error) { - cmd := exec.CommandContext(ctx, "gh", "release", "view", - "--repo", repo, - "--json", "tagName", - "-q", ".tagName", - ) - out, err := cmd.Output() + url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", err + } + req.Header.Set("User-Agent", "wherobots-cli") + + resp, err := http.DefaultClient.Do(req) if err != nil { return "", err } - return strings.TrimSpace(string(out)), nil + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("GitHub API returned status %d", resp.StatusCode) + } + + var release struct { + TagName string `json:"tag_name"` + } + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return "", err + } + return strings.TrimSpace(release.TagName), nil } // isNewer returns true when latest represents a strictly newer semver than current. diff --git a/scripts/install-release.sh b/scripts/install-release.sh index 9792ae2..102aaa1 100755 --- a/scripts/install-release.sh +++ b/scripts/install-release.sh @@ -12,7 +12,7 @@ usage() { Install wherobots CLI from a GitHub release. Requirements: - - gh CLI installed and authenticated with access to the repository. + - curl Usage: ./scripts/install-release.sh [options] @@ -67,8 +67,8 @@ while (($# > 0)); do esac done -if ! command -v gh >/dev/null 2>&1; then - echo "gh CLI is required. Install from https://cli.github.com/" >&2 +if ! command -v curl >/dev/null 2>&1; then + echo "curl is required." >&2 exit 1 fi @@ -77,16 +77,6 @@ if ! command -v install >/dev/null 2>&1; then exit 1 fi -if ! gh auth status >/dev/null 2>&1; then - echo "gh is not authenticated. Run: gh auth login" >&2 - exit 1 -fi - -if ! gh repo view "$REPO" >/dev/null 2>&1; then - echo "Unable to access repository $REPO with current gh credentials." >&2 - exit 1 -fi - case "$(uname -s)" in Linux) OS="linux" ;; Darwin) OS="darwin" ;; @@ -112,19 +102,25 @@ trap cleanup EXIT # "latest" is not a real tag — resolve it to the actual latest release tag. if [[ "$TAG" == "latest" ]]; then - TAG="$(gh release view --repo "$REPO" --json tagName -q .tagName 2>/dev/null)" || true + API_RESPONSE="$(curl -fsSL -H "User-Agent: wherobots-cli" \ + "https://api.github.com/repos/${REPO}/releases/latest" 2>/dev/null)" || true + # Parse tag_name from JSON without requiring jq — grep for the field and + # strip surrounding quotes/whitespace with sed. + TAG="$(printf '%s' "$API_RESPONSE" | grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/')" || true if [[ -z "$TAG" ]]; then echo "No release found in $REPO" >&2 exit 1 fi fi +DOWNLOAD_BASE="https://github.com/${REPO}/releases/download/${TAG}" + echo "Downloading ${ASSET} from ${REPO}@${TAG}..." -gh release download "$TAG" --repo "$REPO" --pattern "$ASSET" --dir "$TMP_DIR" --clobber +curl -fsSL -o "$TMP_DIR/$ASSET" "${DOWNLOAD_BASE}/${ASSET}" if [[ "$SKIP_CHECKSUM" -eq 0 ]]; then echo "Verifying checksum..." - gh release download "$TAG" --repo "$REPO" --pattern "checksums.txt" --dir "$TMP_DIR" --clobber + curl -fsSL -o "$TMP_DIR/checksums.txt" "${DOWNLOAD_BASE}/checksums.txt" EXPECTED="$(awk -v file="$ASSET" '$2 == file { print $1 }' "$TMP_DIR/checksums.txt" | head -n1)" if [[ -z "$EXPECTED" ]]; then echo "Could not find checksum entry for $ASSET" >&2