From 0ae01efb4ec7a0f82309d41e92a491a33db61b55 Mon Sep 17 00:00:00 2001 From: Samuel Date: Tue, 21 Jul 2026 11:18:08 +0800 Subject: [PATCH 1/7] add release automation --- .github/workflows/release.yml | 54 +++++++++++++++++++++++++++++++++ .github/workflows/test.yml | 56 +++++++++++++++++++++++++++++++++++ .goreleaser.yaml | 39 ++++++++++++++++++++++-- cmd/etherscan/main.go | 2 +- 4 files changed, 147 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ead33db --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,54 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + id-token: write + attestations: write + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + release: + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache-dependency-path: go.sum + + - name: Test + run: go test ./... + + - name: Install Syft + id: syft + uses: anchore/sbom-action/download-syft@v0 + with: + syft-version: v1.44.0 + + - name: Release with GoReleaser + uses: goreleaser/goreleaser-action@v7 + with: + distribution: goreleaser + version: v2.17.0 + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Attest release artifacts + uses: actions/attest@v4 + with: + subject-checksums: dist/checksums.txt diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..f9919fd --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,56 @@ +name: Test + +on: + pull_request: + push: + branches: + - master + +permissions: + contents: read + +jobs: + test: + name: ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - macos-latest + - windows-latest + + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache-dependency-path: go.sum + + - name: Test + run: go test ./... + + - name: Build CLI + run: go build ./cmd/etherscan + + release-config: + name: GoReleaser configuration + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Install GoReleaser + uses: goreleaser/goreleaser-action@v7 + with: + distribution: goreleaser + version: v2.17.0 + install-only: true + + - name: Validate GoReleaser configuration + run: goreleaser check diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 1053912..c59d340 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -2,6 +2,11 @@ version: 2 project_name: etherscan +report_sizes: true + +gomod: + proxy: true + builds: - id: etherscan main: ./cmd/etherscan @@ -15,14 +20,42 @@ builds: goarch: - amd64 - arm64 + flags: + - -trimpath + mod_timestamp: "{{ .CommitTimestamp }}" ldflags: - - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} + - >- + -s -w + -X main.version={{ .Version }} + -X main.commit={{ .Commit }} + -X main.date={{ .CommitDate }} archives: - - formats: [tar.gz] + - id: etherscan + ids: + - etherscan + name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + formats: + - tar.gz format_overrides: - goos: windows - formats: [zip] + formats: + - zip + files: + - README.md checksum: name_template: checksums.txt + +sboms: + - id: archives + artifacts: archive + +snapshot: + version_template: "{{ incpatch .Version }}-next" + +changelog: + sort: asc + +release: + prerelease: auto diff --git a/cmd/etherscan/main.go b/cmd/etherscan/main.go index aac7568..58dcedb 100644 --- a/cmd/etherscan/main.go +++ b/cmd/etherscan/main.go @@ -10,7 +10,7 @@ import ( ) var ( - version = "1.0" + version = "dev" commit = "none" date = "unknown" ) From 8f1c8c3680f86bdf9498a09d7c56705d75a47505 Mon Sep 17 00:00:00 2001 From: Samuel Date: Tue, 21 Jul 2026 12:11:40 +0800 Subject: [PATCH 2/7] update --- .goreleaser.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index c59d340..82cc92b 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -4,9 +4,6 @@ project_name: etherscan report_sizes: true -gomod: - proxy: true - builds: - id: etherscan main: ./cmd/etherscan From bf0d8a64a41fa5f4710a3f2446ac3eb46bb76f0b Mon Sep 17 00:00:00 2001 From: Samuel Date: Tue, 21 Jul 2026 12:23:04 +0800 Subject: [PATCH 3/7] update --- .github/workflows/release.yml | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ead33db..2183b56 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,12 +7,6 @@ on: permissions: contents: write - id-token: write - attestations: write - -concurrency: - group: release-${{ github.ref }} - cancel-in-progress: false jobs: release: @@ -23,6 +17,7 @@ jobs: uses: actions/checkout@v6 with: fetch-depth: 0 + persist-credentials: false - name: Set up Go uses: actions/setup-go@v6 @@ -34,7 +29,6 @@ jobs: run: go test ./... - name: Install Syft - id: syft uses: anchore/sbom-action/download-syft@v0 with: syft-version: v1.44.0 @@ -42,13 +36,7 @@ jobs: - name: Release with GoReleaser uses: goreleaser/goreleaser-action@v7 with: - distribution: goreleaser version: v2.17.0 args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Attest release artifacts - uses: actions/attest@v4 - with: - subject-checksums: dist/checksums.txt From 5c956df8c271a698ce9c6a3a340b40461b09ac6c Mon Sep 17 00:00:00 2001 From: Samuel Date: Tue, 21 Jul 2026 12:44:17 +0800 Subject: [PATCH 4/7] update --- .github/workflows/release.yml | 5 ----- .goreleaser.yaml | 4 ---- 2 files changed, 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2183b56..eed0d17 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,11 +28,6 @@ jobs: - name: Test run: go test ./... - - name: Install Syft - uses: anchore/sbom-action/download-syft@v0 - with: - syft-version: v1.44.0 - - name: Release with GoReleaser uses: goreleaser/goreleaser-action@v7 with: diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 82cc92b..f97b295 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -44,10 +44,6 @@ archives: checksum: name_template: checksums.txt -sboms: - - id: archives - artifacts: archive - snapshot: version_template: "{{ incpatch .Version }}-next" From 5408b0837ca3e1399b542ec19e1e03848fc6cda9 Mon Sep 17 00:00:00 2001 From: Samuel Date: Tue, 21 Jul 2026 17:35:16 +0800 Subject: [PATCH 5/7] add installer --- .github/workflows/installers.yml | 40 ++++++ README.md | 20 ++- scripts/install.ps1 | 236 +++++++++++++++++++++++++++++++ scripts/install.sh | 208 +++++++++++++++++++++++++++ scripts/test-install.ps1 | 85 +++++++++++ scripts/test-install.sh | 81 +++++++++++ 6 files changed, 669 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/installers.yml create mode 100644 scripts/install.ps1 create mode 100644 scripts/install.sh create mode 100644 scripts/test-install.ps1 create mode 100644 scripts/test-install.sh diff --git a/.github/workflows/installers.yml b/.github/workflows/installers.yml new file mode 100644 index 0000000..3b8b938 --- /dev/null +++ b/.github/workflows/installers.yml @@ -0,0 +1,40 @@ +name: Installers + +on: + pull_request: + push: + branches: + - master + +permissions: + contents: read + +jobs: + powershell: + name: Windows PowerShell + runs-on: windows-latest + + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Test PowerShell installer + shell: pwsh + run: ./scripts/test-install.ps1 + + shell: + name: ${{ matrix.os }} shell + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - macos-latest + + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Test shell installer + run: sh ./scripts/test-install.sh diff --git a/README.md b/README.md index 058995a..b7bd58d 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,25 @@ Etherscan's API documentation remains as the main reference for endpoint paramet ## Installation -Download a prebuilt binary from [GitHub Releases](https://github.com/etherscan/etherscan-cli/releases) +### Windows + +```powershell +irm https://raw.githubusercontent.com/etherscan/etherscan-cli/master/scripts/install.ps1 | iex +``` + +### macOS and Linux + +```sh +curl -fsSL https://raw.githubusercontent.com/etherscan/etherscan-cli/master/scripts/install.sh | sh +``` + +The installers select the correct binary, verify its checksum, and add `etherscan` to your `PATH`. Open a new terminal after installation. + +### Manual installation + +Download the appropriate archive and `checksums.txt` from [GitHub Releases](https://github.com/etherscan/etherscan-cli/releases). Verify the checksum, extract the archive, and place `etherscan` on your `PATH`. + +If installation fails, [open a GitHub issue](https://github.com/etherscan/etherscan-cli/issues/new). ## Quickstart diff --git a/scripts/install.ps1 b/scripts/install.ps1 new file mode 100644 index 0000000..ba7ad0e --- /dev/null +++ b/scripts/install.ps1 @@ -0,0 +1,236 @@ +[CmdletBinding()] +param( + [string]$Version = $env:ETHERSCAN_VERSION, + [string]$InstallDir = $env:ETHERSCAN_INSTALL_DIR, + [switch]$NoPathUpdate +) + +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" + +$Repository = "etherscan/etherscan-cli" +$DownloadBaseUrl = $env:ETHERSCAN_INSTALL_TEST_DOWNLOAD_BASE_URL + +function Get-EtherscanArchitecture { + $architecture = $env:PROCESSOR_ARCHITEW6432 + if ([string]::IsNullOrWhiteSpace($architecture)) { + $architecture = $env:PROCESSOR_ARCHITECTURE + } + + switch -Regex ($architecture) { + "^(AMD64|x86_64)$" { return "amd64" } + "^(ARM64|aarch64)$" { return "arm64" } + default { throw "Unsupported Windows architecture: $architecture. Etherscan CLI supports amd64 and arm64." } + } +} + +function Get-GitHubApiHeaders { + $headers = @{ + Accept = "application/vnd.github+json" + "User-Agent" = "etherscan-cli-installer" + "X-GitHub-Api-Version" = "2022-11-28" + } + if (-not [string]::IsNullOrWhiteSpace($env:ETHERSCAN_GITHUB_TOKEN)) { + $headers.Authorization = "Bearer $($env:ETHERSCAN_GITHUB_TOKEN)" + } + return $headers +} + +function Resolve-EtherscanVersion { + param([string]$RequestedVersion) + + if (-not [string]::IsNullOrWhiteSpace($RequestedVersion) -and $RequestedVersion -ne "latest") { + $tag = if ($RequestedVersion.StartsWith("v")) { $RequestedVersion } else { "v$RequestedVersion" } + } + else { + if (-not [string]::IsNullOrWhiteSpace($DownloadBaseUrl)) { + throw "A version is required when the installer test download source is used." + } + $release = Invoke-RestMethod ` + -Uri "https://api.github.com/repos/$Repository/releases/latest" ` + -Headers (Get-GitHubApiHeaders) + $tag = [string]$release.tag_name + } + + if ($tag -notmatch '^v[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$') { + throw "Invalid release version: $tag" + } + + return @{ + Tag = $tag + Version = $tag.Substring(1) + } +} + +function Copy-InstallerFile { + param( + [string]$Base, + [string]$Name, + [string]$Destination + ) + + if (Test-Path -LiteralPath $Base -PathType Container) { + Copy-Item -LiteralPath (Join-Path $Base $Name) -Destination $Destination + return + } + + $uri = "$($Base.TrimEnd('/'))/$Name" + $parsedUri = [Uri]$uri + if ($parsedUri.Scheme -ne "https") { + throw "Remote downloads must use HTTPS: $uri" + } + + $headers = @{ + "User-Agent" = "etherscan-cli-installer" + } + if ($parsedUri.Host -in @("github.com", "api.github.com") -and + -not [string]::IsNullOrWhiteSpace($env:ETHERSCAN_GITHUB_TOKEN)) { + $headers.Authorization = "Bearer $($env:ETHERSCAN_GITHUB_TOKEN)" + } + + Invoke-WebRequest -Uri $uri -OutFile $Destination -Headers $headers -UseBasicParsing +} + +function Add-EtherscanToUserPath { + param([string]$Directory) + + $fullDirectory = [IO.Path]::GetFullPath($Directory).TrimEnd('\') + $userPath = [Environment]::GetEnvironmentVariable("Path", "User") + $entries = @($userPath -split ';' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + $alreadyPresent = $entries | Where-Object { + try { + $expandedEntry = [Environment]::ExpandEnvironmentVariables($_) + [IO.Path]::GetFullPath($expandedEntry).TrimEnd('\').Equals($fullDirectory, [StringComparison]::OrdinalIgnoreCase) + } + catch { + $_.TrimEnd('\').Equals($fullDirectory, [StringComparison]::OrdinalIgnoreCase) + } + } + + if (-not $alreadyPresent) { + $newUserPath = if ([string]::IsNullOrWhiteSpace($userPath)) { + $fullDirectory + } + else { + "$($userPath.TrimEnd(';'));$fullDirectory" + } + [Environment]::SetEnvironmentVariable("Path", $newUserPath, "User") + Write-Host "Added $fullDirectory to your user PATH." + } + + $processEntries = @($env:Path -split ';') + if (-not ($processEntries | Where-Object { $_.TrimEnd('\').Equals($fullDirectory, [StringComparison]::OrdinalIgnoreCase) })) { + $env:Path = "$env:Path;$fullDirectory" + } +} + +if ($env:OS -ne "Windows_NT") { + throw "This installer supports Windows only. Use install.sh on macOS or Linux." +} + +if ([string]::IsNullOrWhiteSpace($InstallDir)) { + $localAppData = [Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData) + $InstallDir = Join-Path $localAppData "Programs\Etherscan\bin" +} +if ($InstallDir.Contains(';')) { + throw "The installation directory cannot contain a semicolon." +} +if ($InstallDir.IndexOfAny([char[]]"`r`n") -ge 0) { + throw "The installation directory cannot contain a line break." +} + +$resolved = Resolve-EtherscanVersion -RequestedVersion $Version +$architecture = Get-EtherscanArchitecture +$archiveName = "etherscan_$($resolved.Version)_windows_$architecture.zip" +$baseUrl = if ([string]::IsNullOrWhiteSpace($DownloadBaseUrl)) { + "https://github.com/$Repository/releases/download/$($resolved.Tag)" +} +else { + $DownloadBaseUrl +} + +$tempDirectory = Join-Path ([IO.Path]::GetTempPath()) "etherscan-install-$PID-$([Guid]::NewGuid().ToString('N'))" +$archivePath = Join-Path $tempDirectory $archiveName +$checksumPath = Join-Path $tempDirectory "checksums.txt" +$sourceExecutable = Join-Path $tempDirectory "etherscan.exe" + +try { + New-Item -ItemType Directory -Path $tempDirectory -Force | Out-Null + + Write-Host "Downloading Etherscan CLI $($resolved.Version) for windows/$architecture..." + Copy-InstallerFile -Base $baseUrl -Name $archiveName -Destination $archivePath + Copy-InstallerFile -Base $baseUrl -Name "checksums.txt" -Destination $checksumPath + + $pattern = '^([0-9A-Fa-f]{64})\s+\*?' + [Regex]::Escape($archiveName) + '$' + $checksumLine = Get-Content -LiteralPath $checksumPath | Where-Object { $_ -match $pattern } | Select-Object -First 1 + if (-not $checksumLine -or $checksumLine -notmatch $pattern) { + throw "No checksum was published for $archiveName." + } + + $expectedHash = $Matches[1].ToLowerInvariant() + $actualHash = (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actualHash -ne $expectedHash) { + throw "Checksum verification failed for $archiveName. Expected $expectedHash, received $actualHash." + } + + Add-Type -AssemblyName System.IO.Compression.FileSystem + $zip = [IO.Compression.ZipFile]::OpenRead($archivePath) + try { + $executableEntries = @($zip.Entries | Where-Object { $_.FullName.Replace('\', '/') -eq "etherscan.exe" }) + if ($executableEntries.Count -ne 1) { + throw "$archiveName must contain exactly one root-level etherscan.exe." + } + + $inputStream = $executableEntries[0].Open() + $outputStream = [IO.File]::Create($sourceExecutable) + try { + $inputStream.CopyTo($outputStream) + } + finally { + $outputStream.Dispose() + $inputStream.Dispose() + } + } + finally { + $zip.Dispose() + } + + New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null + $targetExecutable = Join-Path $InstallDir "etherscan.exe" + $stagedExecutable = Join-Path $InstallDir ".etherscan.exe.new-$PID" + $backupExecutable = Join-Path $InstallDir ".etherscan.exe.old-$PID" + Copy-Item -LiteralPath $sourceExecutable -Destination $stagedExecutable -Force + + try { + if (Test-Path -LiteralPath $targetExecutable) { + Move-Item -LiteralPath $targetExecutable -Destination $backupExecutable -Force + } + Move-Item -LiteralPath $stagedExecutable -Destination $targetExecutable -Force + Remove-Item -LiteralPath $backupExecutable -Force -ErrorAction SilentlyContinue + } + catch { + Remove-Item -LiteralPath $stagedExecutable -Force -ErrorAction SilentlyContinue + if ((Test-Path -LiteralPath $backupExecutable) -and -not (Test-Path -LiteralPath $targetExecutable)) { + Move-Item -LiteralPath $backupExecutable -Destination $targetExecutable -Force + } + throw + } + + if (-not $NoPathUpdate) { + Add-EtherscanToUserPath -Directory $InstallDir + } + + Write-Host "" + Write-Host "Etherscan CLI $($resolved.Version) installed successfully." + Write-Host "Installed to: $targetExecutable" + if ($NoPathUpdate) { + Write-Host "Add $InstallDir to PATH to run etherscan from any directory." + } + else { + Write-Host "Run 'etherscan version' to verify the installation." + Write-Host "Open a new terminal if the command is not yet available." + } +} +finally { + Remove-Item -LiteralPath $tempDirectory -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000..4302b23 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,208 @@ +#!/bin/sh + +set -eu + +repository="etherscan/etherscan-cli" +version=${ETHERSCAN_VERSION:-} +install_dir=${ETHERSCAN_INSTALL_DIR:-"$HOME/.local/bin"} +download_base=${ETHERSCAN_INSTALL_TEST_DOWNLOAD_BASE_URL:-} +update_path=1 + +usage() { + cat <<'EOF' +Install Etherscan CLI. + +Usage: install.sh [options] + +Options: + --version VERSION Install a specific version (for example, v1.1.0). + --install-dir DIRECTORY Install into DIRECTORY (default: ~/.local/bin). + --no-path-update Do not update the shell profile. + -h, --help Show this help. +EOF +} + +die() { + printf 'error: %s\n' "$*" >&2 + exit 1 +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --version) + [ "$#" -ge 2 ] || die "--version requires a value" + version=$2 + shift 2 + ;; + --install-dir) + [ "$#" -ge 2 ] || die "--install-dir requires a value" + install_dir=$2 + shift 2 + ;; + --no-path-update) + update_path=0 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + die "unknown option: $1" + ;; + esac +done + +if printf '%s' "$install_dir" | LC_ALL=C grep '[[:cntrl:]]' >/dev/null 2>&1; then + die "the installation directory cannot contain control characters" +fi + +fetch_stdout() { + url=$1 + if command -v curl >/dev/null 2>&1; then + curl -fsSL -A etherscan-cli-installer "$url" + elif command -v wget >/dev/null 2>&1; then + wget -qO- --user-agent=etherscan-cli-installer "$url" + else + die "curl or wget is required" + fi +} + +fetch_file() { + base=$1 + name=$2 + destination=$3 + + if [ -d "$base" ]; then + cp "$base/$name" "$destination" + return + fi + + case "$base" in + file://*) + cp "${base#file://}/$name" "$destination" + ;; + https://*) + if command -v curl >/dev/null 2>&1; then + curl -fsSL -A etherscan-cli-installer "$base/$name" -o "$destination" + elif command -v wget >/dev/null 2>&1; then + wget -q --user-agent=etherscan-cli-installer "$base/$name" -O "$destination" + else + die "curl or wget is required" + fi + ;; + *) + die "invalid download base URL or directory: $base" + ;; + esac +} + +if [ -z "$version" ] || [ "$version" = latest ]; then + [ -z "$download_base" ] || die "a version is required with the installer test download source" + release_json=$(fetch_stdout "https://api.github.com/repos/$repository/releases/latest") + version=$(printf '%s\n' "$release_json" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | sed -n '1p') + [ -n "$version" ] || die "could not resolve the latest Etherscan CLI version" +fi + +case "$version" in + v*) tag=$version; release_version=${version#v} ;; + *) tag="v$version"; release_version=$version ;; +esac + +printf '%s\n' "$tag" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$' || die "invalid release version: $tag" + +system_name=${ETHERSCAN_INSTALL_TEST_OS:-$(uname -s)} +case "$system_name" in + Linux|linux) os=linux ;; + Darwin|darwin) os=darwin ;; + *) die "unsupported operating system: $system_name" ;; +esac + +machine_arch=${ETHERSCAN_INSTALL_TEST_ARCH:-$(uname -m)} +case "$machine_arch" in + x86_64|amd64) arch=amd64 ;; + arm64|aarch64) arch=arm64 ;; + *) die "unsupported architecture: $machine_arch. Etherscan CLI supports amd64 and arm64." ;; +esac + +archive_name="etherscan_${release_version}_${os}_${arch}.tar.gz" +if [ -z "$download_base" ]; then + download_base="https://github.com/$repository/releases/download/$tag" +fi + +temp_dir=$(mktemp -d 2>/dev/null || mktemp -d -t etherscan-install) +trap 'rm -rf "$temp_dir"' EXIT HUP INT TERM +archive_path="$temp_dir/$archive_name" +checksum_path="$temp_dir/checksums.txt" +source_executable="$temp_dir/etherscan" + +printf 'Downloading Etherscan CLI %s for %s/%s...\n' "$release_version" "$os" "$arch" +fetch_file "$download_base" "$archive_name" "$archive_path" +fetch_file "$download_base" checksums.txt "$checksum_path" + +expected_hash=$(awk -v name="$archive_name" '$2 == name || $2 == ("*" name) { print tolower($1); exit }' "$checksum_path") +[ -n "$expected_hash" ] || die "no checksum was published for $archive_name" +printf '%s\n' "$expected_hash" | grep -Eq '^[0-9a-f]{64}$' || die "invalid checksum published for $archive_name" + +if command -v sha256sum >/dev/null 2>&1; then + actual_hash=$(sha256sum "$archive_path" | awk '{ print tolower($1) }') +elif command -v shasum >/dev/null 2>&1; then + actual_hash=$(shasum -a 256 "$archive_path" | awk '{ print tolower($1) }') +else + die "sha256sum or shasum is required to verify the download" +fi + +[ "$actual_hash" = "$expected_hash" ] || die "checksum verification failed for $archive_name" + +entry_count=$(tar -tzf "$archive_path" | awk '$0 == "etherscan" { count++ } END { print count + 0 }') +[ "$entry_count" -eq 1 ] || die "$archive_name must contain exactly one root-level etherscan" +tar -xOzf "$archive_path" etherscan >"$source_executable" +[ -s "$source_executable" ] || die "$archive_name contains an empty etherscan executable" + +mkdir -p "$install_dir" +staged_executable="$install_dir/.etherscan.new.$$" +cp "$source_executable" "$staged_executable" +chmod 0755 "$staged_executable" +mv -f "$staged_executable" "$install_dir/etherscan" + +path_updated=0 +if [ "$update_path" -eq 1 ]; then + case ":$PATH:" in + *:"$install_dir":*) ;; + *) + shell_name=${SHELL:-sh} + shell_name=${shell_name##*/} + escaped_install_dir=$(printf '%s' "$install_dir" | sed 's/[\\"$`]/\\&/g') + if [ "$shell_name" = fish ]; then + profile="$HOME/.config/fish/config.fish" + mkdir -p "$(dirname "$profile")" + path_line="fish_add_path \"$escaped_install_dir\"" + else + case "$shell_name" in + zsh) profile="$HOME/.zshrc" ;; + bash) profile="$HOME/.bashrc" ;; + *) profile="$HOME/.profile" ;; + esac + path_line="export PATH=\"$escaped_install_dir:\$PATH\"" + fi + + if ! [ -f "$profile" ] || ! grep -F -e "$install_dir" "$profile" >/dev/null 2>&1; then + { + printf '\n# Etherscan CLI\n' + printf '%s\n' "$path_line" + } >>"$profile" + path_updated=1 + fi + ;; + esac +fi + +printf '\nEtherscan CLI %s installed successfully.\n' "$release_version" +printf 'Installed to: %s\n' "$install_dir/etherscan" +if [ "$update_path" -eq 0 ]; then + printf 'Add %s to PATH to run etherscan from any directory.\n' "$install_dir" +elif [ "$path_updated" -eq 1 ]; then + printf 'Open a new terminal, then run: etherscan version\n' +else + printf 'Run: etherscan version\n' +fi diff --git a/scripts/test-install.ps1 b/scripts/test-install.ps1 new file mode 100644 index 0000000..cab98b9 --- /dev/null +++ b/scripts/test-install.ps1 @@ -0,0 +1,85 @@ +$ErrorActionPreference = "Stop" + +$installer = Join-Path $PSScriptRoot "install.ps1" +$tempDirectory = Join-Path ([IO.Path]::GetTempPath()) "etherscan-installer-test-$PID-$([Guid]::NewGuid().ToString('N'))" +$fixtureDirectory = Join-Path $tempDirectory "fixtures" +$bundleDirectory = Join-Path $tempDirectory "bundle" +$installDirectory = Join-Path $tempDirectory "install dir" +$version = "9.9.9-test.1" + +$architecture = $env:PROCESSOR_ARCHITEW6432 +if ([string]::IsNullOrWhiteSpace($architecture)) { + $architecture = $env:PROCESSOR_ARCHITECTURE +} +$goArchitecture = switch -Regex ($architecture) { + "^(AMD64|x86_64)$" { "amd64" } + "^(ARM64|aarch64)$" { "arm64" } + default { throw "Unsupported test architecture: $architecture" } +} +$archiveName = "etherscan_${version}_windows_$goArchitecture.zip" +$archivePath = Join-Path $fixtureDirectory $archiveName +$checksumPath = Join-Path $fixtureDirectory "checksums.txt" +$previousDownloadBaseUrl = $env:ETHERSCAN_INSTALL_TEST_DOWNLOAD_BASE_URL + +function Write-Fixture { + param([string]$Content) + + Remove-Item -LiteralPath $bundleDirectory -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $archivePath -Force -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Path $bundleDirectory -Force | Out-Null + Set-Content -LiteralPath (Join-Path $bundleDirectory "etherscan.exe") -Value $Content -NoNewline + Compress-Archive -Path (Join-Path $bundleDirectory "*") -DestinationPath $archivePath + $hash = (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash.ToLowerInvariant() + Set-Content -LiteralPath $checksumPath -Value "$hash $archiveName" +} + +try { + New-Item -ItemType Directory -Path $fixtureDirectory -Force | Out-Null + $env:ETHERSCAN_INSTALL_TEST_DOWNLOAD_BASE_URL = $fixtureDirectory + + Write-Fixture -Content "first" + & $installer -Version $version -InstallDir $installDirectory -NoPathUpdate + $installed = Join-Path $installDirectory "etherscan.exe" + if ((Get-Content -LiteralPath $installed -Raw) -ne "first") { + throw "fresh installation did not install the expected executable" + } + + Write-Fixture -Content "second" + & $installer -Version $version -InstallDir $installDirectory -NoPathUpdate + if ((Get-Content -LiteralPath $installed -Raw) -ne "second") { + throw "reinstallation did not replace the executable" + } + + Set-Content -LiteralPath $checksumPath -Value "$('0' * 64) $archiveName" + $failed = $false + try { + & $installer -Version $version -InstallDir $installDirectory -NoPathUpdate + } + catch { + $failed = $_.Exception.Message -like "Checksum verification failed*" + } + if (-not $failed) { + throw "installer accepted an invalid checksum" + } + if ((Get-Content -LiteralPath $installed -Raw) -ne "second") { + throw "failed verification modified the installed executable" + } + + $failed = $false + try { + $env:ETHERSCAN_INSTALL_TEST_DOWNLOAD_BASE_URL = "http://example.invalid" + & $installer -Version $version -InstallDir $installDirectory -NoPathUpdate + } + catch { + $failed = $_.Exception.Message -like "Remote downloads must use HTTPS*" + } + if (-not $failed) { + throw "installer accepted an insecure download URL" + } + + Write-Host "PowerShell installer tests passed." +} +finally { + $env:ETHERSCAN_INSTALL_TEST_DOWNLOAD_BASE_URL = $previousDownloadBaseUrl + Remove-Item -LiteralPath $tempDirectory -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/scripts/test-install.sh b/scripts/test-install.sh new file mode 100644 index 0000000..b606082 --- /dev/null +++ b/scripts/test-install.sh @@ -0,0 +1,81 @@ +#!/bin/sh + +set -eu + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +installer="$script_dir/install.sh" +temp_dir=$(mktemp -d 2>/dev/null || mktemp -d -t etherscan-installer-test) +trap 'rm -rf "$temp_dir"' EXIT HUP INT TERM + +fixture_dir="$temp_dir/fixtures" +bundle_dir="$temp_dir/bundle" +install_dir="$temp_dir/install dir" +version=9.9.9-test.1 + +case "$(uname -s)" in + Linux) os=linux ;; + Darwin) os=darwin ;; + MINGW*|MSYS*|CYGWIN*) + os=linux + export ETHERSCAN_INSTALL_TEST_OS=linux + ;; + *) printf 'unsupported test OS\n' >&2; exit 1 ;; +esac + +case "$(uname -m)" in + x86_64|amd64) arch=amd64 ;; + arm64|aarch64) arch=arm64 ;; + *) printf 'unsupported test architecture\n' >&2; exit 1 ;; +esac +export ETHERSCAN_INSTALL_TEST_ARCH=$arch + +archive_name="etherscan_${version}_${os}_${arch}.tar.gz" +archive_path="$fixture_dir/$archive_name" +checksum_path="$fixture_dir/checksums.txt" +mkdir -p "$fixture_dir" +export ETHERSCAN_INSTALL_TEST_DOWNLOAD_BASE_URL=$fixture_dir + +write_fixture() { + content=$1 + rm -rf "$bundle_dir" + mkdir -p "$bundle_dir" + printf '%s' "$content" >"$bundle_dir/etherscan" + chmod 0755 "$bundle_dir/etherscan" + tar -czf "$archive_path" -C "$bundle_dir" etherscan + if command -v sha256sum >/dev/null 2>&1; then + hash=$(sha256sum "$archive_path" | awk '{ print tolower($1) }') + else + hash=$(shasum -a 256 "$archive_path" | awk '{ print tolower($1) }') + fi + printf '%s %s\n' "$hash" "$archive_name" >"$checksum_path" +} + +write_fixture first +sh "$installer" --version "$version" --install-dir "$install_dir" --no-path-update +[ "$(cat "$install_dir/etherscan")" = first ] || { printf 'fresh installation failed\n' >&2; exit 1; } + +write_fixture second +sh "$installer" --version "$version" --install-dir "$install_dir" --no-path-update +[ "$(cat "$install_dir/etherscan")" = second ] || { printf 'reinstallation failed\n' >&2; exit 1; } + +profile_home="$temp_dir/profile-home" +profile_install_dir="$profile_home/bin with spaces" +mkdir -p "$profile_home" +HOME="$profile_home" SHELL=/bin/sh sh "$installer" --version "$version" --install-dir "$profile_install_dir" +grep -F "$profile_install_dir" "$profile_home/.profile" >/dev/null || { printf 'PATH profile update failed\n' >&2; exit 1; } +HOME="$profile_home" SHELL=/bin/sh sh "$installer" --version "$version" --install-dir "$profile_install_dir" +[ "$(grep -Fc '# Etherscan CLI' "$profile_home/.profile")" -eq 1 ] || { printf 'PATH profile update was not idempotent\n' >&2; exit 1; } + +printf '%064d %s\n' 0 "$archive_name" >"$checksum_path" +if sh "$installer" --version "$version" --install-dir "$install_dir" --no-path-update >/dev/null 2>&1; then + printf 'installer accepted an invalid checksum\n' >&2 + exit 1 +fi +[ "$(cat "$install_dir/etherscan")" = second ] || { printf 'failed verification changed installation\n' >&2; exit 1; } + +if ETHERSCAN_INSTALL_TEST_DOWNLOAD_BASE_URL="http://example.invalid" sh "$installer" --version "$version" --install-dir "$install_dir" --no-path-update >/dev/null 2>&1; then + printf 'installer accepted an insecure download URL\n' >&2 + exit 1 +fi + +printf 'Shell installer tests passed.\n' From 74d11a3648c117de4350cb6bb63e16ea412ce424 Mon Sep 17 00:00:00 2001 From: Samuel Date: Wed, 22 Jul 2026 11:45:46 +0800 Subject: [PATCH 6/7] add Homebrew distribution and self-update --- .github/workflows/release.yml | 1 + .github/workflows/test.yml | 4 +- .goreleaser.yaml | 19 +++ LICENSE | 21 +++ README.md | 28 +++- internal/cli/root.go | 105 ++++++++++++- internal/cli/update_test.go | 91 +++++++++++ internal/updater/command_unix.go | 7 + internal/updater/command_windows.go | 12 ++ internal/updater/updater.go | 214 ++++++++++++++++++++++++++ internal/updater/updater_test.go | 114 ++++++++++++++ internal/updater/upgrade.go | 224 ++++++++++++++++++++++++++++ internal/updater/upgrade_test.go | 109 ++++++++++++++ internal/updater/version.go | 116 ++++++++++++++ internal/updater/version_test.go | 42 ++++++ scripts/install.ps1 | 10 +- 16 files changed, 1111 insertions(+), 6 deletions(-) create mode 100644 LICENSE create mode 100644 internal/cli/update_test.go create mode 100644 internal/updater/command_unix.go create mode 100644 internal/updater/command_windows.go create mode 100644 internal/updater/updater.go create mode 100644 internal/updater/updater_test.go create mode 100644 internal/updater/upgrade.go create mode 100644 internal/updater/upgrade_test.go create mode 100644 internal/updater/version.go create mode 100644 internal/updater/version_test.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eed0d17..0af204b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,3 +35,4 @@ jobs: args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f9919fd..59ac7d1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -52,5 +52,5 @@ jobs: version: v2.17.0 install-only: true - - name: Validate GoReleaser configuration - run: goreleaser check + - name: Validate snapshot release + run: goreleaser release --snapshot --clean --skip=publish diff --git a/.goreleaser.yaml b/.goreleaser.yaml index f97b295..138afde 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -40,6 +40,7 @@ archives: - zip files: - README.md + - LICENSE checksum: name_template: checksums.txt @@ -52,3 +53,21 @@ changelog: release: prerelease: auto + +brews: + - name: etherscan + ids: + - etherscan + repository: + owner: etherscan + name: homebrew-etherscan-cli + token: "{{ .Env.HOMEBREW_TAP_TOKEN }}" + directory: Formula + homepage: "https://github.com/etherscan/etherscan-cli" + description: "Command-line client and interactive explorer for the Etherscan V2 API" + license: MIT + install: | + bin.install "etherscan" + test: | + system "#{bin}/etherscan", "version" + skip_upload: '{{ if index .Env "HOMEBREW_TAP_TOKEN" }}auto{{ else }}true{{ end }}' diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..eaeaca4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Etherscan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index b7bd58d..b7cec9a 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,26 @@ Etherscan's API documentation remains as the main reference for endpoint paramet ## Installation +### Homebrew (macOS and Linux) + +```sh +brew install etherscan/etherscan-cli/etherscan +``` + +Or tap first, then install: + +```sh +brew tap etherscan/etherscan-cli +brew install etherscan +``` + ### Windows ```powershell irm https://raw.githubusercontent.com/etherscan/etherscan-cli/master/scripts/install.ps1 | iex ``` -### macOS and Linux +### Installation script (macOS and Linux) ```sh curl -fsSL https://raw.githubusercontent.com/etherscan/etherscan-cli/master/scripts/install.sh | sh @@ -26,6 +39,14 @@ Download the appropriate archive and `checksums.txt` from [GitHub Releases](http If installation fails, [open a GitHub issue](https://github.com/etherscan/etherscan-cli/issues/new). +### Updating + +Update to the latest stable release with: + +```sh +etherscan update +``` + ## Quickstart Store and validate an Etherscan API key, then make your first request: @@ -93,6 +114,7 @@ If `--all` reaches `--max-pages`, the result may be truncated. | `etherscan login` | Validate and store an API key | | `etherscan logout` | Remove the stored API key | | `etherscan uninstall` | Remove all CLI configuration | +| `etherscan update` | Update to the latest stable release | | `etherscan whoami` | Show the active chain and saved API key | | `etherscan config` | Get, list, or set CLI configuration | | `etherscan chains list` | List chains built into this CLI release | @@ -235,3 +257,7 @@ If `--all` reaches `--max-pages`, the result may be truncated. | --- | --- | | `etherscan apilimit` | [getapilimit](https://docs.etherscan.io/api-reference/endpoint/getapilimit.md) | + +## License + +[MIT](LICENSE) diff --git a/internal/cli/root.go b/internal/cli/root.go index 255ee29..d4643e6 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -19,6 +19,7 @@ import ( "github.com/etherscan/etherscan-cli/internal/config" "github.com/etherscan/etherscan-cli/internal/output" "github.com/etherscan/etherscan-cli/internal/tui" + "github.com/etherscan/etherscan-cli/internal/updater" "github.com/spf13/cobra" "golang.org/x/term" ) @@ -47,7 +48,18 @@ type globalState struct { all bool } +type updateManager interface { + Check(context.Context, string, bool) (updater.Result, error) + Skip(string) error + DetectMethod() string + Upgrade(context.Context, string, string, io.Writer, io.Writer) (bool, error) +} + func NewRootCommand(info BuildInfo) *cobra.Command { + return newRootCommand(info, updater.NewService()) +} + +func newRootCommand(info BuildInfo, updates updateManager) *cobra.Command { state := &globalState{timeout: 30 * time.Second, rate: 3, maxPages: 20} root := &cobra.Command{ Use: "etherscan", @@ -60,6 +72,10 @@ func NewRootCommand(info BuildInfo) *cobra.Command { // piped/redirected (agents, scripts, CI) it prints a plain text splash so // nothing hangs waiting for keypresses. if interactiveTTY() { + exit, err := offerUpdate(cmd.Context(), updates, info.Version, cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr()) + if err != nil || exit { + return err + } return launchTUI(cmd.Context(), state, info) } printSplash(cmd.OutOrStdout(), info) @@ -83,7 +99,7 @@ func NewRootCommand(info BuildInfo) *cobra.Command { root.PersistentFlags().IntVar(&state.maxPages, "max-pages", 20, "maximum pages for --all") hideFlags(root, "apikey", "base-url", "compact", "max-pages", "rate-limit", "timeout", "verbose", "debug", "yes") - root.AddCommand(loginCommand(state), logoutCommand(state), uninstallCommand(state), configCommand(state), chainsCommand(), whoamiCommand(state), versionCommand(info), tuiCommand(state, info), completionCommand(root)) + root.AddCommand(loginCommand(state), logoutCommand(state), uninstallCommand(state), configCommand(state), chainsCommand(), whoamiCommand(state), versionCommand(info), updateCommand(info, updates), tuiCommand(state, info, updates), completionCommand(root)) addEndpointCommands(root, state) return root } @@ -516,7 +532,88 @@ func versionCommand(info BuildInfo) *cobra.Command { }} } -func tuiCommand(state *globalState, info BuildInfo) *cobra.Command { +func updateCommand(info BuildInfo, updates updateManager) *cobra.Command { + var method string + cmd := &cobra.Command{ + Use: "update", + Short: "Update Etherscan CLI to the latest stable release", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if method != "" && !updater.ValidMethod(method) { + return fmt.Errorf("unsupported update method %q (use homebrew or script)", method) + } + result, err := updates.Check(cmd.Context(), info.Version, true) + if err != nil { + return err + } + if !result.UpdateAvailable { + fmt.Fprintf(cmd.OutOrStdout(), "Etherscan CLI %s is already up to date.\n", result.Current) + return nil + } + if method == "" { + method = updates.DetectMethod() + } + fmt.Fprintf(cmd.OutOrStdout(), "Updating Etherscan CLI %s -> %s using %s...\n", result.Current, result.Latest, method) + background, err := updates.Upgrade(cmd.Context(), method, result.Latest, cmd.OutOrStdout(), cmd.ErrOrStderr()) + if err != nil { + return err + } + if background { + fmt.Fprintln(cmd.OutOrStdout(), "The update will finish after this process exits.") + } else { + fmt.Fprintf(cmd.OutOrStdout(), "Etherscan CLI %s installed. Restart the CLI to use it.\n", result.Latest) + } + return nil + }, + } + cmd.Flags().StringVar(&method, "method", "", "update method: homebrew or script") + return cmd +} + +func offerUpdate(ctx context.Context, updates updateManager, current string, in io.Reader, out, errOut io.Writer) (bool, error) { + result, err := updates.Check(ctx, current, false) + if err != nil || !result.UpdateAvailable { + return false, nil + } + fmt.Fprintf(out, "\nUpdate available! %s -> %s\n", result.Current, result.Latest) + if result.ReleaseURL != "" { + fmt.Fprintf(out, "Release notes: %s\n", result.ReleaseURL) + } + fmt.Fprintln(out, "\n1. Update now") + fmt.Fprintln(out, "2. Later") + fmt.Fprintln(out, "3. Skip this version") + fmt.Fprint(out, "\nChoose [1]: ") + choice, readErr := bufio.NewReader(in).ReadString('\n') + if readErr != nil && !errors.Is(readErr, io.EOF) { + return false, nil + } + switch strings.TrimSpace(choice) { + case "", "1": + method := updates.DetectMethod() + fmt.Fprintf(out, "Updating with %s...\n", method) + background, err := updates.Upgrade(ctx, method, result.Latest, out, errOut) + if err != nil { + return true, err + } + if background { + fmt.Fprintln(out, "The update will finish after this process exits.") + } else { + fmt.Fprintf(out, "Etherscan CLI %s installed. Restart the CLI to use it.\n", result.Latest) + } + return true, nil + case "3": + if err := updates.Skip(result.Latest); err != nil { + return false, nil + } + fmt.Fprintf(out, "Skipped Etherscan CLI %s. You will be notified about the next release.\n\n", result.Latest) + return false, nil + default: + fmt.Fprintln(out) + return false, nil + } +} + +func tuiCommand(state *globalState, info BuildInfo, updates updateManager) *cobra.Command { return &cobra.Command{ Use: "tui", Short: "Launch the interactive explorer", @@ -525,6 +622,10 @@ func tuiCommand(state *globalState, info BuildInfo) *cobra.Command { if !interactiveTTY() { return errors.New("tui requires an interactive terminal") } + exit, err := offerUpdate(cmd.Context(), updates, info.Version, cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr()) + if err != nil || exit { + return err + } return launchTUI(cmd.Context(), state, info) }, } diff --git a/internal/cli/update_test.go b/internal/cli/update_test.go new file mode 100644 index 0000000..42d8d8f --- /dev/null +++ b/internal/cli/update_test.go @@ -0,0 +1,91 @@ +package cli + +import ( + "bytes" + "context" + "io" + "strings" + "testing" + + "github.com/etherscan/etherscan-cli/internal/updater" +) + +type fakeUpdateManager struct { + result updater.Result + checkErr error + method string + skipped string + upgradedMethod string + upgradedVersion string + background bool +} + +func (f *fakeUpdateManager) Check(context.Context, string, bool) (updater.Result, error) { + return f.result, f.checkErr +} + +func (f *fakeUpdateManager) Skip(version string) error { + f.skipped = version + return nil +} + +func (f *fakeUpdateManager) DetectMethod() string { return f.method } + +func (f *fakeUpdateManager) Upgrade(_ context.Context, method, version string, _, _ io.Writer) (bool, error) { + f.upgradedMethod = method + f.upgradedVersion = version + return f.background, nil +} + +func TestOfferUpdateChoices(t *testing.T) { + result := updater.Result{ + Current: "1.1.0", + Latest: "1.2.0", + ReleaseURL: "https://example.test/release", + Checked: true, + UpdateAvailable: true, + } + + t.Run("later", func(t *testing.T) { + manager := &fakeUpdateManager{result: result, method: updater.MethodScript} + var out bytes.Buffer + exit, err := offerUpdate(context.Background(), manager, "1.1.0", strings.NewReader("2\n"), &out, &bytes.Buffer{}) + if err != nil || exit || manager.skipped != "" || manager.upgradedVersion != "" { + t.Fatalf("unexpected result: exit=%v err=%v manager=%+v", exit, err, manager) + } + }) + + t.Run("skip", func(t *testing.T) { + manager := &fakeUpdateManager{result: result, method: updater.MethodScript} + exit, err := offerUpdate(context.Background(), manager, "1.1.0", strings.NewReader("3\n"), &bytes.Buffer{}, &bytes.Buffer{}) + if err != nil || exit || manager.skipped != "1.2.0" { + t.Fatalf("unexpected result: exit=%v err=%v manager=%+v", exit, err, manager) + } + }) + + t.Run("update", func(t *testing.T) { + manager := &fakeUpdateManager{result: result, method: updater.MethodHomebrew} + exit, err := offerUpdate(context.Background(), manager, "1.1.0", strings.NewReader("1\n"), &bytes.Buffer{}, &bytes.Buffer{}) + if err != nil || !exit || manager.upgradedMethod != updater.MethodHomebrew || manager.upgradedVersion != "1.2.0" { + t.Fatalf("unexpected result: exit=%v err=%v manager=%+v", exit, err, manager) + } + }) +} + +func TestUpdateCommandUsesRequestedMethod(t *testing.T) { + manager := &fakeUpdateManager{ + result: updater.Result{Current: "1.1.0", Latest: "1.2.0", Checked: true, UpdateAvailable: true}, + method: updater.MethodScript, + } + root := newRootCommand(BuildInfo{Version: "1.1.0"}, manager) + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs([]string{"update", "--method", "homebrew"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if manager.upgradedMethod != updater.MethodHomebrew || manager.upgradedVersion != "1.2.0" { + t.Fatalf("unexpected update: %+v", manager) + } +} diff --git a/internal/updater/command_unix.go b/internal/updater/command_unix.go new file mode 100644 index 0000000..376232b --- /dev/null +++ b/internal/updater/command_unix.go @@ -0,0 +1,7 @@ +//go:build !windows + +package updater + +import "os/exec" + +func configureBackgroundCommand(cmd *exec.Cmd) {} diff --git a/internal/updater/command_windows.go b/internal/updater/command_windows.go new file mode 100644 index 0000000..0ade982 --- /dev/null +++ b/internal/updater/command_windows.go @@ -0,0 +1,12 @@ +//go:build windows + +package updater + +import ( + "os/exec" + "syscall" +) + +func configureBackgroundCommand(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} +} diff --git a/internal/updater/updater.go b/internal/updater/updater.go new file mode 100644 index 0000000..c761b65 --- /dev/null +++ b/internal/updater/updater.go @@ -0,0 +1,214 @@ +package updater + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/etherscan/etherscan-cli/internal/config" +) + +const latestReleaseURL = "https://api.github.com/repos/etherscan/etherscan-cli/releases/latest" + +type Result struct { + Current string + Latest string + ReleaseURL string + Checked bool + UpdateAvailable bool +} + +type state struct { + LastCheckDate string `json:"last_check_date,omitempty"` + LatestVersion string `json:"latest_version,omitempty"` + ReleaseURL string `json:"release_url,omitempty"` + SkippedVersion string `json:"skipped_version,omitempty"` +} + +type Service struct { + HTTPClient *http.Client + LatestReleaseURL string + StatePath string + Now func() time.Time + Executable func() (string, error) + GOOS string + LookPath func(string) (string, error) + InstallerURL func(string, string) string + runCommand commandRunner +} + +func NewService() *Service { + return &Service{ + HTTPClient: &http.Client{Timeout: 15 * time.Second}, + LatestReleaseURL: latestReleaseURL, + Now: time.Now, + Executable: os.Executable, + GOOS: runtimeGOOS, + LookPath: execLookPath, + InstallerURL: defaultInstallerURL, + runCommand: defaultCommandRunner, + } +} + +func (s *Service) Check(ctx context.Context, current string, force bool) (Result, error) { + currentText, currentVersion, err := canonicalVersion(current) + if err != nil { + return Result{}, err + } + result := Result{Current: currentText} + if !force && os.Getenv("ETHERSCAN_NO_UPDATE_CHECK") != "" { + return result, nil + } + path, err := s.statePath() + if err != nil { + return Result{}, err + } + st := loadState(path) + today := s.now().Format("2006-01-02") + if !force && st.LastCheckDate == today { + return result, nil + } + + // Record the attempt before making the request so a failed network does not + // slow every interactive launch for the rest of the day. + st.LastCheckDate = today + _ = saveState(path, st) + + checkCtx := ctx + if !force { + var cancel context.CancelFunc + checkCtx, cancel = context.WithTimeout(ctx, 2*time.Second) + defer cancel() + } + release, err := s.latestRelease(checkCtx) + if err != nil { + return Result{}, err + } + latestText, latestVersion, err := canonicalVersion(release.TagName) + if err != nil { + return Result{}, fmt.Errorf("GitHub returned an invalid release version %q", release.TagName) + } + st.LatestVersion = latestText + st.ReleaseURL = release.HTMLURL + _ = saveState(path, st) + + result.Checked = true + result.Latest = latestText + result.ReleaseURL = release.HTMLURL + result.UpdateAvailable = compareVersions(latestVersion, currentVersion) > 0 && (force || st.SkippedVersion != latestText) + return result, nil +} + +func (s *Service) Skip(version string) error { + version, _, err := canonicalVersion(version) + if err != nil { + return err + } + path, err := s.statePath() + if err != nil { + return err + } + st := loadState(path) + st.SkippedVersion = version + return saveState(path, st) +} + +type githubRelease struct { + TagName string `json:"tag_name"` + HTMLURL string `json:"html_url"` + Draft bool `json:"draft"` + Prerelease bool `json:"prerelease"` +} + +func (s *Service) latestRelease(ctx context.Context) (githubRelease, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, s.LatestReleaseURL, nil) + if err != nil { + return githubRelease{}, err + } + request.Header.Set("Accept", "application/vnd.github+json") + request.Header.Set("User-Agent", "etherscan-cli-updater") + request.Header.Set("X-GitHub-Api-Version", "2022-11-28") + if token := os.Getenv("ETHERSCAN_GITHUB_TOKEN"); token != "" && isGitHubHost(request.URL.Hostname()) { + request.Header.Set("Authorization", "Bearer "+token) + } + response, err := s.client().Do(request) + if err != nil { + return githubRelease{}, fmt.Errorf("check GitHub releases: %w", err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096)) + return githubRelease{}, fmt.Errorf("check GitHub releases: HTTP %s", response.Status) + } + var release githubRelease + if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&release); err != nil { + return githubRelease{}, fmt.Errorf("decode GitHub release: %w", err) + } + if release.TagName == "" || release.Draft || release.Prerelease { + return githubRelease{}, errors.New("GitHub did not return a stable release") + } + if release.HTMLURL == "" { + release.HTMLURL = "https://github.com/etherscan/etherscan-cli/releases/latest" + } + return release, nil +} + +func (s *Service) client() *http.Client { + if s.HTTPClient != nil { + return s.HTTPClient + } + return http.DefaultClient +} + +func (s *Service) now() time.Time { + if s.Now != nil { + return s.Now() + } + return time.Now() +} + +func (s *Service) statePath() (string, error) { + if s.StatePath != "" { + return s.StatePath, nil + } + configPath, err := config.DefaultPath() + if err != nil { + return "", err + } + return filepath.Join(filepath.Dir(configPath), "update-state.json"), nil +} + +func loadState(path string) state { + f, err := os.Open(path) + if err != nil { + return state{} + } + defer f.Close() + var st state + if json.NewDecoder(io.LimitReader(f, 64<<10)).Decode(&st) != nil { + return state{} + } + return st +} + +func saveState(path string, st state) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + return err + } + err = json.NewEncoder(f).Encode(st) + closeErr := f.Close() + if err != nil { + return err + } + return closeErr +} diff --git a/internal/updater/updater_test.go b/internal/updater/updater_test.go new file mode 100644 index 0000000..62e3633 --- /dev/null +++ b/internal/updater/updater_test.go @@ -0,0 +1,114 @@ +package updater + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "time" +) + +func TestDailyCheckMakesOneRequest(t *testing.T) { + t.Setenv("ETHERSCAN_GITHUB_TOKEN", "secret-test-token") + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if got := r.Header.Get("Authorization"); got != "" { + t.Errorf("token was forwarded to a non-GitHub host: %q", got) + } + json.NewEncoder(w).Encode(githubRelease{TagName: "v1.2.0", HTMLURL: "https://example.test/release"}) + })) + defer server.Close() + + service := NewService() + service.LatestReleaseURL = server.URL + service.StatePath = filepath.Join(t.TempDir(), "state.json") + service.Now = func() time.Time { return time.Date(2026, 7, 22, 8, 0, 0, 0, time.Local) } + + first, err := service.Check(context.Background(), "1.1.0", false) + if err != nil { + t.Fatal(err) + } + if !first.Checked || !first.UpdateAvailable || first.Latest != "1.2.0" { + t.Fatalf("unexpected first result: %+v", first) + } + second, err := service.Check(context.Background(), "1.1.0", false) + if err != nil { + t.Fatal(err) + } + if second.Checked || second.UpdateAvailable { + t.Fatalf("unexpected cached result: %+v", second) + } + if requests != 1 { + t.Fatalf("requests = %d, want 1", requests) + } +} + +func TestFailedDailyCheckDoesNotRetryUntilTomorrow(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + http.Error(w, "unavailable", http.StatusServiceUnavailable) + })) + defer server.Close() + + service := NewService() + service.LatestReleaseURL = server.URL + service.StatePath = filepath.Join(t.TempDir(), "state.json") + service.Now = func() time.Time { return time.Date(2026, 7, 22, 8, 0, 0, 0, time.Local) } + if _, err := service.Check(context.Background(), "1.1.0", false); err == nil { + t.Fatal("expected the first check to fail") + } + if _, err := service.Check(context.Background(), "1.1.0", false); err != nil { + t.Fatalf("second check should use the recorded attempt: %v", err) + } + if requests != 1 { + t.Fatalf("requests = %d, want 1", requests) + } +} + +func TestAutomaticCheckCanBeDisabled(t *testing.T) { + t.Setenv("ETHERSCAN_NO_UPDATE_CHECK", "1") + service := NewService() + service.StatePath = filepath.Join(t.TempDir(), "state.json") + service.LatestReleaseURL = "http://127.0.0.1:1/should-not-be-requested" + result, err := service.Check(context.Background(), "1.1.0", false) + if err != nil { + t.Fatal(err) + } + if result.Checked || result.UpdateAvailable { + t.Fatalf("disabled check returned %+v", result) + } +} + +func TestSkipSuppressesAutomaticCheckButNotManualCheck(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(githubRelease{TagName: "v1.2.0", HTMLURL: "https://example.test/release"}) + })) + defer server.Close() + + day := time.Date(2026, 7, 22, 8, 0, 0, 0, time.Local) + service := NewService() + service.LatestReleaseURL = server.URL + service.StatePath = filepath.Join(t.TempDir(), "state.json") + service.Now = func() time.Time { return day } + if err := service.Skip("1.2.0"); err != nil { + t.Fatal(err) + } + automatic, err := service.Check(context.Background(), "1.1.0", false) + if err != nil { + t.Fatal(err) + } + if automatic.UpdateAvailable { + t.Fatal("skipped version was offered automatically") + } + manual, err := service.Check(context.Background(), "1.1.0", true) + if err != nil { + t.Fatal(err) + } + if !manual.UpdateAvailable { + t.Fatal("manual check should ignore a skipped version") + } +} diff --git a/internal/updater/upgrade.go b/internal/updater/upgrade.go new file mode 100644 index 0000000..2d9c6a2 --- /dev/null +++ b/internal/updater/upgrade.go @@ -0,0 +1,224 @@ +package updater + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" +) + +const ( + MethodHomebrew = "homebrew" + MethodScript = "script" +) + +var runtimeGOOS = runtime.GOOS + +type commandRunner func(context.Context, string, []string, io.Writer, io.Writer, bool) error + +func (s *Service) DetectMethod() string { + executable, err := s.executable() + if err == nil { + if resolved, resolveErr := filepath.EvalSymlinks(executable); resolveErr == nil { + executable = resolved + } + normalized := strings.ToLower(filepath.ToSlash(executable)) + if strings.Contains(normalized, "/cellar/etherscan/") || strings.Contains(normalized, "/linuxbrew/.linuxbrew/cellar/etherscan/") { + return MethodHomebrew + } + } + return MethodScript +} + +func ValidMethod(method string) bool { + return method == MethodHomebrew || method == MethodScript +} + +// Upgrade installs a stable release. The returned background value is true on +// Windows, where the installer waits for this running executable to exit before +// replacing it. +func (s *Service) Upgrade(ctx context.Context, method, version string, stdout, stderr io.Writer) (background bool, err error) { + version, _, err = canonicalVersion(version) + if err != nil { + return false, err + } + if method == "" { + method = s.DetectMethod() + } + if !ValidMethod(method) { + return false, fmt.Errorf("unsupported update method %q (use homebrew or script)", method) + } + if method == MethodHomebrew { + if _, err := s.lookPath()("brew"); err != nil { + return false, errorsWithHint(err, "Homebrew was detected but brew is not on PATH") + } + return false, s.runner()(ctx, "brew", []string{"upgrade", "etherscan/etherscan-cli/etherscan"}, stdout, stderr, false) + } + + executable, err := s.executable() + if err != nil { + return false, fmt.Errorf("locate current executable: %w", err) + } + installDir := filepath.Dir(executable) + if strings.ContainsAny(installDir, "\r\n") { + return false, fmt.Errorf("installation directory contains a line break") + } + goos := s.GOOS + if goos == "" { + goos = runtime.GOOS + } + if goos != "windows" && goos != "darwin" && goos != "linux" { + return false, fmt.Errorf("script updates are not supported on %s", goos) + } + + extension := ".sh" + if goos == "windows" { + extension = ".ps1" + } + installerURL := s.installerURL()(goos, version) + installerPath, err := s.downloadInstaller(ctx, installerURL, extension) + if err != nil { + return false, err + } + + if goos == "windows" { + args := []string{ + "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", + "-File", installerPath, + "-Version", "v" + version, + "-InstallDir", installDir, + "-NoPathUpdate", + "-WaitForProcessId", strconv.Itoa(os.Getpid()), + "-CleanupScript", + } + if err := s.runner()(ctx, "powershell.exe", args, stdout, stderr, true); err != nil { + _ = os.Remove(installerPath) + return false, err + } + return true, nil + } + + defer os.Remove(installerPath) + if err := os.Chmod(installerPath, 0o700); err != nil { + return false, err + } + args := []string{installerPath, "--version", "v" + version, "--install-dir", installDir, "--no-path-update"} + return false, s.runner()(ctx, "sh", args, stdout, stderr, false) +} + +func (s *Service) executable() (string, error) { + if s.Executable != nil { + return s.Executable() + } + return os.Executable() +} + +func (s *Service) runner() commandRunner { + if s.runCommand != nil { + return s.runCommand + } + return defaultCommandRunner +} + +func (s *Service) lookPath() func(string) (string, error) { + if s.LookPath != nil { + return s.LookPath + } + return exec.LookPath +} + +func (s *Service) installerURL() func(string, string) string { + if s.InstallerURL != nil { + return s.InstallerURL + } + return defaultInstallerURL +} + +func defaultInstallerURL(goos, version string) string { + extension := ".sh" + if goos == "windows" { + extension = ".ps1" + } + return "https://raw.githubusercontent.com/etherscan/etherscan-cli/v" + version + "/scripts/install" + extension +} + +var execLookPath = exec.LookPath + +func (s *Service) downloadInstaller(ctx context.Context, installerURL, extension string) (string, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, installerURL, nil) + if err != nil { + return "", err + } + request.Header.Set("User-Agent", "etherscan-cli-updater") + if token := os.Getenv("ETHERSCAN_GITHUB_TOKEN"); token != "" && isGitHubHost(request.URL.Hostname()) { + request.Header.Set("Authorization", "Bearer "+token) + } + response, err := s.client().Do(request) + if err != nil { + return "", fmt.Errorf("download installer: %w", err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return "", fmt.Errorf("download installer: HTTP %s", response.Status) + } + contents, err := io.ReadAll(io.LimitReader(response.Body, (1<<20)+1)) + if err != nil { + return "", fmt.Errorf("download installer: %w", err) + } + if len(contents) == 0 || len(contents) > 1<<20 { + return "", fmt.Errorf("download installer: invalid script size") + } + f, err := os.CreateTemp("", "etherscan-update-*"+extension) + if err != nil { + return "", err + } + path := f.Name() + if _, err := f.Write(contents); err != nil { + f.Close() + os.Remove(path) + return "", err + } + if err := f.Close(); err != nil { + os.Remove(path) + return "", err + } + return path, nil +} + +func defaultCommandRunner(ctx context.Context, name string, args []string, stdout, stderr io.Writer, background bool) error { + var cmd *exec.Cmd + if background { + cmd = exec.Command(name, args...) + configureBackgroundCommand(cmd) + } else { + cmd = exec.CommandContext(ctx, name, args...) + } + cmd.Stdout = stdout + cmd.Stderr = stderr + if !background { + return cmd.Run() + } + if err := cmd.Start(); err != nil { + return err + } + return cmd.Process.Release() +} + +func errorsWithHint(err error, hint string) error { + return fmt.Errorf("%s: %w", hint, err) +} + +func isGitHubHost(host string) bool { + switch strings.ToLower(host) { + case "github.com", "api.github.com", "raw.githubusercontent.com": + return true + default: + return false + } +} diff --git a/internal/updater/upgrade_test.go b/internal/updater/upgrade_test.go new file mode 100644 index 0000000..7a2dbc3 --- /dev/null +++ b/internal/updater/upgrade_test.go @@ -0,0 +1,109 @@ +package updater + +import ( + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" +) + +func TestDetectMethod(t *testing.T) { + service := NewService() + service.Executable = func() (string, error) { + return filepath.Join(string(filepath.Separator), "opt", "homebrew", "Cellar", "etherscan", "1.2.0", "bin", "etherscan"), nil + } + if got := service.DetectMethod(); got != MethodHomebrew { + t.Fatalf("DetectMethod() = %q, want %q", got, MethodHomebrew) + } + service.Executable = func() (string, error) { return filepath.Join(t.TempDir(), "etherscan"), nil } + if got := service.DetectMethod(); got != MethodScript { + t.Fatalf("DetectMethod() = %q, want %q", got, MethodScript) + } +} + +func TestScriptUpgradeDispatch(t *testing.T) { + t.Setenv("ETHERSCAN_GITHUB_TOKEN", "secret-test-token") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "" { + t.Errorf("token was forwarded to a non-GitHub host: %q", got) + } + io.WriteString(w, "#!/bin/sh\nexit 0\n") + })) + defer server.Close() + + var name string + var args []string + var background bool + installDir := filepath.Join(t.TempDir(), "bin") + service := NewService() + service.GOOS = "linux" + service.Executable = func() (string, error) { return filepath.Join(installDir, "etherscan"), nil } + service.InstallerURL = func(string, string) string { return server.URL } + service.runCommand = func(_ context.Context, command string, commandArgs []string, _, _ io.Writer, bg bool) error { + name, args, background = command, append([]string(nil), commandArgs...), bg + return nil + } + if _, err := service.Upgrade(context.Background(), MethodScript, "1.2.0", &bytes.Buffer{}, &bytes.Buffer{}); err != nil { + t.Fatal(err) + } + if name != "sh" || background || len(args) != 6 { + t.Fatalf("unexpected dispatch: name=%q args=%q background=%v", name, args, background) + } + if args[1] != "--version" || args[2] != "v1.2.0" || args[3] != "--install-dir" || args[4] != installDir || args[5] != "--no-path-update" { + t.Fatalf("unexpected installer arguments: %q", args) + } +} + +func TestWindowsScriptUpgradeRunsAfterCurrentProcessExits(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, "Write-Host update\n") + })) + defer server.Close() + + installDir := filepath.Join(t.TempDir(), "bin") + var args []string + var background bool + service := NewService() + service.GOOS = "windows" + service.Executable = func() (string, error) { return filepath.Join(installDir, "etherscan.exe"), nil } + service.InstallerURL = func(string, string) string { return server.URL } + service.runCommand = func(_ context.Context, command string, commandArgs []string, _, _ io.Writer, bg bool) error { + if command != "powershell.exe" { + t.Fatalf("command = %q, want powershell.exe", command) + } + args, background = append([]string(nil), commandArgs...), bg + return nil + } + backgroundResult, err := service.Upgrade(context.Background(), MethodScript, "1.2.0", &bytes.Buffer{}, &bytes.Buffer{}) + if err != nil { + t.Fatal(err) + } + joined := strings.Join(args, " ") + if !background || !backgroundResult || !strings.Contains(joined, "-WaitForProcessId") || !strings.Contains(joined, "-CleanupScript") || !strings.Contains(joined, installDir) { + t.Fatalf("unexpected Windows dispatch: args=%q background=%v result=%v", args, background, backgroundResult) + } +} + +func TestHomebrewUpgradeDispatch(t *testing.T) { + var name string + var args []string + service := NewService() + service.LookPath = func(file string) (string, error) { return "/opt/homebrew/bin/" + file, nil } + service.runCommand = func(_ context.Context, command string, commandArgs []string, _, _ io.Writer, background bool) error { + name, args = command, append([]string(nil), commandArgs...) + if background { + t.Fatal("Homebrew update must run in the foreground") + } + return nil + } + if _, err := service.Upgrade(context.Background(), MethodHomebrew, "1.2.0", &bytes.Buffer{}, &bytes.Buffer{}); err != nil { + t.Fatal(err) + } + if name != "brew" || strings.Join(args, " ") != "upgrade etherscan/etherscan-cli/etherscan" { + t.Fatalf("unexpected command: %s %q", name, args) + } +} diff --git a/internal/updater/version.go b/internal/updater/version.go new file mode 100644 index 0000000..2463459 --- /dev/null +++ b/internal/updater/version.go @@ -0,0 +1,116 @@ +package updater + +import ( + "fmt" + "strconv" + "strings" +) + +type semanticVersion struct { + major, minor, patch uint64 + prerelease []string +} + +func parseVersion(value string) (semanticVersion, error) { + value = strings.TrimSpace(strings.TrimPrefix(value, "v")) + if value == "" || value == "dev" { + return semanticVersion{}, fmt.Errorf("development builds cannot check for updates") + } + if i := strings.IndexByte(value, '+'); i >= 0 { + value = value[:i] + } + var prerelease []string + if i := strings.IndexByte(value, '-'); i >= 0 { + prerelease = strings.Split(value[i+1:], ".") + value = value[:i] + if len(prerelease) == 0 { + return semanticVersion{}, fmt.Errorf("invalid version") + } + } + parts := strings.Split(value, ".") + if len(parts) != 3 { + return semanticVersion{}, fmt.Errorf("invalid version") + } + parsed := semanticVersion{prerelease: prerelease} + numbers := []*uint64{&parsed.major, &parsed.minor, &parsed.patch} + for i, part := range parts { + if part == "" || (len(part) > 1 && part[0] == '0') { + return semanticVersion{}, fmt.Errorf("invalid version") + } + n, err := strconv.ParseUint(part, 10, 64) + if err != nil { + return semanticVersion{}, fmt.Errorf("invalid version") + } + *numbers[i] = n + } + for _, identifier := range prerelease { + if identifier == "" { + return semanticVersion{}, fmt.Errorf("invalid version") + } + for _, r := range identifier { + if !(r == '-' || r >= '0' && r <= '9' || r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z') { + return semanticVersion{}, fmt.Errorf("invalid version") + } + } + } + return parsed, nil +} + +func compareVersions(left, right semanticVersion) int { + for _, pair := range [][2]uint64{{left.major, right.major}, {left.minor, right.minor}, {left.patch, right.patch}} { + if pair[0] < pair[1] { + return -1 + } + if pair[0] > pair[1] { + return 1 + } + } + if len(left.prerelease) == 0 && len(right.prerelease) == 0 { + return 0 + } + if len(left.prerelease) == 0 { + return 1 + } + if len(right.prerelease) == 0 { + return -1 + } + for i := 0; i < len(left.prerelease) && i < len(right.prerelease); i++ { + l, r := left.prerelease[i], right.prerelease[i] + if l == r { + continue + } + ln, lerr := strconv.ParseUint(l, 10, 64) + rn, rerr := strconv.ParseUint(r, 10, 64) + switch { + case lerr == nil && rerr == nil: + if ln < rn { + return -1 + } + return 1 + case lerr == nil: + return -1 + case rerr == nil: + return 1 + case l < r: + return -1 + default: + return 1 + } + } + if len(left.prerelease) < len(right.prerelease) { + return -1 + } + if len(left.prerelease) > len(right.prerelease) { + return 1 + } + return 0 +} + +func canonicalVersion(value string) (string, semanticVersion, error) { + trimmed := strings.TrimSpace(strings.TrimPrefix(value, "v")) + parsed, err := parseVersion(trimmed) + if err != nil { + return "", semanticVersion{}, err + } + return trimmed, parsed, nil +} diff --git a/internal/updater/version_test.go b/internal/updater/version_test.go new file mode 100644 index 0000000..f210260 --- /dev/null +++ b/internal/updater/version_test.go @@ -0,0 +1,42 @@ +package updater + +import "testing" + +func TestCompareVersions(t *testing.T) { + tests := []struct { + left, right string + want int + }{ + {"1.2.3", "1.2.3", 0}, + {"1.2.4", "1.2.3", 1}, + {"1.3.0", "1.2.9", 1}, + {"2.0.0", "1.99.99", 1}, + {"1.1.0", "1.1.0-rc.5", 1}, + {"1.1.0-rc.5", "1.1.0-rc.4", 1}, + {"1.1.0-rc.1", "1.1.0", -1}, + {"1.1.0-alpha.2", "1.1.0-alpha.10", -1}, + } + for _, tt := range tests { + t.Run(tt.left+"_"+tt.right, func(t *testing.T) { + left, err := parseVersion(tt.left) + if err != nil { + t.Fatal(err) + } + right, err := parseVersion(tt.right) + if err != nil { + t.Fatal(err) + } + if got := compareVersions(left, right); got != tt.want { + t.Fatalf("compareVersions() = %d, want %d", got, tt.want) + } + }) + } +} + +func TestParseVersionRejectsInvalidValues(t *testing.T) { + for _, value := range []string{"", "dev", "1.2", "01.2.3", "1.2.3-", "1.2.x"} { + if _, err := parseVersion(value); err == nil { + t.Fatalf("parseVersion(%q) accepted an invalid value", value) + } + } +} diff --git a/scripts/install.ps1 b/scripts/install.ps1 index ba7ad0e..3c8fec2 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -2,7 +2,9 @@ param( [string]$Version = $env:ETHERSCAN_VERSION, [string]$InstallDir = $env:ETHERSCAN_INSTALL_DIR, - [switch]$NoPathUpdate + [switch]$NoPathUpdate, + [int]$WaitForProcessId = 0, + [switch]$CleanupScript ) $ErrorActionPreference = "Stop" @@ -138,6 +140,9 @@ if ($InstallDir.Contains(';')) { if ($InstallDir.IndexOfAny([char[]]"`r`n") -ge 0) { throw "The installation directory cannot contain a line break." } +if ($WaitForProcessId -gt 0) { + Wait-Process -Id $WaitForProcessId -ErrorAction SilentlyContinue +} $resolved = Resolve-EtherscanVersion -RequestedVersion $Version $architecture = Get-EtherscanArchitecture @@ -233,4 +238,7 @@ try { } finally { Remove-Item -LiteralPath $tempDirectory -Recurse -Force -ErrorAction SilentlyContinue + if ($CleanupScript -and -not [string]::IsNullOrWhiteSpace($PSCommandPath)) { + Remove-Item -LiteralPath $PSCommandPath -Force -ErrorAction SilentlyContinue + } } From 604c7829a9c819c72e1708f40c748d19abd0177b Mon Sep 17 00:00:00 2001 From: Samuel Date: Wed, 22 Jul 2026 12:36:23 +0800 Subject: [PATCH 7/7] defer TUI API key setup until endpoint use --- internal/cli/root.go | 90 ++++++++++----------- internal/cli/runtime_test.go | 12 +++ internal/cli/tui_test.go | 2 +- internal/client/client.go | 8 ++ internal/client/client_test.go | 21 +++++ internal/tui/setup.go | 133 ------------------------------- internal/tui/setup_test.go | 140 --------------------------------- internal/tui/tui.go | 110 +++++++++++++++++++++++++- internal/tui/tui_test.go | 131 +++++++++++++++++++++++++++++- 9 files changed, 321 insertions(+), 326 deletions(-) delete mode 100644 internal/tui/setup.go delete mode 100644 internal/tui/setup_test.go diff --git a/internal/cli/root.go b/internal/cli/root.go index d4643e6..5b9a92b 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -231,6 +231,13 @@ func runtime(state *globalState) (resolvedRuntime, error) { if key == "" { return resolvedRuntime{}, errNoAPIKey } + return buildRuntime(state, cfg, key) +} + +// buildRuntime constructs the shared runtime from already-loaded configuration. +// Most CLI commands call runtime(), which rejects an empty key first. The TUI is +// the sole caller allowed to pass an empty key so users can browse before setup. +func buildRuntime(state *globalState, cfg config.File, key string) (resolvedRuntime, error) { chainInput := firstNonEmpty(state.chain, os.Getenv("ETHERSCAN_CHAIN"), cfg.DefaultChain, "ethereum") chain, err := chains.Resolve(chainInput) if err != nil { @@ -639,26 +646,22 @@ func interactiveTTY() bool { } // launchTUI resolves the runtime once and hands the interactive explorer the -// endpoint list plus an executor that reuses the existing client/call path. With -// no key resolved it runs the first-launch setup screen first; a key saved there -// lands in the config file, so the runtime resolution below picks it up. +// endpoint list plus an executor that reuses the existing client/call path. An +// empty key is allowed here so first-time users can explore locally; the TUI asks +// for and validates a key only when an API-backed endpoint is submitted. func launchTUI(ctx context.Context, state *globalState, info BuildInfo) error { cfg, _, err := config.Load() if err != nil { return err } - if resolveKey(state, cfg) == "" { - if err := runSetup(ctx, state); err != nil { - return err - } - cfg, _, _ = config.Load() - } - rt, err := runtime(state) + key := resolveKey(state, cfg) + rt, err := buildRuntime(state, cfg, key) if err != nil { return err } + baseURL := firstNonEmpty(state.baseURL, os.Getenv("ETHERSCAN_BASE_URL"), cfg.BaseURL, client.DefaultBaseURL) keyLabel := "none" - if key := resolveKey(state, cfg); key != "" { + if key != "" { keyLabel = maskKey(key) } eps, index := tuiEndpoints() @@ -671,6 +674,30 @@ func launchTUI(ctx context.Context, state *globalState, info BuildInfo) error { } return chain.DisplayName, chain.ID, nil } + saveKey := func(ctx context.Context, key string) (string, error) { + key = strings.TrimSpace(key) + if key == "" { + return "", errors.New("empty API key") + } + if err := checkKeyShape(key); err != nil { + return "", err + } + if err := validateKeyLive(ctx, state, key, rt.chain.ID, baseURL); err != nil { + return "", err + } + latest, _, err := config.Load() + if err != nil { + return "", err + } + latest.BaseURL = baseURL + latest.DefaultChain = rt.chain.Name + config.StoreAPIKey(key, &latest) + if _, err := config.Save(latest); err != nil { + return "", err + } + rt.client = rt.client.WithAPIKey(key) + return maskKey(key), nil + } return tui.Run(ctx, tui.Config{ Endpoints: eps, Exec: tuiExec(&rt, index), @@ -678,6 +705,8 @@ func launchTUI(ctx context.Context, state *globalState, info BuildInfo) error { ChainName: rt.chain.DisplayName, ChainID: rt.chain.ID, KeyLabel: keyLabel, + HasAPIKey: key != "", + SaveAPIKey: saveKey, Chains: tuiChains(), SwitchChain: switchChain, }) @@ -696,45 +725,6 @@ func tuiChains() []tui.ChainInfo { return out } -// runSetup runs the TUI first-launch key screen. Its Save closure applies the -// same shape check, live validation, and persistence as `etherscan login`, so a -// key accepted here behaves identically to one saved via login. -func runSetup(ctx context.Context, state *globalState) error { - save := func(ctx context.Context, key string) error { - key = strings.TrimSpace(key) - if key == "" { - return errors.New("empty API key") - } - if err := checkKeyShape(key); err != nil { - return err - } - cfg, _, err := config.Load() - if err != nil { - return err - } - chain, err := chains.Resolve(firstNonEmpty(state.chain, cfg.DefaultChain, "ethereum")) - if err != nil { - return err - } - baseURL := firstNonEmpty(state.baseURL, cfg.BaseURL, client.DefaultBaseURL) - if err := validateKeyLive(ctx, state, key, chain.ID, baseURL); err != nil { - return err - } - cfg.BaseURL = baseURL - cfg.DefaultChain = chain.Name - config.StoreAPIKey(key, &cfg) - _, err = config.Save(cfg) - return err - } - if err := tui.RunSetup(ctx, tui.SetupConfig{Save: save}); err != nil { - if errors.Is(err, tui.ErrSetupAborted) { - return errNoAPIKey - } - return err - } - return nil -} - // tuiValidate builds the pre-call guard shared by the TUI form (inline errors on // submit) and the executor: the mainnet-only check and validateParams — the SAME // guards the normal CLI path (endpointCommand RunE) applies, so the TUI cannot diff --git a/internal/cli/runtime_test.go b/internal/cli/runtime_test.go index 9a921d1..a7d109c 100644 --- a/internal/cli/runtime_test.go +++ b/internal/cli/runtime_test.go @@ -39,6 +39,18 @@ func TestRuntimeRequiresKey(t *testing.T) { } } +func TestBuildRuntimeAllowsEmptyKeyForTUI(t *testing.T) { + t.Setenv("ETHERSCAN_API_KEY", "") + state := &globalState{timeout: 5 * time.Second, rate: 3} + rt, err := buildRuntime(state, config.File{}, "") + if err != nil { + t.Fatalf("keyless TUI runtime failed: %v", err) + } + if rt.client == nil || rt.chain.ID != "1" { + t.Fatalf("incomplete keyless runtime: client=%v chain=%+v", rt.client, rt.chain) + } +} + func TestRebindRuntimeChainPreservesSession(t *testing.T) { ethereum, err := chains.Resolve("ethereum") if err != nil { diff --git a/internal/cli/tui_test.go b/internal/cli/tui_test.go index 926e2fb..8f469e2 100644 --- a/internal/cli/tui_test.go +++ b/internal/cli/tui_test.go @@ -166,7 +166,7 @@ func TestTuiExecChainList(t *testing.T) { defer srv.Close() rt := resolvedRuntime{ - client: client.New(client.Options{BaseURL: srv.URL + "/v2/api", APIKey: "k", ChainID: "1", RateLimit: 1000}), + client: client.New(client.Options{BaseURL: srv.URL + "/v2/api", ChainID: "1", RateLimit: 1000}), chain: chains.Chain{ID: "1", Name: "ethereum"}, } // Empty index on purpose: chainlist must not need a spec entry. diff --git a/internal/client/client.go b/internal/client/client.go index 78a883e..6e3b67f 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -112,6 +112,14 @@ func (c *Client) ForChain(chainID string) *Client { return &clone } +// WithAPIKey returns a client using apiKey while preserving the existing +// transport, rate limiter, chain, and diagnostic settings. +func (c *Client) WithAPIKey(apiKey string) *Client { + clone := *c + clone.apiKey = apiKey + return &clone +} + func (c *Client) Get(ctx context.Context, module, action string, params map[string]string, retryable bool) (Result, error) { values := url.Values{} values.Set("module", module) diff --git a/internal/client/client_test.go b/internal/client/client_test.go index 5fb566b..b15dce6 100644 --- a/internal/client/client_test.go +++ b/internal/client/client_test.go @@ -154,6 +154,27 @@ func TestClientForChainPreservesSession(t *testing.T) { } } +func TestWithAPIKeyClonesWithoutMutatingOriginal(t *testing.T) { + var queries []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + queries = append(queries, r.URL.RawQuery) + fmt.Fprint(w, `{"status":"1","message":"OK","result":"1"}`) + })) + defer srv.Close() + + original := New(Options{BaseURL: srv.URL, ChainID: "1", RateLimit: 1000}) + withKey := original.WithAPIKey("TESTKEY") + if _, err := original.Get(context.Background(), "account", "balance", nil, false); err != nil { + t.Fatal(err) + } + if _, err := withKey.Get(context.Background(), "account", "balance", nil, false); err != nil { + t.Fatal(err) + } + if len(queries) != 2 || strings.Contains(queries[0], "apikey=") || !strings.Contains(queries[1], "apikey=TESTKEY") { + t.Fatalf("unexpected original/clone queries: %v", queries) + } +} + func TestChainList(t *testing.T) { var gotPath, gotQuery string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/tui/setup.go b/internal/tui/setup.go deleted file mode 100644 index 8ebf489..0000000 --- a/internal/tui/setup.go +++ /dev/null @@ -1,133 +0,0 @@ -package tui - -import ( - "context" - "errors" - "strings" - - "github.com/charmbracelet/bubbles/spinner" - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" -) - -// ErrSetupAborted is returned by RunSetup when the user quits without saving a key. -var ErrSetupAborted = errors.New("setup aborted") - -// SetupConfig wires the first-launch key screen to the caller's validate+persist -// logic. Save receives the entered key and returns nil once it is stored. -type SetupConfig struct { - Save func(ctx context.Context, key string) error -} - -// RunSetup shows the first-launch API-key screen and blocks until a key is saved -// (nil) or the user quits without one (ErrSetupAborted). -func RunSetup(ctx context.Context, cfg SetupConfig) error { - m := newSetupModel(ctx, cfg) - p := tea.NewProgram(&m, tea.WithAltScreen()) - out, err := p.Run() - if err != nil { - return err - } - if sm, ok := out.(*setupModel); ok && sm.saved { - return nil - } - return ErrSetupAborted -} - -type setupSaveMsg struct{ err error } - -type setupModel struct { - ctx context.Context - cfg SetupConfig - input textinput.Model - spin spinner.Model - saving bool - saved bool - errMsg string -} - -func newSetupModel(ctx context.Context, cfg SetupConfig) setupModel { - ti := textinput.New() - ti.Placeholder = "paste your API key" - ti.Prompt = "› " - ti.Focus() - sp := spinner.New() - sp.Spinner = spinner.Dot - sp.Style = lipgloss.NewStyle().Foreground(accent) - return setupModel{ctx: ctx, cfg: cfg, input: ti, spin: sp} -} - -func (m setupModel) Init() tea.Cmd { return textinput.Blink } - -func (m *setupModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - return m, nil - - case spinner.TickMsg: - if m.saving { - var cmd tea.Cmd - m.spin, cmd = m.spin.Update(msg) - return m, cmd - } - return m, nil - - case setupSaveMsg: - m.saving = false - if msg.err != nil { - m.errMsg = msg.err.Error() - m.input.Focus() - return m, textinput.Blink - } - m.saved = true - return m, tea.Quit - - case tea.KeyMsg: - if m.saving { - if msg.String() == "ctrl+c" { - return m, tea.Quit - } - return m, nil - } - switch msg.String() { - case "ctrl+c", "esc": - return m, tea.Quit - case "enter": - key := strings.TrimSpace(m.input.Value()) - if key == "" { - m.errMsg = "API key is required" - return m, nil - } - m.errMsg = "" - m.saving = true - m.input.Blur() - save, ctx := m.cfg.Save, m.ctx - return m, tea.Batch(m.spin.Tick, func() tea.Msg { - return setupSaveMsg{err: save(ctx, key)} - }) - } - } - // Everything else (typed characters, textinput's paste msg) goes to the input. - var cmd tea.Cmd - m.input, cmd = m.input.Update(msg) - return m, cmd -} - -func (m setupModel) View() string { - var b strings.Builder - b.WriteString(titleSt.Render("◆ Etherscan") + "\n\n") - b.WriteString(headSt.Render("Set up your API key") + "\n") - b.WriteString(subSt.Render("An API key is required. Get a free one at https://etherscan.io/apis") + "\n\n") - if m.saving { - b.WriteString(m.spin.View() + " validating key…" + "\n") - } else { - b.WriteString(m.input.View() + "\n") - if m.errMsg != "" { - b.WriteString("\n" + errSt.Render(m.errMsg) + "\n") - } - } - b.WriteString("\n" + footerSt.Render("enter save · esc quit") + "\n") - b.WriteString(subSt.Render("Prefer the shell? Run 'etherscan login' or set ETHERSCAN_API_KEY.")) - return b.String() -} diff --git a/internal/tui/setup_test.go b/internal/tui/setup_test.go deleted file mode 100644 index ab5a9fc..0000000 --- a/internal/tui/setup_test.go +++ /dev/null @@ -1,140 +0,0 @@ -package tui - -import ( - "context" - "strings" - "testing" - - tea "github.com/charmbracelet/bubbletea" -) - -func keyMsg(s string) tea.KeyMsg { - switch s { - case "enter": - return tea.KeyMsg{Type: tea.KeyEnter} - case "esc": - return tea.KeyMsg{Type: tea.KeyEsc} - } - return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} -} - -func TestSetupEmptySubmitShowsError(t *testing.T) { - m := newSetupModel(context.Background(), SetupConfig{Save: func(ctx context.Context, key string) error { - t.Fatal("Save must not be called on empty submit") - return nil - }}) - m.Update(keyMsg("enter")) - if m.errMsg == "" { - t.Fatal("expected an error message on empty submit") - } - if m.saving || m.saved { - t.Fatalf("unexpected state: saving=%v saved=%v", m.saving, m.saved) - } -} - -func TestSetupSaveCalledWithTrimmedKey(t *testing.T) { - var got string - m := newSetupModel(context.Background(), SetupConfig{Save: func(ctx context.Context, key string) error { - got = key - return nil - }}) - m.input.SetValue(" MYKEY ") - _, cmd := m.Update(keyMsg("enter")) - if !m.saving { - t.Fatal("expected saving state after submit") - } - if cmd == nil { - t.Fatal("expected a save command") - } - // Run the batched commands the way Bubble Tea would; one yields setupSaveMsg. - runSetupCmd(t, &m, cmd) - if got != "MYKEY" { - t.Fatalf("Save called with %q, want trimmed MYKEY", got) - } - if !m.saved || m.saving { - t.Fatalf("expected saved state, got saving=%v saved=%v", m.saving, m.saved) - } -} - -func TestSetupSaveErrorReturnsToInput(t *testing.T) { - m := newSetupModel(context.Background(), SetupConfig{}) - m.saving = true - m.Update(setupSaveMsg{err: errString("API key validation failed")}) - if m.saving || m.saved { - t.Fatalf("unexpected state: saving=%v saved=%v", m.saving, m.saved) - } - if !strings.Contains(m.errMsg, "validation failed") { - t.Fatalf("save error not surfaced: %q", m.errMsg) - } - if !strings.Contains(m.View(), "validation failed") { - t.Fatal("error message missing from view") - } -} - -func TestSetupSaveSuccessQuits(t *testing.T) { - m := newSetupModel(context.Background(), SetupConfig{}) - m.saving = true - _, cmd := m.Update(setupSaveMsg{}) - if !m.saved { - t.Fatal("expected saved=true") - } - if cmd == nil { - t.Fatal("expected quit command") - } - if _, ok := cmd().(tea.QuitMsg); !ok { - t.Fatal("expected tea.Quit after successful save") - } -} - -func TestSetupEscQuitsUnsaved(t *testing.T) { - m := newSetupModel(context.Background(), SetupConfig{}) - _, cmd := m.Update(keyMsg("esc")) - if cmd == nil { - t.Fatal("expected quit command") - } - if _, ok := cmd().(tea.QuitMsg); !ok { - t.Fatal("expected tea.Quit on esc") - } - if m.saved { - t.Fatal("esc must not mark the model saved (RunSetup maps this to ErrSetupAborted)") - } -} - -func TestSetupTypingReachesInput(t *testing.T) { - m := newSetupModel(context.Background(), SetupConfig{}) - m.Update(keyMsg("A")) - m.Update(keyMsg("B")) - if m.input.Value() != "AB" { - t.Fatalf("typed runes not in input: %q", m.input.Value()) - } -} - -func TestSetupViewDoesNotPanic(t *testing.T) { - m := newSetupModel(context.Background(), SetupConfig{}) - _ = m.View() - m.saving = true - _ = m.View() - m.saving = false - m.errMsg = "boom" - if !strings.Contains(m.View(), "boom") { - t.Fatal("view missing error message") - } -} - -// runSetupCmd executes a command tree (Batch or single) synchronously and feeds -// the resulting setupSaveMsg back into the model. Spinner ticks are dropped: -// feeding them back would schedule ticks forever. -func runSetupCmd(t *testing.T, m *setupModel, cmd tea.Cmd) { - t.Helper() - if cmd == nil { - return - } - switch v := cmd().(type) { - case tea.BatchMsg: - for _, c := range v { - runSetupCmd(t, m, c) - } - case setupSaveMsg: - m.Update(v) - } -} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 63d0c1d..9c76f06 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -73,6 +73,11 @@ type Config struct { ChainName string ChainID string KeyLabel string // masked key, or "none" + HasAPIKey bool + // SaveAPIKey validates and persists a key, returning its masked display label. + // When provided, API-backed endpoints open an in-TUI setup prompt if HasAPIKey + // is false. Bare endpoints remain available without credentials. + SaveAPIKey func(ctx context.Context, key string) (label string, err error) // Chains is the list offered by the in-TUI chain switcher; SwitchChain applies a // selection (rebinding the client) and returns the resolved display name/id. Both are // optional — a nil SwitchChain disables the switcher entirely. @@ -108,6 +113,7 @@ const ( stateFetching stateResult stateChainPicker + stateAPIKey ) type focusCol int @@ -122,6 +128,11 @@ type resultMsg struct { err error } +type apiKeySavedMsg struct { + label string + err error +} + var ( accent = lipgloss.Color("#5A8DEE") dim = lipgloss.Color("#8A8A8A") @@ -173,6 +184,12 @@ type model struct { chainErr string chainReturn viewState + // just-in-time API-key setup + keyInput textinput.Model + keySaving bool + keyErr string + keyReturn viewState + width, height int ready bool } @@ -231,7 +248,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case spinner.TickMsg: - if m.state == stateFetching { + if m.state == stateFetching || (m.state == stateAPIKey && m.keySaving) { var cmd tea.Cmd m.spin, cmd = m.spin.Update(msg) return m, cmd @@ -242,6 +259,18 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.setResult(msg.raw, msg.err) return m, nil + case apiKeySavedMsg: + m.keySaving = false + if msg.err != nil { + m.keyErr = msg.err.Error() + m.keyInput.Focus() + return m, textinput.Blink + } + m.cfg.HasAPIKey = true + m.cfg.KeyLabel = msg.label + m.keyInput.SetValue("") + return m.startFetch() + case tea.KeyMsg: return m.handleKey(msg) } @@ -259,6 +288,12 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd m.vp, cmd = m.vp.Update(msg) return m, cmd + case stateAPIKey: + if !m.keySaving { + var cmd tea.Cmd + m.keyInput, cmd = m.keyInput.Update(msg) + return m, cmd + } } return m, nil } @@ -278,6 +313,8 @@ func (m *model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.keyResult(msg) case stateChainPicker: return m.keyChainPicker(msg) + case stateAPIKey: + return m.keyAPIKey(msg) } return m, nil } @@ -424,6 +461,9 @@ func (m *model) submitForm() (tea.Model, tea.Cmd) { } func (m *model) startFetch() (tea.Model, tea.Cmd) { + if !m.current.Bare && !m.cfg.HasAPIKey && m.cfg.SaveAPIKey != nil { + return m.openAPIKey() + } m.state = stateFetching m.resultTitle = m.current.Module + "/" + m.current.Action if m.current.Bare { @@ -432,6 +472,56 @@ func (m *model) startFetch() (tea.Model, tea.Cmd) { return m, tea.Batch(m.spin.Tick, m.fetchCmd()) } +func (m *model) openAPIKey() (tea.Model, tea.Cmd) { + m.keyReturn = m.state + m.keyErr = "" + m.keySaving = false + ti := textinput.New() + ti.Placeholder = "paste your API key" + ti.Prompt = "› " + ti.Focus() + m.keyInput = ti + m.state = stateAPIKey + return m, textinput.Blink +} + +func (m *model) keyAPIKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.keySaving { + if msg.String() == "ctrl+c" { + m.keyInput.SetValue("") + return m, tea.Quit + } + return m, nil + } + switch msg.String() { + case "ctrl+c": + m.keyInput.SetValue("") + return m, tea.Quit + case "esc": + m.keyInput.SetValue("") + m.keyErr = "" + m.state = m.keyReturn + return m, nil + case "enter": + key := strings.TrimSpace(m.keyInput.Value()) + if key == "" { + m.keyErr = "API key is required" + return m, nil + } + m.keyErr = "" + m.keySaving = true + m.keyInput.Blur() + save, ctx := m.cfg.SaveAPIKey, m.ctx + return m, tea.Batch(m.spin.Tick, func() tea.Msg { + label, err := save(ctx, key) + return apiKeySavedMsg{label: label, err: err} + }) + } + var cmd tea.Cmd + m.keyInput, cmd = m.keyInput.Update(msg) + return m, cmd +} + func (m *model) fetchCmd() tea.Cmd { ep := m.current params := map[string]string{} @@ -607,11 +697,29 @@ func (m model) View() string { return m.viewResult() case stateChainPicker: return m.viewChainPicker() + case stateAPIKey: + return m.viewAPIKey() default: return m.viewBrowse() } } +func (m model) viewAPIKey() string { + var b strings.Builder + b.WriteString(headSt.Render("Connect your API key") + "\n") + b.WriteString(subSt.Render("An API key is needed to run this endpoint. You can keep exploring without one.") + "\n") + b.WriteString(subSt.Render("Get a free key at https://etherscan.io/apis") + "\n\n") + if m.keySaving { + b.WriteString(m.spin.View() + " validating key…\n") + } else { + b.WriteString(m.keyInput.View() + "\n") + if m.keyErr != "" { + b.WriteString("\n" + errSt.Render(m.keyErr) + "\n") + } + } + return join(m.header(), "", b.String(), m.footer("enter save & continue · esc keep exploring")) +} + func (m model) viewChainPicker() string { list := m.filteredChains() var b strings.Builder diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 5cefe6c..fa83141 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -64,6 +64,133 @@ func TestBrowseNoParamEndpointFetches(t *testing.T) { } } +func TestAPIBackedEndpointPromptsForKeyAndResumes(t *testing.T) { + called := false + cfg := Config{ + Endpoints: []Endpoint{{Module: "stats", Action: "ethprice", Title: "ethprice"}}, + Exec: func(ctx context.Context, module, action string, params map[string]string) (json.RawMessage, error) { + called = true + return json.RawMessage(`{"ethusd":"1000"}`), nil + }, + ChainName: "ethereum", + ChainID: "1", + KeyLabel: "none", + SaveAPIKey: func(ctx context.Context, key string) (string, error) { + if key != "TESTKEY" { + t.Fatalf("unexpected key: %q", key) + } + return "TEST…TKEY", nil + }, + } + m := newModel(context.Background(), cfg) + m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + m.focus = focusEndpoints + + if _, cmd := m.openSelected(); cmd == nil { + t.Fatal("expected key-input command") + } + if m.state != stateAPIKey || called { + t.Fatalf("expected key setup without an API call, state=%v called=%v", m.state, called) + } + if !strings.Contains(m.View(), "keep exploring") { + t.Fatalf("setup view does not explain cancellation:\n%s", m.View()) + } + + m.keyInput.SetValue("TESTKEY") + if _, cmd := m.keyAPIKey(tea.KeyMsg{Type: tea.KeyEnter}); cmd == nil || !m.keySaving { + t.Fatal("enter should begin asynchronous key validation") + } + label, err := m.cfg.SaveAPIKey(context.Background(), "TESTKEY") + if err != nil { + t.Fatal(err) + } + _, cmd := m.Update(apiKeySavedMsg{label: label}) + if !m.cfg.HasAPIKey || m.cfg.KeyLabel != "TEST…TKEY" { + t.Fatalf("saved key state not reflected: has=%v label=%q", m.cfg.HasAPIKey, m.cfg.KeyLabel) + } + if m.state != stateFetching || cmd == nil { + t.Fatalf("pending request did not resume: state=%v cmd=%v", m.state, cmd) + } + m.fetchCmd()() + if !called { + t.Fatal("resumed request did not call executor") + } +} + +func TestAPIKeyPromptCancelReturnsToExistingForm(t *testing.T) { + cfg := Config{ + Endpoints: []Endpoint{{ + Module: "account", Action: "balance", Title: "balance", + Params: []Param{{Name: "address", Label: "address", Required: true}}, + }}, + Exec: func(context.Context, string, string, map[string]string) (json.RawMessage, error) { return nil, nil }, + ChainName: "ethereum", + ChainID: "1", + KeyLabel: "none", + SaveAPIKey: func(context.Context, string) (string, error) { return "", nil }, + } + m := newModel(context.Background(), cfg) + m.focus = focusEndpoints + m.openSelected() + m.inputs[0].SetValue("0x80f3950a4d371c43360f292a4170624abd9eed03") + m.submitForm() + if m.state != stateAPIKey || m.keyReturn != stateForm { + t.Fatalf("expected key prompt returning to form, state=%v return=%v", m.state, m.keyReturn) + } + m.keyInput.SetValue("sensitive") + m.keyAPIKey(tea.KeyMsg{Type: tea.KeyEsc}) + if m.state != stateForm { + t.Fatalf("cancel should return to form, got %v", m.state) + } + if got := m.inputs[0].Value(); !strings.HasPrefix(got, "0x80f3") { + t.Fatalf("form input was not preserved: %q", got) + } + if m.keyInput.Value() != "" { + t.Fatal("cancel should clear key input") + } +} + +func TestBareEndpointRunsWithoutAPIKey(t *testing.T) { + called := false + cfg := Config{ + Endpoints: []Endpoint{{Module: "getapilimit", Action: "chainlist", Title: "chainlist", Bare: true}}, + Exec: func(context.Context, string, string, map[string]string) (json.RawMessage, error) { + called = true + return json.RawMessage(`[]`), nil + }, + ChainName: "ethereum", + ChainID: "1", + KeyLabel: "none", + SaveAPIKey: func(context.Context, string) (string, error) { return "", nil }, + } + m := newModel(context.Background(), cfg) + m.focus = focusEndpoints + if _, cmd := m.openSelected(); cmd == nil || m.state != stateFetching { + t.Fatalf("bare endpoint should fetch directly, state=%v cmd=%v", m.state, cmd) + } + m.fetchCmd()() + if !called { + t.Fatal("bare endpoint did not call executor") + } +} + +func TestAPIKeyValidationErrorStaysInPrompt(t *testing.T) { + cfg := Config{ + Endpoints: []Endpoint{{Module: "stats", Action: "ethprice", Title: "ethprice"}}, + ChainName: "ethereum", + ChainID: "1", + KeyLabel: "none", + SaveAPIKey: func(context.Context, string) (string, error) { return "", errString("invalid API key") }, + } + m := newModel(context.Background(), cfg) + m.focus = focusEndpoints + m.openSelected() + m.Update(apiKeySavedMsg{err: errString("invalid API key")}) + if m.state != stateAPIKey || m.keySaving || !strings.Contains(m.keyErr, "invalid API key") { + t.Fatalf("validation error not retained in setup: state=%v saving=%v err=%q", m.state, m.keySaving, m.keyErr) + } +} + // TestGroupLabelDrivesSidebar: endpoints sharing a Group land in one sidebar // group under the group label, while the exec call and result header keep the // wire module. @@ -480,13 +607,15 @@ func TestViewsDoNotPanic(t *testing.T) { m := testModel(func(ctx context.Context, module, action string, params map[string]string) (json.RawMessage, error) { return json.RawMessage(`[]`), nil }) - for _, st := range []viewState{stateBrowse, stateForm, stateFetching, stateResult} { + for _, st := range []viewState{stateBrowse, stateForm, stateFetching, stateResult, stateAPIKey} { m.state = st if st == stateForm { m.modIdx = 0 m.focus = focusEndpoints m.epIdx = 0 m.openSelected() + } else if st == stateAPIKey { + m.openAPIKey() } _ = m.View() }