Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 26 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,11 @@ on:
tags:
- "v*"

permissions:
contents: write

jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write

steps:
- name: Check out repository
Expand Down Expand Up @@ -41,3 +40,27 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}

npm:
if: vars.NPM_PUBLISH_ENABLED == 'true'
needs: release
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write

steps:
- name: Check out repository
uses: actions/checkout@v6
with:
persist-credentials: false

- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: "24"
registry-url: https://registry.npmjs.org
package-manager-cache: false

- name: Publish npm package
run: node npm/publish.js
36 changes: 36 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,39 @@ jobs:

- name: Validate snapshot release
run: goreleaser release --snapshot --clean --skip=publish

npm-package:
name: npm (${{ 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: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: "24"
cache: npm

- name: Test npm package on macOS/Linux
if: runner.os != 'Windows'
run: sh ./scripts/test-npm.sh

- name: Test npm package on Windows
if: runner.os == 'Windows'
shell: pwsh
run: ./scripts/test-npm.ps1
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Build output
/bin/
/dist/
/vendor/
*.tgz
*.exe
*.exe~
*.dll
Expand Down
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,23 @@ brew install etherscan/etherscan-cli/etherscan
etherscan version
```

### npm — macOS, Linux, and Windows

Install the CLI globally:

```sh
npm install -g @etherscan/etherscan
etherscan version
```

Or run it once without keeping a global installation:

```sh
npx @etherscan/etherscan version
```

The npm package downloads the matching native release archive and verifies its SHA-256 checksum during installation. Lifecycle scripts must be enabled.

### Installation script — macOS and Linux

```sh
Expand Down Expand Up @@ -376,11 +393,12 @@ Update through the same channel used to install the CLI:
| Installation channel | Update command |
| --- | --- |
| Homebrew | `brew upgrade etherscan/etherscan-cli/etherscan` |
| npm | `npm install -g @etherscan/etherscan@latest` |
| 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.
Use the channel-specific command above rather than mixing update mechanisms. For an npm-managed installation, `etherscan update` prints the npm command without modifying files under `node_modules`.

## Shell completion

Expand All @@ -407,6 +425,10 @@ 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.

The npm distribution can be checked with `sh scripts/test-npm.sh` on macOS/Linux or `./scripts/test-npm.ps1` in PowerShell. These tests pack and install the package against local fixture release archives; they do not publish to npm.

For the first npm release, publish the GitHub release assets before publishing the package. From an exact release-tag checkout, run `npm version --no-git-tag-version <version>` followed by `npm publish --access public`. Then configure npm trusted publishing for `etherscan/etherscan-cli` and `.github/workflows/release.yml`, and set the `NPM_PUBLISH_ENABLED` repository variable to `true` for later tagged releases.

## 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).
Expand Down
22 changes: 19 additions & 3 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -591,7 +591,7 @@ func updateCommand(info BuildInfo, updates updateManager) *cobra.Command {
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)
return fmt.Errorf("unsupported update method %q (use homebrew, npm, or script)", method)
}
result, err := updates.Check(cmd.Context(), info.Version, true)
if err != nil {
Expand All @@ -601,8 +601,19 @@ func updateCommand(info BuildInfo, updates updateManager) *cobra.Command {
fmt.Fprintf(cmd.OutOrStdout(), "Etherscan CLI %s is already up to date.\n", result.Current)
return nil
}
detectedMethod := updates.DetectMethod()
if detectedMethod == updater.MethodNPM {
fmt.Fprintln(cmd.OutOrStdout(), "This installation is managed by npm. Run:")
fmt.Fprintln(cmd.OutOrStdout(), " npm install -g @etherscan/etherscan@latest")
return nil
}
if method == "" {
method = updates.DetectMethod()
method = detectedMethod
}
if method == updater.MethodNPM {
fmt.Fprintln(cmd.OutOrStdout(), "This installation is managed by npm. Run:")
fmt.Fprintln(cmd.OutOrStdout(), " npm install -g @etherscan/etherscan@latest")
return nil
}
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())
Expand All @@ -617,7 +628,7 @@ func updateCommand(info BuildInfo, updates updateManager) *cobra.Command {
return nil
},
}
cmd.Flags().StringVar(&method, "method", "", "update method: homebrew or script")
cmd.Flags().StringVar(&method, "method", "", "update method: homebrew, npm, or script")
return cmd
}

