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/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..0af204b
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,38 @@
+name: Release
+
+on:
+ push:
+ tags:
+ - "v*"
+
+permissions:
+ contents: write
+
+jobs:
+ release:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+
+ - 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: Release with GoReleaser
+ uses: goreleaser/goreleaser-action@v7
+ with:
+ version: v2.17.0
+ 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
new file mode 100644
index 0000000..59ac7d1
--- /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 snapshot release
+ run: goreleaser release --snapshot --clean --skip=publish
diff --git a/.goreleaser.yaml b/.goreleaser.yaml
index 1053912..138afde 100644
--- a/.goreleaser.yaml
+++ b/.goreleaser.yaml
@@ -2,6 +2,8 @@ version: 2
project_name: etherscan
+report_sizes: true
+
builds:
- id: etherscan
main: ./cmd/etherscan
@@ -15,14 +17,57 @@ 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
+ - LICENSE
checksum:
name_template: checksums.txt
+
+snapshot:
+ version_template: "{{ incpatch .Version }}-next"
+
+changelog:
+ sort: asc
+
+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 058995a..8d1c0a3 100644
--- a/README.md
+++ b/README.md
@@ -1,219 +1,422 @@
-# Etherscan CLI
+
Etherscan CLI
-A cross-platform Go CLI and interactive explorer for the [Etherscan V2 API](https://docs.etherscan.io/). It maps supported Etherscan endpoints into commands for accounts, tokens, contracts, logs, gas, stats, proxy/RPC-style methods, API usage, address metadata, across multiple EVM chains.
+
+ Explore EVM chains from your terminal.
+ One API key for balances, transactions, tokens, contracts, logs, gas, stats, and more.
+
-Etherscan's API documentation remains as the main reference for endpoint parameters, responses, rate limits, supported chains, and errors.
+
+
+
+
+
-## Installation
+
+ Install ·
+ Get started ·
+ Examples ·
+ Command reference ·
+ API documentation
+
-Download a prebuilt binary from [GitHub Releases](https://github.com/etherscan/etherscan-cli/releases)
+The official command-line client and interactive explorer for the [Etherscan V2 API](https://docs.etherscan.io/). Use it interactively, pipe clean JSON into scripts, export transactions to CSV, or give an AI agent a predictable interface to on-chain data.
-## Quickstart
+## Why Etherscan CLI?
-Store and validate an Etherscan API key, then make your first request:
+- **Explore interactively** — browse endpoints, fill parameters, switch chains, and inspect results without memorizing commands.
+- **Use one multichain interface** — query Ethereum and supported EVM chains by name or chain ID.
+- **Work with humans or machines** — read tables in a terminal or emit clean JSON and CSV for automation.
+- **Reach broad API coverage** — access accounts, contracts, tokens, logs, blocks, gas, stats, name tags, and proxy methods, with automatic pagination for list endpoints.
+
+## Install
+
+Prebuilt releases support macOS, Linux, and Windows on amd64 and arm64. After installing with any persistent method, run `etherscan version` to verify that the binary is on your `PATH`.
+
+### Homebrew — macOS and Linux
+
+```sh
+brew install etherscan/etherscan-cli/etherscan
+etherscan version
+```
+
+### Installation script — macOS and Linux
+
+```sh
+curl -fsSL https://raw.githubusercontent.com/etherscan/etherscan-cli/master/scripts/install.sh | sh
+```
+
+The script selects the correct amd64 or arm64 archive, verifies its SHA-256 checksum, installs to `~/.local/bin` by default, and adds that directory to your shell profile when needed. Open a new terminal if `etherscan` is not immediately available. Run the installer with `--help` to see version, install-directory, and `PATH` options.
+
+### PowerShell — Windows
+
+Run in PowerShell:
+
+```powershell
+irm https://raw.githubusercontent.com/etherscan/etherscan-cli/master/scripts/install.ps1 | iex
+```
+
+Or from Command Prompt:
+
+```bat
+powershell -NoProfile -ExecutionPolicy Bypass -Command "irm https://raw.githubusercontent.com/etherscan/etherscan-cli/master/scripts/install.ps1 | iex"
+```
+
+The installer selects the correct x64 or arm64 archive, verifies its SHA-256 checksum, installs to `%LOCALAPPDATA%\Programs\Etherscan\bin` by default, and adds that directory to your user `PATH`. Open a new terminal if `etherscan` is not immediately available.
+
+### Go
+
+Go 1.25 or newer is required.
+
+```sh
+go install github.com/etherscan/etherscan-cli/cmd/etherscan@latest
+```
+
+Ensure your Go binary directory (`GOBIN`, or `GOPATH/bin` by default) is on `PATH`.
+
+### Manual
+
+Download the archive for your operating system and architecture plus `checksums.txt` from [GitHub Releases](https://github.com/etherscan/etherscan-cli/releases/latest). Verify the archive's SHA-256 checksum, extract it, and place `etherscan` (or `etherscan.exe`) on your `PATH`.
+
+## Get started
+
+### 1. Create an API key
+
+Create an API key in your [Etherscan API dashboard](https://etherscan.io/myapikey). One Etherscan V2 key works across supported chains, subject to your API plan.
+
+### 2. Authenticate
+
+Validate and save the key locally, then confirm the active identity:
```sh
etherscan login
etherscan whoami
-etherscan account balance 0x0000000000000000000000000000000000000000
```
-Select a chain by name or chain ID:
+For CI or a temporary shell session, set `ETHERSCAN_API_KEY` instead of saving the key.
+
+macOS or Linux:
```sh
-etherscan --chain base account txlist 0x0000000000000000000000000000000000000000 --page 1 --offset 5
-etherscan --chain 8453 gastracker oracle
+export ETHERSCAN_API_KEY="YOUR_API_KEY"
+```
+
+PowerShell:
+
+```powershell
+$env:ETHERSCAN_API_KEY = "YOUR_API_KEY"
+```
+
+Command Prompt:
+
+```bat
+set "ETHERSCAN_API_KEY=YOUR_API_KEY"
```
-Use JSON when handing results to another program or agent:
+### 3. Make your first request
```sh
-etherscan account txlist 0x0000000000000000000000000000000000000000 --json
+etherscan account balance 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045
```
-## Interactive Explorer
+### Explore interactively
-Run `etherscan` in an interactive terminal, allowing you to explore, pick and fill in the parameters of each endpoint.
+Run the CLI without a command to open the full-screen endpoint explorer:
```sh
etherscan
```
-
+Use `etherscan tui` to launch it explicitly. The explorer can be opened before authentication and asks you to validate and save a key when you submit an API-backed endpoint.
+
+## Practical workflows
+
+### Follow wallet activity
+
+```sh
+# Recent normal transactions
+etherscan account txlist 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 --page 1 --offset 10
+
+# ERC-20 transfers as JSON
+etherscan account tokentx 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 --json
+
+# Collect multiple pages for analysis
+etherscan account txlist 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 --all --max-pages 50 --csv
+```
+
+### Inspect a smart contract
+
+```sh
+# WETH contract ABI and verified source metadata
+etherscan contract getabi 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2
+etherscan contract getsourcecode 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 --json
+```
+
+### Switch chains
+
+```sh
+# Chain names and numeric IDs both work
+etherscan --chain base gastracker oracle
+etherscan --chain 8453 account balance 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045
-## Output and Scripting
+# See every chain built into this release
+etherscan chains list
+```
-Tables are the default. Use JSON or CSV for scripts and pipelines:
+### Build scripts and agent workflows
```sh
-etherscan account balance 0x... --json
-etherscan account balance 0x... --json --compact
-etherscan account txlist 0x... --csv
-etherscan account txlist 0x... --all --max-pages 50 --json
+# Clean JSON for jq, Python, or an AI agent
+etherscan account txlist 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 --json --compact | jq '.[0]'
+
+# CSV for spreadsheets and data pipelines
+etherscan account tokentx 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 --csv > token-transfers.csv
```
+## Output and Pagination
+
+Tables are the default. API results are written to stdout; progress messages, warnings, diagnostics, and errors go to stderr so stdout remains suitable for pipelines and redirection.
+
| Flag | Purpose |
| --- | --- |
-| `--output ` | Select `table`, `json`, or `csv` |
+| `--output `, `-o ` | Select `table`, `json`, or `csv` |
| `--json` | Print the raw API result as JSON |
-| `--compact` | Print compact JSON |
+| `--compact` | Remove indentation from JSON output |
| `--csv` | Print list-style results as CSV |
-| `--all` | Automatically paginate supported list commands |
-| `--max-pages ` | Stop automatic pagination after at most `n` pages |
+| `--all` | Automatically follow pages for supported list commands |
+| `--max-pages ` | Stop `--all` after at most `n` pages (default: `20`) |
-If `--all` reaches `--max-pages`, the result may be truncated.
+If `--all` reaches `--max-pages`, the CLI prints a warning to stderr and the result may be truncated. Increase the limit or narrow the request with supported filters when you need more results.
+## Command reference
-## Command Index
+Every command includes built-in parameter and usage help:
+
+```sh
+etherscan --help
+etherscan account txlist --help
+etherscan contract verify --help
+```
+
+
+Browse all commands
### CLI utilities
| Command | Description |
| --- | --- |
-| `etherscan` | Launch the explorer in an interactive terminal |
+| `etherscan` | Launch the interactive explorer in a terminal |
| `etherscan tui` | Explicitly launch the interactive explorer |
| `etherscan login` | Validate and store an API key |
| `etherscan logout` | Remove the stored API key |
| `etherscan uninstall` | Remove all CLI configuration |
-| `etherscan whoami` | Show the active chain and saved API key |
+| `etherscan update` | Update a Homebrew or installer-script installation |
+| `etherscan whoami` | Show the active chain and masked API key |
| `etherscan config` | Get, list, or set CLI configuration |
| `etherscan chains list` | List chains built into this CLI release |
| `etherscan completion` | Generate shell completion |
-| `etherscan version` | Print build information |
-| `etherscan --help` | Helpful tips and command usage |
+| `etherscan version` | Print the CLI version |
+| `etherscan --help` | Show command usage and available options |
### Account
-| Command | API documentation |
-| --- | --- |
-| `etherscan account balance` | [balance](https://docs.etherscan.io/api-reference/endpoint/balance.md) |
-| `etherscan account balancemulti` | [balancemulti](https://docs.etherscan.io/api-reference/endpoint/balancemulti.md) |
-| `etherscan account txlist` | [txlist](https://docs.etherscan.io/api-reference/endpoint/txlist.md), [advanced-filter-txlist](https://docs.etherscan.io/api-reference/endpoint/advanced-filter-txlist.md) |
-| `etherscan account txlistinternal` | [txlistinternal](https://docs.etherscan.io/api-reference/endpoint/txlistinternal.md), [txlistinternal-blockrange](https://docs.etherscan.io/api-reference/endpoint/txlistinternal-blockrange.md), [txlistinternal-txhash](https://docs.etherscan.io/api-reference/endpoint/txlistinternal-txhash.md), [advanced-filter-txlistinternal](https://docs.etherscan.io/api-reference/endpoint/advanced-filter-txlistinternal.md) |
-| `etherscan account tokentx` | [tokentx](https://docs.etherscan.io/api-reference/endpoint/tokentx.md), [advanced-filter-tokentx](https://docs.etherscan.io/api-reference/endpoint/advanced-filter-tokentx.md) |
-| `etherscan account tokennfttx` | [tokennfttx](https://docs.etherscan.io/api-reference/endpoint/tokennfttx.md), [advanced-filter-tokennfttx](https://docs.etherscan.io/api-reference/endpoint/advanced-filter-tokennfttx.md) |
-| `etherscan account token1155tx` | [token1155tx](https://docs.etherscan.io/api-reference/endpoint/token1155tx.md), [advanced-filter-token1155tx](https://docs.etherscan.io/api-reference/endpoint/advanced-filter-token1155tx.md) |
-| `etherscan account getminedblocks` | [getminedblocks](https://docs.etherscan.io/api-reference/endpoint/getminedblocks.md) |
-| `etherscan account balancehistory` | [balancehistory](https://docs.etherscan.io/api-reference/endpoint/balancehistory.md) |
-| `etherscan account tokenbalance` | [tokenbalance](https://docs.etherscan.io/api-reference/endpoint/tokenbalance.md) |
-| `etherscan account tokenbalancehistory` | [tokenbalancehistory](https://docs.etherscan.io/api-reference/endpoint/tokenbalancehistory.md) |
-| `etherscan account addresstokenbalance` | [addresstokenbalance](https://docs.etherscan.io/api-reference/endpoint/addresstokenbalance.md) |
-| `etherscan account addresstokennftbalance` | [addresstokennftbalance](https://docs.etherscan.io/api-reference/endpoint/addresstokennftbalance.md) |
-| `etherscan account addresstokennftinventory` | [addresstokennftinventory](https://docs.etherscan.io/api-reference/endpoint/addresstokennftinventory.md) |
-| `etherscan account getdeposittxs` | [getdeposittxs](https://docs.etherscan.io/api-reference/endpoint/getdeposittxs.md) |
-| `etherscan account getwithdrawaltxs` | [getwithdrawaltxs](https://docs.etherscan.io/api-reference/endpoint/getwithdrawaltxs.md) |
-| `etherscan account txsBeaconWithdrawal` | [txsbeaconwithdrawal](https://docs.etherscan.io/api-reference/endpoint/txsbeaconwithdrawal.md) |
-| `etherscan account fundedby` | [fundedby](https://docs.etherscan.io/api-reference/endpoint/fundedby.md) |
-| `etherscan account txnbridge` | [txnbridge](https://docs.etherscan.io/api-reference/endpoint/txnbridge.md) |
+| Command | Description | API docs |
+| --- | --- | --- |
+| `etherscan account balance` | Get the native balance of an address | [balance](https://docs.etherscan.io/api-reference/endpoint/balance.md) |
+| `etherscan account balancemulti` | Get native balances for multiple addresses | [balancemulti](https://docs.etherscan.io/api-reference/endpoint/balancemulti.md) |
+| `etherscan account txlist` | List normal transactions for an address or advanced filter | [txlist](https://docs.etherscan.io/api-reference/endpoint/txlist.md), [advanced-filter-txlist](https://docs.etherscan.io/api-reference/endpoint/advanced-filter-txlist.md) |
+| `etherscan account txlistinternal` | List internal transactions by address, transaction hash, block range, or advanced filter | [txlistinternal](https://docs.etherscan.io/api-reference/endpoint/txlistinternal.md), [txlistinternal-blockrange](https://docs.etherscan.io/api-reference/endpoint/txlistinternal-blockrange.md), [txlistinternal-txhash](https://docs.etherscan.io/api-reference/endpoint/txlistinternal-txhash.md), [advanced-filter-txlistinternal](https://docs.etherscan.io/api-reference/endpoint/advanced-filter-txlistinternal.md) |
+| `etherscan account tokentx` | List ERC-20 token transfers | [tokentx](https://docs.etherscan.io/api-reference/endpoint/tokentx.md), [advanced-filter-tokentx](https://docs.etherscan.io/api-reference/endpoint/advanced-filter-tokentx.md) |
+| `etherscan account tokennfttx` | List ERC-721 token transfers | [tokennfttx](https://docs.etherscan.io/api-reference/endpoint/tokennfttx.md), [advanced-filter-tokennfttx](https://docs.etherscan.io/api-reference/endpoint/advanced-filter-tokennfttx.md) |
+| `etherscan account token1155tx` | List ERC-1155 token transfers | [token1155tx](https://docs.etherscan.io/api-reference/endpoint/token1155tx.md), [advanced-filter-token1155tx](https://docs.etherscan.io/api-reference/endpoint/advanced-filter-token1155tx.md) |
+| `etherscan account getminedblocks` | List blocks or uncles mined by an address | [getminedblocks](https://docs.etherscan.io/api-reference/endpoint/getminedblocks.md) |
+| `etherscan account balancehistory` | Get an address's native balance at a block | [balancehistory](https://docs.etherscan.io/api-reference/endpoint/balancehistory.md) |
+| `etherscan account tokenbalance` | Get an address's ERC-20 token balance | [tokenbalance](https://docs.etherscan.io/api-reference/endpoint/tokenbalance.md) |
+| `etherscan account tokenbalancehistory` | Get an address's token balance at a block | [tokenbalancehistory](https://docs.etherscan.io/api-reference/endpoint/tokenbalancehistory.md) |
+| `etherscan account addresstokenbalance` | List ERC-20 holdings for an address | [addresstokenbalance](https://docs.etherscan.io/api-reference/endpoint/addresstokenbalance.md) |
+| `etherscan account addresstokennftbalance` | List NFT holdings for an address | [addresstokennftbalance](https://docs.etherscan.io/api-reference/endpoint/addresstokennftbalance.md) |
+| `etherscan account addresstokennftinventory` | List an address's inventory for an NFT contract | [addresstokennftinventory](https://docs.etherscan.io/api-reference/endpoint/addresstokennftinventory.md) |
+| `etherscan account getdeposittxs` | List L2 deposit transactions | [getdeposittxs](https://docs.etherscan.io/api-reference/endpoint/getdeposittxs.md) |
+| `etherscan account getwithdrawaltxs` | List L2 withdrawal transactions | [getwithdrawaltxs](https://docs.etherscan.io/api-reference/endpoint/getwithdrawaltxs.md) |
+| `etherscan account txsBeaconWithdrawal` | List Ethereum beacon withdrawals | [txsbeaconwithdrawal](https://docs.etherscan.io/api-reference/endpoint/txsbeaconwithdrawal.md) |
+| `etherscan account fundedby` | Find the address that likely funded an account | [fundedby](https://docs.etherscan.io/api-reference/endpoint/fundedby.md) |
+| `etherscan account txnbridge` | List bridge transactions for an address | [txnbridge](https://docs.etherscan.io/api-reference/endpoint/txnbridge.md) |
### Contract
-| Command | API documentation |
-| --- | --- |
-| `etherscan contract getabi` | [getabi](https://docs.etherscan.io/api-reference/endpoint/getabi.md) |
-| `etherscan contract getsourcecode` | [getsourcecode](https://docs.etherscan.io/api-reference/endpoint/getsourcecode.md) |
-| `etherscan contract getcontractcreation` | [getcontractcreation](https://docs.etherscan.io/api-reference/endpoint/getcontractcreation.md) |
-| `etherscan contract verify` | [verifysourcecode](https://docs.etherscan.io/api-reference/endpoint/verifysourcecode.md) |
-| `etherscan contract verify-status` | [checkverifystatus](https://docs.etherscan.io/api-reference/endpoint/checkverifystatus.md) |
-| `etherscan contract verify-proxy` | [verifyproxycontract](https://docs.etherscan.io/api-reference/endpoint/verifyproxycontract.md) |
-| `etherscan contract check-proxy` | [checkproxyverification](https://docs.etherscan.io/api-reference/endpoint/checkproxyverification.md) |
+| Command | Description | API docs |
+| --- | --- | --- |
+| `etherscan contract getabi` | Get a verified contract's ABI | [getabi](https://docs.etherscan.io/api-reference/endpoint/getabi.md) |
+| `etherscan contract getsourcecode` | Get verified source code and contract metadata | [getsourcecode](https://docs.etherscan.io/api-reference/endpoint/getsourcecode.md) |
+| `etherscan contract getcontractcreation` | Get creator and creation transaction data for contracts | [getcontractcreation](https://docs.etherscan.io/api-reference/endpoint/getcontractcreation.md) |
+| `etherscan contract verify` | Submit contract source code for verification | [verifysourcecode](https://docs.etherscan.io/api-reference/endpoint/verifysourcecode.md) |
+| `etherscan contract verify-status` | Check a source verification submission | [checkverifystatus](https://docs.etherscan.io/api-reference/endpoint/checkverifystatus.md) |
+| `etherscan contract verify-proxy` | Submit a proxy contract for verification | [verifyproxycontract](https://docs.etherscan.io/api-reference/endpoint/verifyproxycontract.md) |
+| `etherscan contract check-proxy` | Check a proxy verification submission | [checkproxyverification](https://docs.etherscan.io/api-reference/endpoint/checkproxyverification.md) |
### Transaction
-| Command | API documentation |
-| --- | --- |
-| `etherscan transaction status` | [getstatus](https://docs.etherscan.io/api-reference/endpoint/getstatus.md) |
-| `etherscan transaction receipt-status` | [gettxreceiptstatus](https://docs.etherscan.io/api-reference/endpoint/gettxreceiptstatus.md) |
+| Command | Description | API docs |
+| --- | --- | --- |
+| `etherscan transaction status` | Get a transaction's execution status and error description | [getstatus](https://docs.etherscan.io/api-reference/endpoint/getstatus.md) |
+| `etherscan transaction receipt-status` | Get a transaction receipt's success or failure status | [gettxreceiptstatus](https://docs.etherscan.io/api-reference/endpoint/gettxreceiptstatus.md) |
### Block
-| Command | API documentation |
-| --- | --- |
-| `etherscan block reward` | [getblockreward](https://docs.etherscan.io/api-reference/endpoint/getblockreward.md) |
-| `etherscan block countdown` | [getblockcountdown](https://docs.etherscan.io/api-reference/endpoint/getblockcountdown.md) |
-| `etherscan block txcount` | [getblocktxnscount](https://docs.etherscan.io/api-reference/endpoint/getblocktxnscount.md) |
-| `etherscan block bytime` | [getblocknobytime](https://docs.etherscan.io/api-reference/endpoint/getblocknobytime.md) |
+| Command | Description | API docs |
+| --- | --- | --- |
+| `etherscan block reward` | Get block and uncle rewards | [getblockreward](https://docs.etherscan.io/api-reference/endpoint/getblockreward.md) |
+| `etherscan block countdown` | Estimate the time remaining until a block | [getblockcountdown](https://docs.etherscan.io/api-reference/endpoint/getblockcountdown.md) |
+| `etherscan block txcount` | Get the number of transactions in a block | [getblocktxnscount](https://docs.etherscan.io/api-reference/endpoint/getblocktxnscount.md) |
+| `etherscan block bytime` | Find the closest block before or after a timestamp | [getblocknobytime](https://docs.etherscan.io/api-reference/endpoint/getblocknobytime.md) |
### Logs
-| Command | API documentation |
-| --- | --- |
-| `etherscan logs get` | [getlogs](https://docs.etherscan.io/api-reference/endpoint/getlogs.md), [getlogs-address-topics](https://docs.etherscan.io/api-reference/endpoint/getlogs-address-topics.md), [getlogs-topics](https://docs.etherscan.io/api-reference/endpoint/getlogs-topics.md) |
+| Command | Description | API docs |
+| --- | --- | --- |
+| `etherscan logs get` | Query event logs by block range, address, and topics | [getlogs](https://docs.etherscan.io/api-reference/endpoint/getlogs.md), [getlogs-address-topics](https://docs.etherscan.io/api-reference/endpoint/getlogs-address-topics.md), [getlogs-topics](https://docs.etherscan.io/api-reference/endpoint/getlogs-topics.md) |
### Stats
-| Command | API documentation |
-| --- | --- |
-| `etherscan stats ethsupply` | [ethsupply](https://docs.etherscan.io/api-reference/endpoint/ethsupply.md) |
-| `etherscan stats ethsupply2` | [ethsupply2](https://docs.etherscan.io/api-reference/endpoint/ethsupply2.md) |
-| `etherscan stats ethprice` | [ethprice](https://docs.etherscan.io/api-reference/endpoint/ethprice.md) |
-| `etherscan stats chainsize` | [chainsize](https://docs.etherscan.io/api-reference/endpoint/chainsize.md) |
-| `etherscan stats nodecount` | [nodecount](https://docs.etherscan.io/api-reference/endpoint/nodecount.md) |
-| `etherscan stats tokensupply` | [tokensupply](https://docs.etherscan.io/api-reference/endpoint/tokensupply.md) |
-| `etherscan stats tokensupplyhistory` | [tokensupplyhistory](https://docs.etherscan.io/api-reference/endpoint/tokensupplyhistory.md) |
-| `etherscan stats ethdailyprice` | [ethdailyprice](https://docs.etherscan.io/api-reference/endpoint/ethdailyprice.md) |
-| `etherscan stats dailytx` | [dailytx](https://docs.etherscan.io/api-reference/endpoint/dailytx.md) |
-| `etherscan stats dailynewaddress` | [dailynewaddress](https://docs.etherscan.io/api-reference/endpoint/dailynewaddress.md) |
-| `etherscan stats dailyavgblocksize` | [dailyavgblocksize](https://docs.etherscan.io/api-reference/endpoint/dailyavgblocksize.md) |
-| `etherscan stats dailyavgblocktime` | [dailyavgblocktime](https://docs.etherscan.io/api-reference/endpoint/dailyavgblocktime.md) |
-| `etherscan stats dailyavggasprice` | [dailyavggasprice](https://docs.etherscan.io/api-reference/endpoint/dailyavggasprice.md) |
-| `etherscan stats dailyavggaslimit` | [dailyavggaslimit](https://docs.etherscan.io/api-reference/endpoint/dailyavggaslimit.md) |
-| `etherscan stats dailygasused` | [dailygasused](https://docs.etherscan.io/api-reference/endpoint/dailygasused.md) |
-| `etherscan stats dailyblockrewards` | [dailyblockrewards](https://docs.etherscan.io/api-reference/endpoint/dailyblockrewards.md) |
-| `etherscan stats dailyblkcount` | [dailyblkcount](https://docs.etherscan.io/api-reference/endpoint/dailyblkcount.md) |
-| `etherscan stats dailytxnfee` | [dailytxnfee](https://docs.etherscan.io/api-reference/endpoint/dailytxnfee.md) |
-| `etherscan stats dailynetutilization` | [dailynetutilization](https://docs.etherscan.io/api-reference/endpoint/dailynetutilization.md) |
-| `etherscan stats dailyuncleblkcount` | [dailyuncleblkcount](https://docs.etherscan.io/api-reference/endpoint/dailyuncleblkcount.md) |
-| `etherscan stats dailyavghashrate` | [dailyavghashrate](https://docs.etherscan.io/api-reference/endpoint/dailyavghashrate.md) |
-| `etherscan stats dailyavgnetdifficulty` | [dailyavgnetdifficulty](https://docs.etherscan.io/api-reference/endpoint/dailyavgnetdifficulty.md) |
-| `etherscan stats dailyensregister` | [dailyensregister](https://docs.etherscan.io/api-reference/endpoint/dailyensregister.md) |
-| `etherscan stats nodecounthistory` | [nodecounthistory](https://docs.etherscan.io/api-reference/endpoint/nodecounthistory.md) |
+| Command | Description | API docs |
+| --- | --- | --- |
+| `etherscan stats ethsupply` | Get the total ETH supply | [ethsupply](https://docs.etherscan.io/api-reference/endpoint/ethsupply.md) |
+| `etherscan stats ethsupply2` | Get the extended ETH supply breakdown | [ethsupply2](https://docs.etherscan.io/api-reference/endpoint/ethsupply2.md) |
+| `etherscan stats ethprice` | Get the latest ETH price | [ethprice](https://docs.etherscan.io/api-reference/endpoint/ethprice.md) |
+| `etherscan stats chainsize` | Get historical Ethereum chain size data | [chainsize](https://docs.etherscan.io/api-reference/endpoint/chainsize.md) |
+| `etherscan stats nodecount` | Get the total Ethereum node count | [nodecount](https://docs.etherscan.io/api-reference/endpoint/nodecount.md) |
+| `etherscan stats tokensupply` | Get an ERC-20 token's total supply | [tokensupply](https://docs.etherscan.io/api-reference/endpoint/tokensupply.md) |
+| `etherscan stats tokensupplyhistory` | Get a token's total supply at a block | [tokensupplyhistory](https://docs.etherscan.io/api-reference/endpoint/tokensupplyhistory.md) |
+| `etherscan stats ethdailyprice` | Get historical daily ETH prices | [ethdailyprice](https://docs.etherscan.io/api-reference/endpoint/ethdailyprice.md) |
+| `etherscan stats dailytx` | Get historical daily transaction counts | [dailytx](https://docs.etherscan.io/api-reference/endpoint/dailytx.md) |
+| `etherscan stats dailynewaddress` | Get historical daily new-address counts | [dailynewaddress](https://docs.etherscan.io/api-reference/endpoint/dailynewaddress.md) |
+| `etherscan stats dailyavgblocksize` | Get historical average daily block size | [dailyavgblocksize](https://docs.etherscan.io/api-reference/endpoint/dailyavgblocksize.md) |
+| `etherscan stats dailyavgblocktime` | Get historical average daily block time | [dailyavgblocktime](https://docs.etherscan.io/api-reference/endpoint/dailyavgblocktime.md) |
+| `etherscan stats dailyavggasprice` | Get historical average daily gas price | [dailyavggasprice](https://docs.etherscan.io/api-reference/endpoint/dailyavggasprice.md) |
+| `etherscan stats dailyavggaslimit` | Get historical average daily gas limit | [dailyavggaslimit](https://docs.etherscan.io/api-reference/endpoint/dailyavggaslimit.md) |
+| `etherscan stats dailygasused` | Get historical total daily gas used | [dailygasused](https://docs.etherscan.io/api-reference/endpoint/dailygasused.md) |
+| `etherscan stats dailyblockrewards` | Get historical daily block rewards | [dailyblockrewards](https://docs.etherscan.io/api-reference/endpoint/dailyblockrewards.md) |
+| `etherscan stats dailyblkcount` | Get historical daily block counts | [dailyblkcount](https://docs.etherscan.io/api-reference/endpoint/dailyblkcount.md) |
+| `etherscan stats dailytxnfee` | Get historical daily transaction fees | [dailytxnfee](https://docs.etherscan.io/api-reference/endpoint/dailytxnfee.md) |
+| `etherscan stats dailynetutilization` | Get historical daily network utilization | [dailynetutilization](https://docs.etherscan.io/api-reference/endpoint/dailynetutilization.md) |
+| `etherscan stats dailyuncleblkcount` | Get historical daily uncle block counts | [dailyuncleblkcount](https://docs.etherscan.io/api-reference/endpoint/dailyuncleblkcount.md) |
+| `etherscan stats dailyavghashrate` | Get historical average daily network hash rate | [dailyavghashrate](https://docs.etherscan.io/api-reference/endpoint/dailyavghashrate.md) |
+| `etherscan stats dailyavgnetdifficulty` | Get historical average daily network difficulty | [dailyavgnetdifficulty](https://docs.etherscan.io/api-reference/endpoint/dailyavgnetdifficulty.md) |
+| `etherscan stats dailyensregister` | Get historical daily ENS registration counts | [dailyensregister](https://docs.etherscan.io/api-reference/endpoint/dailyensregister.md) |
+| `etherscan stats nodecounthistory` | Get historical Ethereum node counts | [nodecounthistory](https://docs.etherscan.io/api-reference/endpoint/nodecounthistory.md) |
### Token
-| Command | API documentation |
-| --- | --- |
-| `etherscan token info` | [tokeninfo](https://docs.etherscan.io/api-reference/endpoint/tokeninfo.md) |
-| `etherscan token tokenholderlist` | [tokenholderlist](https://docs.etherscan.io/api-reference/endpoint/tokenholderlist.md) |
-| `etherscan token tokenholdercount` | [tokenholdercount](https://docs.etherscan.io/api-reference/endpoint/tokenholdercount.md) |
-| `etherscan token topholders` | [topholders](https://docs.etherscan.io/api-reference/endpoint/topholders.md) |
+| Command | Description | API docs |
+| --- | --- | --- |
+| `etherscan token info` | Get token metadata such as name, symbol, type, and supply | [tokeninfo](https://docs.etherscan.io/api-reference/endpoint/tokeninfo.md) |
+| `etherscan token tokenholderlist` | List token holders and their balances | [tokenholderlist](https://docs.etherscan.io/api-reference/endpoint/tokenholderlist.md) |
+| `etherscan token tokenholdercount` | Get a token's holder count | [tokenholdercount](https://docs.etherscan.io/api-reference/endpoint/tokenholdercount.md) |
+| `etherscan token topholders` | Get the largest token holders | [topholders](https://docs.etherscan.io/api-reference/endpoint/topholders.md) |
### Gas Tracker
-| Command | API documentation |
-| --- | --- |
-| `etherscan gastracker oracle` | [gasoracle](https://docs.etherscan.io/api-reference/endpoint/gasoracle.md) |
-| `etherscan gastracker estimate` | [gasestimate](https://docs.etherscan.io/api-reference/endpoint/gasestimate.md) |
+| Command | Description | API docs |
+| --- | --- | --- |
+| `etherscan gastracker oracle` | Get safe, proposed, and fast gas prices | [gasoracle](https://docs.etherscan.io/api-reference/endpoint/gasoracle.md) |
+| `etherscan gastracker estimate` | Estimate confirmation time for a gas price | [gasestimate](https://docs.etherscan.io/api-reference/endpoint/gasestimate.md) |
### Nametag
-| Command | API documentation |
-| --- | --- |
-| `etherscan nametag getaddresstag` | [getaddresstag](https://docs.etherscan.io/api-reference/endpoint/getaddresstag.md) |
+| Command | Description | API docs |
+| --- | --- | --- |
+| `etherscan nametag getaddresstag` | Get name tags and metadata for addresses (Pro Plus) | [getaddresstag](https://docs.etherscan.io/api-reference/endpoint/getaddresstag.md) |
### Proxy
-| Command | API documentation |
-| --- | --- |
-| `etherscan proxy eth_blockNumber` | [ethblocknumber](https://docs.etherscan.io/api-reference/endpoint/ethblocknumber.md) |
-| `etherscan proxy eth_getBlockByNumber` | [ethgetblockbynumber](https://docs.etherscan.io/api-reference/endpoint/ethgetblockbynumber.md) |
-| `etherscan proxy eth_getTransactionByHash` | [ethgettransactionbyhash](https://docs.etherscan.io/api-reference/endpoint/ethgettransactionbyhash.md) |
-| `etherscan proxy eth_getTransactionByBlockNumberAndIndex` | [ethgettransactionbyblocknumberandindex](https://docs.etherscan.io/api-reference/endpoint/ethgettransactionbyblocknumberandindex.md) |
-| `etherscan proxy eth_getTransactionCount` | [ethgettransactioncount](https://docs.etherscan.io/api-reference/endpoint/ethgettransactioncount.md) |
-| `etherscan proxy eth_getBlockTransactionCountByNumber` | [ethgetblocktransactioncountbynumber](https://docs.etherscan.io/api-reference/endpoint/ethgetblocktransactioncountbynumber.md) |
-| `etherscan proxy eth_getUncleByBlockNumberAndIndex` | [ethgetunclebyblocknumberandindex](https://docs.etherscan.io/api-reference/endpoint/ethgetunclebyblocknumberandindex.md) |
-| `etherscan proxy eth_sendRawTransaction` | [ethsendrawtransaction](https://docs.etherscan.io/api-reference/endpoint/ethsendrawtransaction.md) |
-| `etherscan proxy eth_call` | [ethcall](https://docs.etherscan.io/api-reference/endpoint/ethcall.md) |
-| `etherscan proxy eth_estimateGas` | [ethestimategas](https://docs.etherscan.io/api-reference/endpoint/ethestimategas.md) |
-| `etherscan proxy eth_getTransactionReceipt` | [ethgettransactionreceipt](https://docs.etherscan.io/api-reference/endpoint/ethgettransactionreceipt.md) |
-| `etherscan proxy eth_getCode` | [ethgetcode](https://docs.etherscan.io/api-reference/endpoint/ethgetcode.md) |
-| `etherscan proxy eth_getStorageAt` | [ethgetstorageat](https://docs.etherscan.io/api-reference/endpoint/ethgetstorageat.md) |
-| `etherscan proxy eth_gasPrice` | [ethgasprice](https://docs.etherscan.io/api-reference/endpoint/ethgasprice.md) |
+| Command | Description | API docs |
+| --- | --- | --- |
+| `etherscan proxy eth_blockNumber` | Get the latest block number | [ethblocknumber](https://docs.etherscan.io/api-reference/endpoint/ethblocknumber.md) |
+| `etherscan proxy eth_getBlockByNumber` | Get a block by number or tag | [ethgetblockbynumber](https://docs.etherscan.io/api-reference/endpoint/ethgetblockbynumber.md) |
+| `etherscan proxy eth_getTransactionByHash` | Get a transaction by hash | [ethgettransactionbyhash](https://docs.etherscan.io/api-reference/endpoint/ethgettransactionbyhash.md) |
+| `etherscan proxy eth_getTransactionByBlockNumberAndIndex` | Get a transaction by block number and index | [ethgettransactionbyblocknumberandindex](https://docs.etherscan.io/api-reference/endpoint/ethgettransactionbyblocknumberandindex.md) |
+| `etherscan proxy eth_getTransactionCount` | Get an address's transaction count (nonce) | [ethgettransactioncount](https://docs.etherscan.io/api-reference/endpoint/ethgettransactioncount.md) |
+| `etherscan proxy eth_getBlockTransactionCountByNumber` | Get a block's transaction count | [ethgetblocktransactioncountbynumber](https://docs.etherscan.io/api-reference/endpoint/ethgetblocktransactioncountbynumber.md) |
+| `etherscan proxy eth_getUncleByBlockNumberAndIndex` | Get an uncle by block number and index | [ethgetunclebyblocknumberandindex](https://docs.etherscan.io/api-reference/endpoint/ethgetunclebyblocknumberandindex.md) |
+| `etherscan proxy eth_sendRawTransaction` | Broadcast a signed raw transaction | [ethsendrawtransaction](https://docs.etherscan.io/api-reference/endpoint/ethsendrawtransaction.md) |
+| `etherscan proxy eth_call` | Execute a read-only contract call | [ethcall](https://docs.etherscan.io/api-reference/endpoint/ethcall.md) |
+| `etherscan proxy eth_estimateGas` | Estimate the gas required for a transaction | [ethestimategas](https://docs.etherscan.io/api-reference/endpoint/ethestimategas.md) |
+| `etherscan proxy eth_getTransactionReceipt` | Get a transaction receipt by hash | [ethgettransactionreceipt](https://docs.etherscan.io/api-reference/endpoint/ethgettransactionreceipt.md) |
+| `etherscan proxy eth_getCode` | Get the code stored at an address | [ethgetcode](https://docs.etherscan.io/api-reference/endpoint/ethgetcode.md) |
+| `etherscan proxy eth_getStorageAt` | Get a value from a contract storage position | [ethgetstorageat](https://docs.etherscan.io/api-reference/endpoint/ethgetstorageat.md) |
+| `etherscan proxy eth_gasPrice` | Get the current gas price | [ethgasprice](https://docs.etherscan.io/api-reference/endpoint/ethgasprice.md) |
### API Usage
-| Command | API documentation |
-| --- | --- |
-| `etherscan apilimit` | [getapilimit](https://docs.etherscan.io/api-reference/endpoint/getapilimit.md) |
+| Command | Description | API docs |
+| --- | --- | --- |
+| `etherscan apilimit` | Show used, available, and total API credits | [getapilimit](https://docs.etherscan.io/api-reference/endpoint/getapilimit.md) |
+
+
+
+## Configuration and authentication
+
+Authentication is resolved in this order: `--api-key` for the current command, `ETHERSCAN_API_KEY`, then the key saved by `etherscan login`.
+
+`etherscan login` and the TUI key-setup prompt store the key as plaintext in `$XDG_CONFIG_HOME/etherscan/config.toml` when `XDG_CONFIG_HOME` is set, or `~/.etherscan/config.toml` otherwise. The directory and file are created with restrictive permissions where the operating system supports them. Treat this file as a secret, never commit API keys, and prefer the environment variable or `--api-key` for CI and temporary sessions.
+
+`etherscan logout` removes only the saved key. If `ETHERSCAN_API_KEY` is set, it remains active until you unset it in that shell or environment.
+
+Manage non-secret defaults with:
+
+```sh
+etherscan config list
+etherscan config set default_chain=base
+etherscan config set default_output=json
+```
+
+For the active chain, `--chain` takes precedence over `ETHERSCAN_CHAIN`, which takes precedence over `default_chain` in the configuration file.
+
+## Updating
+
+Update through the same channel used to install the CLI:
+
+| Installation channel | Update command |
+| --- | --- |
+| Homebrew | `brew upgrade etherscan/etherscan-cli/etherscan` |
+| macOS/Linux or Windows installer script | `etherscan update` |
+| Go | `go install github.com/etherscan/etherscan-cli/cmd/etherscan@latest` |
+| Manual release archive | Download and verify the new archive from [GitHub Releases](https://github.com/etherscan/etherscan-cli/releases/latest) |
+
+Use the channel-specific command above rather than mixing update mechanisms. `etherscan update` is intended for Homebrew and installer-script installations.
+
+## Shell completion
+
+Generate completion for bash, zsh, fish, or PowerShell, then load or save the output according to your shell's completion setup:
+
+```sh
+etherscan completion bash
+etherscan completion zsh
+etherscan completion fish
+etherscan completion powershell
+```
+
+## Development
+
+Go 1.25 or newer is required to build the CLI.
+
+```sh
+# Run the Go test suite
+go test ./...
+
+# Build the CLI
+go build -o etherscan ./cmd/etherscan
+```
+
+Installer changes can be checked with `sh scripts/test-install.sh` on macOS/Linux or `./scripts/test-install.ps1` in PowerShell on Windows.
+
+## API coverage and support
+
+Endpoint, chain, and plan availability can differ. The Etherscan documentation is authoritative for [supported chains](https://docs.etherscan.io/supported-chains), [rate limits](https://docs.etherscan.io/resources/rate-limits), [PRO endpoints](https://docs.etherscan.io/resources/pro-endpoints), parameters, responses, and [API errors](https://docs.etherscan.io/resources/common-error-messages).
+
+Report CLI bugs and feature requests in [GitHub Issues](https://github.com/etherscan/etherscan-cli/issues). For API-key, account, billing, or endpoint-support questions, use the [Etherscan support form](https://etherscan.io/contactus?id=11).
+
+## License
+
+[MIT](LICENSE)
diff --git a/cmd/etherscan/main.go b/cmd/etherscan/main.go
index aac7568..0809fa5 100644
--- a/cmd/etherscan/main.go
+++ b/cmd/etherscan/main.go
@@ -5,12 +5,17 @@ import (
"fmt"
"os"
"os/signal"
+ "runtime/debug"
+ "strings"
"github.com/etherscan/etherscan-cli/internal/cli"
)
+// These are overridden at release time via -ldflags (see .goreleaser.yaml). A
+// plain `go install`/`go build` leaves them at these defaults, so buildInfo()
+// falls back to the module build info the toolchain embeds.
var (
- version = "1.0"
+ version = "dev"
commit = "none"
date = "unknown"
)
@@ -19,9 +24,37 @@ func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
- root := cli.NewRootCommand(cli.BuildInfo{Version: version, Commit: commit, Date: date})
+ root := cli.NewRootCommand(buildInfo())
if err := root.ExecuteContext(ctx); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
+
+// buildInfo resolves version/commit/date. GoReleaser's ldflags win when present;
+// otherwise (e.g. `go install github.com/etherscan/etherscan-cli/cmd/etherscan@latest`)
+// it reads the version the Go toolchain stamps into the module build info, so
+// `etherscan version` reports the installed tag/pseudo-version instead of "dev".
+func buildInfo() cli.BuildInfo {
+ v, c, d := version, commit, date
+ if v == "dev" {
+ if bi, ok := debug.ReadBuildInfo(); ok {
+ if mv := bi.Main.Version; mv != "" && mv != "(devel)" {
+ v = strings.TrimPrefix(mv, "v")
+ }
+ for _, s := range bi.Settings {
+ switch s.Key {
+ case "vcs.revision":
+ if c == "none" {
+ c = s.Value
+ }
+ case "vcs.time":
+ if d == "unknown" {
+ d = s.Value
+ }
+ }
+ }
+ }
+ }
+ return cli.BuildInfo{Version: v, Commit: c, Date: d}
+}
diff --git a/internal/cli/root.go b/internal/cli/root.go
index 255ee29..bf0f68c 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
}
@@ -215,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 {
@@ -516,7 +539,90 @@ 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 [2]: ")
+ choice, readErr := bufio.NewReader(in).ReadString('\n')
+ if readErr != nil && !errors.Is(readErr, io.EOF) {
+ return false, nil
+ }
+ // Enter (empty) defaults to Later so a reflexive keypress on the way into the
+ // explorer never kicks off a self-update; only an explicit "1" updates.
+ 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 +631,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)
},
}
@@ -538,26 +648,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()
@@ -570,6 +676,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),
@@ -577,6 +707,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,
})
@@ -595,45 +727,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/cli/update_test.go b/internal/cli/update_test.go
new file mode 100644
index 0000000..8a12c03
--- /dev/null
+++ b/internal/cli/update_test.go
@@ -0,0 +1,99 @@
+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("enter defaults to later", func(t *testing.T) {
+ manager := &fakeUpdateManager{result: result, method: updater.MethodScript}
+ exit, err := offerUpdate(context.Background(), manager, "1.1.0", strings.NewReader("\n"), &bytes.Buffer{}, &bytes.Buffer{})
+ if err != nil || exit || manager.skipped != "" || manager.upgradedVersion != "" {
+ t.Fatalf("empty input should default to Later: 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/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()
}
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..ae1835f
--- /dev/null
+++ b/internal/updater/updater.go
@@ -0,0 +1,225 @@
+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 {
+ dir := filepath.Dir(path)
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ return err
+ }
+ // Write to a sibling temp file and rename over the target so a concurrent CLI
+ // launch (e.g. two terminals on the same day) can never observe a torn file.
+ f, err := os.CreateTemp(dir, ".update-state-*.json")
+ if err != nil {
+ return err
+ }
+ tmp := f.Name()
+ err = json.NewEncoder(f).Encode(st)
+ if closeErr := f.Close(); err == nil {
+ err = closeErr
+ }
+ if err != nil {
+ os.Remove(tmp)
+ return err
+ }
+ if err := os.Rename(tmp, path); err != nil {
+ os.Remove(tmp)
+ return err
+ }
+ return nil
+}
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
new file mode 100644
index 0000000..3c8fec2
--- /dev/null
+++ b/scripts/install.ps1
@@ -0,0 +1,244 @@
+[CmdletBinding()]
+param(
+ [string]$Version = $env:ETHERSCAN_VERSION,
+ [string]$InstallDir = $env:ETHERSCAN_INSTALL_DIR,
+ [switch]$NoPathUpdate,
+ [int]$WaitForProcessId = 0,
+ [switch]$CleanupScript
+)
+
+$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."
+}
+if ($WaitForProcessId -gt 0) {
+ Wait-Process -Id $WaitForProcessId -ErrorAction SilentlyContinue
+}
+
+$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
+ if ($CleanupScript -and -not [string]::IsNullOrWhiteSpace($PSCommandPath)) {
+ Remove-Item -LiteralPath $PSCommandPath -Force -ErrorAction SilentlyContinue
+ }
+}
diff --git a/scripts/install.sh b/scripts/install.sh
new file mode 100644
index 0000000..4a7c761
--- /dev/null
+++ b/scripts/install.sh
@@ -0,0 +1,211 @@
+#!/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
+
+ # Match the exact line we would write (whole-line, fixed-string) so an
+ # unrelated profile line that merely contains the path does not suppress
+ # the update, and a genuine duplicate is not appended.
+ if ! [ -f "$profile" ] || ! grep -Fx -e "$path_line" "$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'