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. +

+ Test status + Installer test status + Release status +

-## 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 ``` -image +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'