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
28 changes: 27 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,16 @@ jobs:
go-version-file: go.mod
cache-dependency-path: go.sum

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

- name: Test
run: go test ./...
run: |
go test ./...
sh ./scripts/test-npm.sh

- name: Vulnerability scan
run: |
Expand All @@ -41,6 +49,16 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}

- name: Save npm release assets
uses: actions/upload-artifact@v4
with:
name: npm-release-assets
path: |
dist/etherscan_*.tar.gz
dist/etherscan_*.zip
dist/checksums.txt
if-no-files-found: error

npm:
if: vars.NPM_PUBLISH_ENABLED == 'true'
needs: release
Expand All @@ -62,5 +80,13 @@ jobs:
registry-url: https://registry.npmjs.org
package-manager-cache: false

- name: Download npm release assets
uses: actions/download-artifact@v5
with:
name: npm-release-assets
path: dist

- name: Publish npm package
run: node npm/publish.js
env:
NPM_PROVENANCE: "true"
1 change: 0 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,6 @@ jobs:
uses: actions/setup-node@v6
with:
node-version: "24"
cache: npm

# Runs on every matrix OS. The Windows leg is the one that matters: it packs
# from a CRLF checkout, the combination no other job covered.
Expand Down
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ Or run it once without keeping a global installation:
npx @etherscan-npm/cli version
```

The npm package downloads the matching native release archive and verifies its SHA-256 checksum during installation. Lifecycle scripts must be enabled.
The npm package selects a platform-specific optional dependency containing the native binary. It does not run installation lifecycle scripts. Installing with `--omit=optional` is not supported.

> The package is currently published as `@etherscan-npm/cli` while the `@etherscan` npm scope is being transferred. The `etherscan` command is unchanged.

Expand Down Expand Up @@ -488,9 +488,20 @@ 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.
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 umbrella and current-platform packages with lifecycle scripts disabled; 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.
The first seven-package npm release requires a one-time bootstrap by an npm administrator with publish access to the `@etherscan-npm` scope. After the matching GitHub release succeeds, check out its exact tag and run:

```sh
gh release download v1.0.4 --dir dist
npm login
npm whoami
VERSION=1.0.4 node npm/publish.js
```

The publisher verifies all six archives against `checksums.txt`, creates the six public platform packages by publishing them first, and publishes `@etherscan-npm/cli` last. It safely skips exact versions that already exist so a partial publication can be retried. Replace `1.0.4` with the actual unused release version if necessary.

After the bootstrap, configure npm trusted publishing for the umbrella and all six platform packages, targeting `etherscan/etherscan-cli` and `.github/workflows/release.yml`. Set the `NPM_PUBLISH_ENABLED` repository variable to `true`; subsequent tagged releases publish through GitHub Actions with provenance.

## API coverage and support

Expand Down
6 changes: 3 additions & 3 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -672,15 +672,15 @@ func updateCommand(info BuildInfo, updates updateManager) *cobra.Command {
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/cli@latest")
fmt.Fprintln(cmd.OutOrStdout(), " npm install -g @etherscan-npm/cli@latest")
return nil
}
if method == "" {
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/cli@latest")
fmt.Fprintln(cmd.OutOrStdout(), " npm install -g @etherscan-npm/cli@latest")
return nil
}
fmt.Fprintf(cmd.OutOrStdout(), "Updating Etherscan CLI %s -> %s using %s...\n", result.Current, result.Latest, method)
Expand Down Expand Up @@ -724,7 +724,7 @@ func offerUpdate(ctx context.Context, updates updateManager, current string, in
method := updates.DetectMethod()
if method == updater.MethodNPM {
fmt.Fprintln(out, "This installation is managed by npm. Run:")
fmt.Fprintln(out, " npm install -g @etherscan/cli@latest")
fmt.Fprintln(out, " npm install -g @etherscan-npm/cli@latest")
return true, nil
}
fmt.Fprintf(out, "Updating with %s...\n", method)
Expand Down
6 changes: 3 additions & 3 deletions internal/cli/update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ func TestOfferUpdateChoices(t *testing.T) {
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/cli@latest") {
if !strings.Contains(out.String(), "npm install -g @etherscan-npm/cli@latest") {
t.Fatalf("output = %q, want npm install instruction", out.String())
}
})
Expand Down Expand Up @@ -135,7 +135,7 @@ func TestUpdateCommandShowsNPMInstruction(t *testing.T) {
if manager.upgradedVersion != "" {
t.Fatalf("npm update invoked updater: %+v", manager)
}
if !strings.Contains(out.String(), "npm install -g @etherscan/cli@latest") {
if !strings.Contains(out.String(), "npm install -g @etherscan-npm/cli@latest") {
t.Fatalf("output = %q, want npm install instruction", out.String())
}
}
Expand All @@ -156,7 +156,7 @@ func TestUpdateCommandCannotForceScriptForNPMInstallation(t *testing.T) {
if manager.upgradedVersion != "" {
t.Fatalf("npm update invoked forced script updater: %+v", manager)
}
if !strings.Contains(out.String(), "npm install -g @etherscan/cli@latest") {
if !strings.Contains(out.String(), "npm install -g @etherscan-npm/cli@latest") {
t.Fatalf("output = %q, want npm install instruction", out.String())
}
}
52 changes: 44 additions & 8 deletions internal/updater/uninstall_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,22 +36,25 @@ func TestNPMPackageNameWithoutMarkerFailsClosed(t *testing.T) {
}
}

type npmOwnershipCase struct {
name string
executable string
want string
ok bool
}

func TestNPMPackageFromExecutable(t *testing.T) {
t.Setenv("ETHERSCAN_INSTALL_METHOD", "")
tests := []struct {
name string
executable string
want string
ok bool
}{
tests := []npmOwnershipCase{
// Legacy <=1.0.3 layout: the binary was vendored inside the umbrella.
{
name: "canonical package",
name: "canonical package legacy vendor layout",
executable: filepath.Join("C:", "Users", "test", "node_modules", "@etherscan", "cli", "vendor", "etherscan.exe"),
want: NPMCanonicalPackage,
ok: true,
},
{
name: "transitional package",
name: "transitional package legacy vendor layout",
executable: filepath.Join("usr", "local", "lib", "node_modules", "@etherscan-npm", "cli", "vendor", "etherscan"),
want: NPMTransitionalPackage,
ok: true,
Expand All @@ -60,12 +63,45 @@ func TestNPMPackageFromExecutable(t *testing.T) {
name: "lookalike package",
executable: filepath.Join("usr", "local", "lib", "node_modules", "@etherscan", "cli-malicious", "vendor", "etherscan"),
},
{
name: "lookalike platform package",
executable: filepath.Join("usr", "local", "lib", "node_modules", "@etherscan-npm", "cli-linux-x64-malicious", "etherscan"),
},
{
name: "unrelated scoped package",
executable: filepath.Join("usr", "local", "lib", "node_modules", "@other", "cli", "etherscan"),
},
{
name: "manual install",
executable: filepath.Join("usr", "local", "bin", "etherscan"),
},
}

// Since 1.0.4 the binary ships in a platform package beside the umbrella. Every
// one of them must classify as npm-owned and report the umbrella, never itself.
for _, scope := range []struct {
directory string
umbrella string
}{
{directory: "@etherscan", umbrella: NPMCanonicalPackage},
{directory: "@etherscan-npm", umbrella: NPMTransitionalPackage},
} {
for _, platform := range []string{
"darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64", "win32-arm64", "win32-x64",
} {
binary := "etherscan"
if strings.HasPrefix(platform, "win32") {
binary = "etherscan.exe"
}
tests = append(tests, npmOwnershipCase{
name: scope.directory + "/cli-" + platform,
executable: filepath.Join("usr", "local", "lib", "node_modules", scope.directory, "cli-"+platform, binary),
want: scope.umbrella,
ok: true,
})
}
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, ok := npmPackageFromExecutable(test.executable)
Expand Down
29 changes: 25 additions & 4 deletions internal/updater/upgrade.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,20 @@ const (
NPMWrapperPIDEnv = "ETHERSCAN_NPM_WRAPPER_PID"
)

// npmPlatformSuffixes are the per-platform package names published alongside each
// umbrella. Since 1.0.4 the umbrella ships only the launcher, so a binary npm owns
// lives in one of these siblings. The empty suffix keeps <=1.0.3 installations,
// which vendored the binary inside the umbrella itself, detectable.
var npmPlatformSuffixes = []string{
"",
"-darwin-arm64",
"-darwin-x64",
"-linux-arm64",
"-linux-x64",
"-win32-arm64",
"-win32-x64",
}

var runtimeGOOS = runtime.GOOS

type commandRunner func(context.Context, string, []string, io.Writer, io.Writer, bool) error
Expand Down Expand Up @@ -81,11 +95,18 @@ func validateNPMPackageName(name string) (string, error) {
}
}

// npmPackageFromExecutable reports the umbrella package that owns executable. A
// platform package resolves to its umbrella so uninstall never targets a sibling,
// which npm removes with the umbrella anyway. Both surrounding slashes are
// required, which is what rejects lookalikes: "@etherscan/cli-malicious" and
// "@etherscan-npm/cli-linux-x64-malicious" cannot match a bare suffix.
func npmPackageFromExecutable(executable string) (string, bool) {
normalized := strings.ToLower(filepath.ToSlash(executable))
for _, candidate := range []string{NPMCanonicalPackage, NPMTransitionalPackage} {
if strings.Contains(normalized, "/node_modules/"+candidate+"/") {
return candidate, true
for _, umbrella := range []string{NPMCanonicalPackage, NPMTransitionalPackage} {
for _, suffix := range npmPlatformSuffixes {
if strings.Contains(normalized, "/node_modules/"+umbrella+suffix+"/") {
return umbrella, true
}
}
}
return "", false
Expand All @@ -106,7 +127,7 @@ func (s *Service) Upgrade(ctx context.Context, method, version string, stdout, s
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/cli@latest")
return false, fmt.Errorf("npm manages this installation; run npm install -g %s@latest", NPMTransitionalPackage)
}
if method == MethodHomebrew {
if _, err := s.lookPath()("brew"); err != nil {
Expand Down
9 changes: 8 additions & 1 deletion internal/updater/upgrade_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ func TestDetectMethod(t *testing.T) {
if got := service.DetectMethod(); got != MethodNPM {
t.Fatalf("DetectMethod() for transitional scope = %q, want %q", got, MethodNPM)
}
// The current layout: the binary lives in a platform package, not the umbrella.
service.Executable = func() (string, error) {
return filepath.Join(string(filepath.Separator), "usr", "lib", "node_modules", "@etherscan-npm", "cli-linux-x64", "etherscan"), nil
}
if got := service.DetectMethod(); got != MethodNPM {
t.Fatalf("DetectMethod() for platform package = %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 {
Expand All @@ -45,7 +52,7 @@ func TestDetectMethod(t *testing.T) {
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/cli@latest") {
if err == nil || !strings.Contains(err.Error(), "npm install -g @etherscan-npm/cli@latest") {
t.Fatalf("Upgrade() error = %v, want npm install instruction", err)
}
}
Expand Down
41 changes: 32 additions & 9 deletions npm/bin/etherscan.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,45 @@
"use strict";

const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const packageInfo = require("../../package.json");
const { PLATFORMS, platformPackage } = require("../platform");

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

// Resolve these once so the package lookup and executable name cannot disagree.
const platform = os.platform();
const arch = os.arch();
const binaryName = platform === "win32" ? "etherscan.exe" : "etherscan";
const reinstallHint = `Reinstall ${packageInfo.name} without --omit=optional.`;

function getExecutable() {
const packageName = platformPackage(platform, arch);
if (!packageName) {
console.error(
`Etherscan CLI does not support ${platform} ${arch}. ` +
`Supported platforms: ${Object.keys(PLATFORMS).join(", ")}.`,
);
process.exit(1);
}

try {
const manifest = require.resolve(`${packageName}/package.json`, {
paths: [packageRoot],
});
return path.join(path.dirname(manifest), binaryName);
} catch {
console.error(`The platform package ${packageName} is not installed. ${reinstallHint}`);
process.exit(1);
}
}

const executable = getExecutable();

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

Expand Down
Loading