Expand All @@ -643,6 +654,11 @@ func offerUpdate(ctx context.Context, updates updateManager, current string, in
switch strings.TrimSpace(choice) {
case "1":
method := updates.DetectMethod()
if method == updater.MethodNPM {
fmt.Fprintln(out, "This installation is managed by npm. Run:")
fmt.Fprintln(out, " npm install -g @etherscan/etherscan@latest")
return true, nil
}
fmt.Fprintf(out, "Updating with %s...\n", method)
background, err := updates.Upgrade(ctx, method, result.Latest, out, errOut)
if err != nil {
Expand Down
54 changes: 54 additions & 0 deletions internal/cli/update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,18 @@ func TestOfferUpdateChoices(t *testing.T) {
t.Fatalf("unexpected result: exit=%v err=%v manager=%+v", exit, err, manager)
}
})

t.Run("npm update instruction", func(t *testing.T) {
manager := &fakeUpdateManager{result: result, method: updater.MethodNPM}
var out bytes.Buffer
exit, err := offerUpdate(context.Background(), manager, "1.1.0", strings.NewReader("1\n"), &out, &bytes.Buffer{})
if err != nil || !exit || manager.upgradedVersion != "" {
t.Fatalf("unexpected result: exit=%v err=%v manager=%+v", exit, err, manager)
}
if !strings.Contains(out.String(), "npm install -g @etherscan/etherscan@latest") {
t.Fatalf("output = %q, want npm install instruction", out.String())
}
})
}

func TestUpdateCommandUsesRequestedMethod(t *testing.T) {
Expand All @@ -97,3 +109,45 @@ func TestUpdateCommandUsesRequestedMethod(t *testing.T) {
t.Fatalf("unexpected update: %+v", manager)
}
}

func TestUpdateCommandShowsNPMInstruction(t *testing.T) {
manager := &fakeUpdateManager{
result: updater.Result{Current: "1.1.0", Latest: "1.2.0", Checked: true, UpdateAvailable: true},
method: updater.MethodNPM,
}
root := newRootCommand(BuildInfo{Version: "1.1.0"}, manager)
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"update"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if manager.upgradedVersion != "" {
t.Fatalf("npm update invoked updater: %+v", manager)
}
if !strings.Contains(out.String(), "npm install -g @etherscan/etherscan@latest") {
t.Fatalf("output = %q, want npm install instruction", out.String())
}
}

