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
98 changes: 72 additions & 26 deletions internal/commands/upgrade.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ package commands
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
Expand Down Expand Up @@ -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
Expand All @@ -79,6 +76,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-*")
Expand All @@ -87,15 +93,15 @@ 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)
}

assetPath := filepath.Join(tmpDir, asset)

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 {
Expand All @@ -113,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 {
Expand All @@ -150,15 +145,66 @@ func detectPlatform() (string, string, error) {
return osName, archName, nil
}

func ghDownload(tag, pattern, dir string) error {
out, err := exec.Command("gh", "release", "download", tag,
"--repo", upgradeRepo,
"--pattern", pattern,
"--dir", dir,
"--clobber",
).CombinedOutput()
// resolveLatestTag queries the GitHub API for the latest release tag.
func resolveLatestTag() (string, error) {
url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", upgradeRepo)
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("%s: %s", err, strings.TrimSpace(string(out)))
return "", fmt.Errorf("GitHub API request failed: %w", err)
}
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
}

// 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("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
}
Expand Down
37 changes: 26 additions & 11 deletions internal/version/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ package version

import (
"context"
"encoding/json"
"fmt"
"os/exec"
"net/http"
"strconv"
"strings"
"time"
Expand All @@ -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).
//
Expand All @@ -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) {
Expand Down Expand Up @@ -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.
Expand Down
35 changes: 20 additions & 15 deletions scripts/install-release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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

Expand All @@ -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" ;;
Expand All @@ -110,12 +100,27 @@ 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
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
Expand Down
Loading