func TestUpdateCommandCannotForceScriptForNPMInstallation(t *testing.T) {
manager := &fakeUpdateManager{
result: updater.Result{Current: "1.1.0", Latest: "1.2.0", Checked: true, UpdateAvailable: true},
method: updater.MethodNPM,
}
root := newRootCommand(BuildInfo{Version: "1.1.0"}, manager)
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"update", "--method", "script"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if manager.upgradedVersion != "" {
t.Fatalf("npm update invoked forced script updater: %+v", manager)
}
if !strings.Contains(out.String(), "npm install -g @etherscan/etherscan@latest") {
t.Fatalf("output = %q, want npm install instruction", out.String())
}
}
14 changes: 12 additions & 2 deletions internal/updater/upgrade.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (

const (
MethodHomebrew = "homebrew"
MethodNPM = "npm"
MethodScript = "script"
)

Expand All @@ -23,12 +24,18 @@ var runtimeGOOS = runtime.GOOS
type commandRunner func(context.Context, string, []string, io.Writer, io.Writer, bool) error

func (s *Service) DetectMethod() string {
if strings.EqualFold(os.Getenv("ETHERSCAN_INSTALL_METHOD"), MethodNPM) {
return MethodNPM
}
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, "/node_modules/@etherscan/etherscan/") {
return MethodNPM
}
if strings.Contains(normalized, "/cellar/etherscan/") || strings.Contains(normalized, "/linuxbrew/.linuxbrew/cellar/etherscan/") {
return MethodHomebrew
}
Expand All @@ -37,7 +44,7 @@ func (s *Service) DetectMethod() string {
}

func ValidMethod(method string) bool {
return method == MethodHomebrew || method == MethodScript
return method == MethodHomebrew || method == MethodNPM || method == MethodScript
}

// Upgrade installs a stable release. The returned background value is true on
Expand All @@ -52,7 +59,10 @@ func (s *Service) Upgrade(ctx context.Context, method, version string, stdout, s
method = s.DetectMethod()
}
if !ValidMethod(method) {
return false, fmt.Errorf("unsupported update method %q (use homebrew or script)", method)
return false, fmt.Errorf("unsupported update method %q (use homebrew, npm, or script)", method)
}
if method == MethodNPM {
return false, fmt.Errorf("npm manages this installation; run npm install -g @etherscan/etherscan@latest")
}
if method == MethodHomebrew {
if _, err := s.lookPath()("brew"); err != nil {
Expand Down
19 changes: 19 additions & 0 deletions internal/updater/upgrade_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,25 @@ func TestDetectMethod(t *testing.T) {
if got := service.DetectMethod(); got != MethodScript {
t.Fatalf("DetectMethod() = %q, want %q", got, MethodScript)
}
service.Executable = func() (string, error) {
return filepath.Join(string(filepath.Separator), "usr", "lib", "node_modules", "@etherscan", "etherscan", "vendor", "etherscan"), nil
}
if got := service.DetectMethod(); got != MethodNPM {
t.Fatalf("DetectMethod() = %q, want %q", got, MethodNPM)
}
t.Setenv("ETHERSCAN_INSTALL_METHOD", MethodNPM)
service.Executable = func() (string, error) { return filepath.Join(t.TempDir(), "etherscan"), nil }
if got := service.DetectMethod(); got != MethodNPM {
t.Fatalf("DetectMethod() with npm marker = %q, want %q", got, MethodNPM)
}
}

func TestNPMUpgradeReturnsPackageManagerInstruction(t *testing.T) {
service := NewService()
_, err := service.Upgrade(context.Background(), MethodNPM, "1.2.0", &bytes.Buffer{}, &bytes.Buffer{})
if err == nil || !strings.Contains(err.Error(), "npm install -g @etherscan/etherscan@latest") {
t.Fatalf("Upgrade() error = %v, want npm install instruction", err)
}
}

func TestScriptUpgradeDispatch(t *testing.T) {
Expand Down
42 changes: 42 additions & 0 deletions npm/bin/etherscan.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
#!/usr/bin/env node

"use strict";

const fs = require("node:fs");
const path = require("node:path");
const { spawnSync } = require("node:child_process");

const packageRoot = path.resolve(__dirname, "..", "..");
const executable = path.join(
packageRoot,
"vendor",
process.platform === "win32" ? "etherscan.exe" : "etherscan",
);

if (!fs.existsSync(executable)) {
console.error(
"Etherscan CLI is not installed in this npm package. " +
"Reinstall @etherscan/etherscan without --ignore-scripts.",
);
process.exit(1);
}

const result = spawnSync(executable, process.argv.slice(2), {
stdio: "inherit",
env: {
...process.env,
ETHERSCAN_INSTALL_METHOD: "npm",
},
});

if (result.error) {
console.error(`Failed to start Etherscan CLI: ${result.error.message}`);
process.exit(1);
}

if (result.signal) {
console.error(`Etherscan CLI terminated by signal ${result.signal}.`);
process.exit(1);
}

process.exit(result.status === null ? 1 : result.status);
Loading