@etherscan-npm/cli 1.0.3 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE CHANGED
File without changes
package/README.md CHANGED
@@ -52,7 +52,7 @@ Or run it once without keeping a global installation:
52
52
  npx @etherscan-npm/cli version
53
53
  ```
54
54
 
55
- The npm package downloads the matching native release archive and verifies its SHA-256 checksum during installation. Lifecycle scripts must be enabled.
55
+ 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.
56
56
 
57
57
  > The package is currently published as `@etherscan-npm/cli` while the `@etherscan` npm scope is being transferred. The `etherscan` command is unchanged.
58
58
 
@@ -488,9 +488,20 @@ go build -o etherscan ./cmd/etherscan
488
488
 
489
489
  Installer changes can be checked with `sh scripts/test-install.sh` on macOS/Linux or `./scripts/test-install.ps1` in PowerShell on Windows.
490
490
 
491
- 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.
491
+ 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.
492
492
 
493
- 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.
493
+ 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:
494
+
495
+ ```sh
496
+ gh release download v1.0.4 --dir dist
497
+ npm login
498
+ npm whoami
499
+ VERSION=1.0.4 node npm/publish.js
500
+ ```
501
+
502
+ 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.
503
+
504
+ 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.
494
505
 
495
506
  ## API coverage and support
496
507
 
@@ -3,22 +3,45 @@
3
3
  "use strict";
4
4
 
5
5
  const fs = require("node:fs");
6
+ const os = require("node:os");
6
7
  const path = require("node:path");
7
8
  const { spawnSync } = require("node:child_process");
8
9
  const packageInfo = require("../../package.json");
10
+ const { PLATFORMS, platformPackage } = require("../platform");
9
11
 
10
12
  const packageRoot = path.resolve(__dirname, "..", "..");
11
- const executable = path.join(
12
- packageRoot,
13
- "vendor",
14
- process.platform === "win32" ? "etherscan.exe" : "etherscan",
15
- );
13
+
14
+ // Resolve these once so the package lookup and executable name cannot disagree.
15
+ const platform = os.platform();
16
+ const arch = os.arch();
17
+ const binaryName = platform === "win32" ? "etherscan.exe" : "etherscan";
18
+ const reinstallHint = `Reinstall ${packageInfo.name} without --omit=optional.`;
19
+
20
+ function getExecutable() {
21
+ const packageName = platformPackage(platform, arch);
22
+ if (!packageName) {
23
+ console.error(
24
+ `Etherscan CLI does not support ${platform} ${arch}. ` +
25
+ `Supported platforms: ${Object.keys(PLATFORMS).join(", ")}.`,
26
+ );
27
+ process.exit(1);
28
+ }
29
+
30
+ try {
31
+ const manifest = require.resolve(`${packageName}/package.json`, {
32
+ paths: [packageRoot],
33
+ });
34
+ return path.join(path.dirname(manifest), binaryName);
35
+ } catch {
36
+ console.error(`The platform package ${packageName} is not installed. ${reinstallHint}`);
37
+ process.exit(1);
38
+ }
39
+ }
40
+
41
+ const executable = getExecutable();
16
42
 
17
43
  if (!fs.existsSync(executable)) {
18
- console.error(
19
- "Etherscan CLI is not installed in this npm package. " +
20
- "Reinstall @etherscan-npm/cli without --ignore-scripts.",
21
- );
44
+ console.error(`The platform package executable is missing: ${executable}. ${reinstallHint}`);
22
45
  process.exit(1);
23
46
  }
24
47
 
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+
3
+ const PLATFORMS = Object.freeze({
4
+ "darwin arm64": "@etherscan-npm/cli-darwin-arm64",
5
+ "darwin x64": "@etherscan-npm/cli-darwin-x64",
6
+ "linux arm64": "@etherscan-npm/cli-linux-arm64",
7
+ "linux x64": "@etherscan-npm/cli-linux-x64",
8
+ "win32 arm64": "@etherscan-npm/cli-win32-arm64",
9
+ "win32 x64": "@etherscan-npm/cli-win32-x64",
10
+ });
11
+
12
+ function platformPackage(platform, arch) {
13
+ return PLATFORMS[`${platform} ${arch}`] || null;
14
+ }
15
+
16
+ module.exports = { PLATFORMS, platformPackage };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@etherscan-npm/cli",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "Command-line client and interactive explorer for the Etherscan V2 API",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/etherscan/etherscan-cli",
@@ -16,7 +16,6 @@
16
16
  "etherscan": "npm/bin/etherscan.js"
17
17
  },
18
18
  "scripts": {
19
- "postinstall": "node npm/postinstall.js",
20
19
  "prepublishOnly": "node npm/prepublish-check.js",
21
20
  "test:package": "npm pack --dry-run",
22
21
  "test:release": "node npm/publish.test.js",
@@ -24,13 +23,18 @@
24
23
  },
25
24
  "files": [
26
25
  "npm/bin/etherscan.js",
27
- "npm/postinstall.js",
28
- "npm/prepublish-check.js",
29
- "scripts/install.sh",
30
- "scripts/install.ps1",
26
+ "npm/platform.js",
31
27
  "README.md",
32
28
  "LICENSE"
33
29
  ],
30
+ "optionalDependencies": {
31
+ "@etherscan-npm/cli-darwin-arm64": "1.0.5",
32
+ "@etherscan-npm/cli-darwin-x64": "1.0.5",
33
+ "@etherscan-npm/cli-linux-arm64": "1.0.5",
34
+ "@etherscan-npm/cli-linux-x64": "1.0.5",
35
+ "@etherscan-npm/cli-win32-arm64": "1.0.5",
36
+ "@etherscan-npm/cli-win32-x64": "1.0.5"
37
+ },
34
38
  "engines": {
35
39
  "node": ">=18"
36
40
  },
@@ -1,58 +0,0 @@
1
- "use strict";
2
-
3
- const fs = require("node:fs");
4
- const path = require("node:path");
5
- const { spawnSync } = require("node:child_process");
6
- const packageInfo = require("../package.json");
7
-
8
- const packageRoot = path.resolve(__dirname, "..");
9
- const installDir = path.join(packageRoot, "vendor");
10
- const version = `v${packageInfo.version}`;
11
-
12
- let command;
13
- let args;
14
- if (process.platform === "win32") {
15
- command = "powershell.exe";
16
- args = [
17
- "-NoLogo",
18
- "-NoProfile",
19
- "-NonInteractive",
20
- "-ExecutionPolicy",
21
- "Bypass",
22
- "-File",
23
- path.join(packageRoot, "scripts", "install.ps1"),
24
- "-Version",
25
- version,
26
- "-InstallDir",
27
- installDir,
28
- "-NoPathUpdate",
29
- ];
30
- } else {
31
- command = "sh";
32
- args = [
33
- path.join(packageRoot, "scripts", "install.sh"),
34
- "--version",
35
- version,
36
- "--install-dir",
37
- installDir,
38
- "--no-path-update",
39
- ];
40
- }
41
-
42
- const result = spawnSync(command, args, { stdio: "inherit", env: process.env });
43
- if (result.error) {
44
- console.error(`Unable to run the Etherscan CLI installer: ${result.error.message}`);
45
- process.exit(1);
46
- }
47
- if (result.status !== 0) {
48
- process.exit(result.status === null ? 1 : result.status);
49
- }
50
-
51
- const executable = path.join(
52
- installDir,
53
- process.platform === "win32" ? "etherscan.exe" : "etherscan",
54
- );
55
- if (!fs.existsSync(executable)) {
56
- console.error(`The installer did not create ${executable}.`);
57
- process.exit(1);
58
- }
@@ -1,81 +0,0 @@
1
- "use strict";
2
-
3
- const fs = require("node:fs");
4
- const path = require("node:path");
5
- const packageInfo = require("../package.json");
6
-
7
- const versionPattern =
8
- /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
9
-
10
- // npm treats these as glob metacharacters in package.json "files".
11
- const globPattern = /[*?[\]{}]/;
12
-
13
- function checkVersion(version) {
14
- if (version === "0.0.0-development") {
15
- throw new Error("the development placeholder version cannot be published");
16
- }
17
- if (!versionPattern.test(version)) {
18
- throw new Error(`invalid release version: ${version}`);
19
- }
20
- }
21
-
22
- // `npm pack` copies the working tree, not the committed blobs. On Windows a
23
- // checkout with core.autocrlf=true turns the LF blobs into CRLF, and a CRLF
24
- // scripts/install.sh cannot be run by /bin/sh on Linux or macOS, which is how
25
- // @etherscan-npm/cli@1.0.1 shipped an installer that failed on both.
26
- function checkFileLineEndings(filePath, label) {
27
- const contents = fs.readFileSync(filePath);
28
- // Skip binaries using the same NUL-byte heuristic git applies for text=auto.
29
- if (contents.includes(0x00)) {
30
- return;
31
- }
32
- if (contents.includes(0x0d)) {
33
- throw new Error(`${label} contains CRLF line endings`);
34
- }
35
- }
36
-
37
- // Checks the literal entries of package.json "files". This fails closed: npm
38
- // also accepts globs and directories there, and silently skipping what it cannot
39
- // resolve would let a future "files" edit disable the gate while leaving the
40
- // publish green — the same silent-pass shape that let 1.0.1 ship.
41
- function checkLineEndings(baseDir, files = packageInfo.files) {
42
- for (const entry of files) {
43
- if (globPattern.test(entry)) {
44
- throw new Error(
45
- `${entry} is a glob pattern; this check only understands literal file paths, so update it before publishing`,
46
- );
47
- }
48
- const filePath = path.join(baseDir, entry);
49
- let stats;
50
- try {
51
- stats = fs.statSync(filePath);
52
- } catch (error) {
53
- if (error.code === "ENOENT") {
54
- throw new Error(`${entry} is listed in package.json "files" but does not exist`);
55
- }
56
- throw error;
57
- }
58
- if (stats.isDirectory()) {
59
- throw new Error(
60
- `${entry} is a directory; this check only understands literal file paths, so update it before publishing`,
61
- );
62
- }
63
- checkFileLineEndings(filePath, entry);
64
- }
65
- }
66
-
67
- function main() {
68
- checkVersion(packageInfo.version);
69
- checkLineEndings(path.resolve(__dirname, ".."));
70
- }
71
-
72
- module.exports = { checkVersion, checkFileLineEndings, checkLineEndings };
73
-
74
- if (require.main === module) {
75
- try {
76
- main();
77
- } catch (error) {
78
- console.error(`Refusing to publish @etherscan/cli: ${error.message}`);
79
- process.exit(1);
80
- }
81
- }
@@ -1,362 +0,0 @@
1
- [CmdletBinding()]
2
- param(
3
- [string]$Version = $env:ETHERSCAN_VERSION,
4
- [string]$InstallDir = $env:ETHERSCAN_INSTALL_DIR,
5
- [switch]$NoPathUpdate,
6
- [int]$WaitForProcessId = 0,
7
- [switch]$CleanupScript,
8
- [switch]$Uninstall
9
- )
10
-
11
- $ErrorActionPreference = "Stop"
12
- $ProgressPreference = "SilentlyContinue"
13
-
14
- $Repository = "etherscan/etherscan-cli"
15
- $DownloadBaseUrl = $env:ETHERSCAN_INSTALL_TEST_DOWNLOAD_BASE_URL
16
- $InstallMarkerName = ".etherscan-cli-path-added"
17
- $InstallMarkerContent = "etherscan-cli:path-added:v1"
18
-
19
- function Get-EtherscanArchitecture {
20
- $architecture = $env:PROCESSOR_ARCHITEW6432
21
- if ([string]::IsNullOrWhiteSpace($architecture)) {
22
- $architecture = $env:PROCESSOR_ARCHITECTURE
23
- }
24
-
25
- switch -Regex ($architecture) {
26
- "^(AMD64|x86_64)$" { return "amd64" }
27
- "^(ARM64|aarch64)$" { return "arm64" }
28
- default { throw "Unsupported Windows architecture: $architecture. Etherscan CLI supports amd64 and arm64." }
29
- }
30
- }
31
-
32
- function Get-GitHubApiHeaders {
33
- $headers = @{
34
- Accept = "application/vnd.github+json"
35
- "User-Agent" = "etherscan-cli-installer"
36
- "X-GitHub-Api-Version" = "2022-11-28"
37
- }
38
- if (-not [string]::IsNullOrWhiteSpace($env:ETHERSCAN_GITHUB_TOKEN)) {
39
- $headers.Authorization = "Bearer $($env:ETHERSCAN_GITHUB_TOKEN)"
40
- }
41
- return $headers
42
- }
43
-
44
- function Resolve-EtherscanVersion {
45
- param([string]$RequestedVersion)
46
-
47
- if (-not [string]::IsNullOrWhiteSpace($RequestedVersion) -and $RequestedVersion -ne "latest") {
48
- $tag = if ($RequestedVersion.StartsWith("v")) { $RequestedVersion } else { "v$RequestedVersion" }
49
- }
50
- else {
51
- if (-not [string]::IsNullOrWhiteSpace($DownloadBaseUrl)) {
52
- throw "A version is required when the installer test download source is used."
53
- }
54
- $release = Invoke-RestMethod `
55
- -Uri "https://api.github.com/repos/$Repository/releases/latest" `
56
- -Headers (Get-GitHubApiHeaders)
57
- $tag = [string]$release.tag_name
58
- }
59
-
60
- if ($tag -notmatch '^v[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$') {
61
- throw "Invalid release version: $tag"
62
- }
63
-
64
- return @{
65
- Tag = $tag
66
- Version = $tag.Substring(1)
67
- }
68
- }
69
-
70
- function Copy-InstallerFile {
71
- param(
72
- [string]$Base,
73
- [string]$Name,
74
- [string]$Destination
75
- )
76
-
77
- if (Test-Path -LiteralPath $Base -PathType Container) {
78
- Copy-Item -LiteralPath (Join-Path $Base $Name) -Destination $Destination
79
- return
80
- }
81
-
82
- $uri = "$($Base.TrimEnd('/'))/$Name"
83
- $parsedUri = [Uri]$uri
84
- if ($parsedUri.Scheme -ne "https") {
85
- throw "Remote downloads must use HTTPS: $uri"
86
- }
87
-
88
- $headers = @{
89
- "User-Agent" = "etherscan-cli-installer"
90
- }
91
- if ($parsedUri.Host -in @("github.com", "api.github.com") -and
92
- -not [string]::IsNullOrWhiteSpace($env:ETHERSCAN_GITHUB_TOKEN)) {
93
- $headers.Authorization = "Bearer $($env:ETHERSCAN_GITHUB_TOKEN)"
94
- }
95
-
96
- Invoke-WebRequest -Uri $uri -OutFile $Destination -Headers $headers -UseBasicParsing
97
- }
98
-
99
- function Get-SHA256FileHash {
100
- param([string]$Path)
101
-
102
- $sha256 = [Security.Cryptography.SHA256]::Create()
103
- try {
104
- $stream = [IO.File]::OpenRead($Path)
105
- try {
106
- return ([BitConverter]::ToString($sha256.ComputeHash($stream))).Replace("-", "").ToLowerInvariant()
107
- }
108
- finally {
109
- $stream.Dispose()
110
- }
111
- }
112
- finally {
113
- $sha256.Dispose()
114
- }
115
- }
116
-
117
- function Add-EtherscanToUserPath {
118
- param([string]$Directory)
119
-
120
- $fullDirectory = [IO.Path]::GetFullPath($Directory).TrimEnd('\')
121
- $userPath = [Environment]::GetEnvironmentVariable("Path", "User")
122
- $entries = @($userPath -split ';' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
123
- $alreadyPresent = $entries | Where-Object {
124
- $entry = $_
125
- try {
126
- $expandedEntry = [Environment]::ExpandEnvironmentVariables($entry)
127
- [IO.Path]::GetFullPath($expandedEntry).TrimEnd('\').Equals($fullDirectory, [StringComparison]::OrdinalIgnoreCase)
128
- }
129
- catch {
130
- $entry.TrimEnd('\').Equals($fullDirectory, [StringComparison]::OrdinalIgnoreCase)
131
- }
132
- }
133
-
134
- if (-not $alreadyPresent) {
135
- $newUserPath = if ([string]::IsNullOrWhiteSpace($userPath)) {
136
- $fullDirectory
137
- }
138
- else {
139
- "$($userPath.TrimEnd(';'));$fullDirectory"
140
- }
141
- [Environment]::SetEnvironmentVariable("Path", $newUserPath, "User")
142
- Write-Host "Added $fullDirectory to your user PATH."
143
- $pathAdded = $true
144
- }
145
- else {
146
- $pathAdded = $false
147
- }
148
-
149
- $processEntries = @($env:Path -split ';')
150
- if (-not ($processEntries | Where-Object { $_.TrimEnd('\').Equals($fullDirectory, [StringComparison]::OrdinalIgnoreCase) })) {
151
- $env:Path = "$env:Path;$fullDirectory"
152
- }
153
- return $pathAdded
154
- }
155
-
156
- function Remove-EtherscanFromUserPath {
157
- param([string]$Directory)
158
-
159
- $fullDirectory = [IO.Path]::GetFullPath($Directory).TrimEnd('\')
160
- $entries = @([Environment]::GetEnvironmentVariable("Path", "User") -split ';' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
161
- $kept = @($entries | Where-Object {
162
- $entry = $_
163
- try {
164
- $expandedEntry = [Environment]::ExpandEnvironmentVariables($entry)
165
- -not [IO.Path]::GetFullPath($expandedEntry).TrimEnd('\').Equals($fullDirectory, [StringComparison]::OrdinalIgnoreCase)
166
- }
167
- catch {
168
- -not $entry.TrimEnd('\').Equals($fullDirectory, [StringComparison]::OrdinalIgnoreCase)
169
- }
170
- })
171
- if ($kept.Count -ne $entries.Count) {
172
- [Environment]::SetEnvironmentVariable("Path", ($kept -join ';'), "User")
173
- Write-Host "Removed $fullDirectory from your user PATH."
174
- }
175
- }
176
-
177
- function Test-EtherscanInstallMarker {
178
- param([string]$Path)
179
- if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $false }
180
- $item = Get-Item -LiteralPath $Path -Force
181
- if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { return $false }
182
- return (Get-Content -LiteralPath $Path -Raw) -eq $InstallMarkerContent
183
- }
184
-
185
- function Get-EtherscanConfigDirectory {
186
- if (-not [string]::IsNullOrWhiteSpace($env:XDG_CONFIG_HOME)) {
187
- return Join-Path $env:XDG_CONFIG_HOME "etherscan"
188
- }
189
- return Join-Path $env:USERPROFILE ".etherscan"
190
- }
191
-
192
- if ($env:OS -ne "Windows_NT") {
193
- throw "This installer supports Windows only. Use install.sh on macOS or Linux."
194
- }
195
-
196
- if ([string]::IsNullOrWhiteSpace($InstallDir)) {
197
- $localAppData = [Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)
198
- $InstallDir = Join-Path $localAppData "Programs\Etherscan\bin"
199
- }
200
- if ($InstallDir.Contains(';')) {
201
- throw "The installation directory cannot contain a semicolon."
202
- }
203
- if ($InstallDir.IndexOfAny([char[]]"`r`n") -ge 0) {
204
- throw "The installation directory cannot contain a line break."
205
- }
206
- if ($WaitForProcessId -gt 0) {
207
- Wait-Process -Id $WaitForProcessId -ErrorAction SilentlyContinue
208
- }
209
-
210
- if ($Uninstall) {
211
- try {
212
- $targetExecutable = Join-Path $InstallDir "etherscan.exe"
213
- $marker = Join-Path $InstallDir $InstallMarkerName
214
- $validMarker = Test-EtherscanInstallMarker -Path $marker
215
- $localAppData = [Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)
216
- $defaultInstallDir = Join-Path $localAppData "Programs\Etherscan\bin"
217
- $legacyDefault = [IO.Path]::GetFullPath($InstallDir).TrimEnd('\').Equals(
218
- [IO.Path]::GetFullPath($defaultInstallDir).TrimEnd('\'), [StringComparison]::OrdinalIgnoreCase)
219
- $removed = $false
220
-
221
- if (Test-Path -LiteralPath $targetExecutable) {
222
- Remove-Item -LiteralPath $targetExecutable -Force
223
- Write-Host "Removed $targetExecutable"
224
- $removed = $true
225
- }
226
-
227
- $otherEntries = @()
228
- if (Test-Path -LiteralPath $InstallDir -PathType Container) {
229
- $otherEntries = @(Get-ChildItem -LiteralPath $InstallDir -Force | Where-Object {
230
- -not ($validMarker -and $_.FullName -eq $marker)
231
- } | Select-Object -First 1)
232
- }
233
- if (-not $NoPathUpdate -and ($validMarker -or $legacyDefault) -and $otherEntries.Count -eq 0) {
234
- Remove-EtherscanFromUserPath -Directory $InstallDir
235
- if ($validMarker) { Remove-Item -LiteralPath $marker -Force }
236
- Remove-Item -LiteralPath $InstallDir -Force -ErrorAction SilentlyContinue
237
- $removed = $true
238
- }
239
- elseif (-not $NoPathUpdate -and (Test-Path -LiteralPath $InstallDir)) {
240
- Write-Host "Left $InstallDir on PATH because ownership was not proven or the directory is shared."
241
- }
242
-
243
- $configDirectory = Get-EtherscanConfigDirectory
244
- if (Test-Path -LiteralPath $configDirectory) {
245
- Remove-Item -LiteralPath $configDirectory -Recurse -Force
246
- Write-Host "Removed $configDirectory"
247
- $removed = $true
248
- }
249
- if ($removed) { Write-Host "Etherscan CLI uninstalled." } else { Write-Host "Nothing to remove." }
250
- if (-not [string]::IsNullOrWhiteSpace($env:ETHERSCAN_API_KEY)) {
251
- Write-Warning "ETHERSCAN_API_KEY remains set; unset it in your shell."
252
- }
253
- }
254
- finally {
255
- if ($CleanupScript -and -not [string]::IsNullOrWhiteSpace($PSCommandPath)) {
256
- Remove-Item -LiteralPath $PSCommandPath -Force -ErrorAction SilentlyContinue
257
- }
258
- }
259
- return
260
- }
261
-
262
- $resolved = Resolve-EtherscanVersion -RequestedVersion $Version
263
- $architecture = Get-EtherscanArchitecture
264
- $archiveName = "etherscan_$($resolved.Version)_windows_$architecture.zip"
265
- $baseUrl = if ([string]::IsNullOrWhiteSpace($DownloadBaseUrl)) {
266
- "https://github.com/$Repository/releases/download/$($resolved.Tag)"
267
- }
268
- else {
269
- $DownloadBaseUrl
270
- }
271
-
272
- $tempDirectory = Join-Path ([IO.Path]::GetTempPath()) "etherscan-install-$PID-$([Guid]::NewGuid().ToString('N'))"
273
- $archivePath = Join-Path $tempDirectory $archiveName
274
- $checksumPath = Join-Path $tempDirectory "checksums.txt"
275
- $sourceExecutable = Join-Path $tempDirectory "etherscan.exe"
276
-
277
- try {
278
- New-Item -ItemType Directory -Path $tempDirectory -Force | Out-Null
279
-
280
- Write-Host "Downloading Etherscan CLI $($resolved.Version) for windows/$architecture..."
281
- Copy-InstallerFile -Base $baseUrl -Name $archiveName -Destination $archivePath
282
- Copy-InstallerFile -Base $baseUrl -Name "checksums.txt" -Destination $checksumPath
283
-
284
- $pattern = '^([0-9A-Fa-f]{64})\s+\*?' + [Regex]::Escape($archiveName) + '$'
285
- $checksumLine = Get-Content -LiteralPath $checksumPath | Where-Object { $_ -match $pattern } | Select-Object -First 1
286
- if (-not $checksumLine -or $checksumLine -notmatch $pattern) {
287
- throw "No checksum was published for $archiveName."
288
- }
289
-
290
- $expectedHash = $Matches[1].ToLowerInvariant()
291
- $actualHash = Get-SHA256FileHash -Path $archivePath
292
- if ($actualHash -ne $expectedHash) {
293
- throw "Checksum verification failed for $archiveName. Expected $expectedHash, received $actualHash."
294
- }
295
-
296
- Add-Type -AssemblyName System.IO.Compression.FileSystem
297
- $zip = [IO.Compression.ZipFile]::OpenRead($archivePath)
298
- try {
299
- $executableEntries = @($zip.Entries | Where-Object { $_.FullName.Replace('\', '/') -eq "etherscan.exe" })
300
- if ($executableEntries.Count -ne 1) {
301
- throw "$archiveName must contain exactly one root-level etherscan.exe."
302
- }
303
-
304
- $inputStream = $executableEntries[0].Open()
305
- $outputStream = [IO.File]::Create($sourceExecutable)
306
- try {
307
- $inputStream.CopyTo($outputStream)
308
- }
309
- finally {
310
- $outputStream.Dispose()
311
- $inputStream.Dispose()
312
- }
313
- }
314
- finally {
315
- $zip.Dispose()
316
- }
317
-
318
- New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
319
- $targetExecutable = Join-Path $InstallDir "etherscan.exe"
320
- $stagedExecutable = Join-Path $InstallDir ".etherscan.exe.new-$PID"
321
- $backupExecutable = Join-Path $InstallDir ".etherscan.exe.old-$PID"
322
- Copy-Item -LiteralPath $sourceExecutable -Destination $stagedExecutable -Force
323
-
324
- try {
325
- if (Test-Path -LiteralPath $targetExecutable) {
326
- Move-Item -LiteralPath $targetExecutable -Destination $backupExecutable -Force
327
- }
328
- Move-Item -LiteralPath $stagedExecutable -Destination $targetExecutable -Force
329
- Remove-Item -LiteralPath $backupExecutable -Force -ErrorAction SilentlyContinue
330
- }
331
- catch {
332
- Remove-Item -LiteralPath $stagedExecutable -Force -ErrorAction SilentlyContinue
333
- if ((Test-Path -LiteralPath $backupExecutable) -and -not (Test-Path -LiteralPath $targetExecutable)) {
334
- Move-Item -LiteralPath $backupExecutable -Destination $targetExecutable -Force
335
- }
336
- throw
337
- }
338
-
339
- if (-not $NoPathUpdate) {
340
- $pathAdded = Add-EtherscanToUserPath -Directory $InstallDir
341
- if ($pathAdded) {
342
- Set-Content -LiteralPath (Join-Path $InstallDir $InstallMarkerName) -Value $InstallMarkerContent -NoNewline
343
- }
344
- }
345
-
346
- Write-Host ""
347
- Write-Host "Etherscan CLI $($resolved.Version) installed successfully."
348
- Write-Host "Installed to: $targetExecutable"
349
- if ($NoPathUpdate) {
350
- Write-Host "Add $InstallDir to PATH to run etherscan from any directory."
351
- }
352
- else {
353
- Write-Host "Run 'etherscan version' to verify the installation."
354
- Write-Host "Open a new terminal if the command is not yet available."
355
- }
356
- }
357
- finally {
358
- Remove-Item -LiteralPath $tempDirectory -Recurse -Force -ErrorAction SilentlyContinue
359
- if ($CleanupScript -and -not [string]::IsNullOrWhiteSpace($PSCommandPath)) {
360
- Remove-Item -LiteralPath $PSCommandPath -Force -ErrorAction SilentlyContinue
361
- }
362
- }
@@ -1,340 +0,0 @@
1
- #!/bin/sh
2
-
3
- set -eu
4
-
5
- repository="etherscan/etherscan-cli"
6
- version=${ETHERSCAN_VERSION:-}
7
- install_dir=${ETHERSCAN_INSTALL_DIR:-"$HOME/.local/bin"}
8
- download_base=${ETHERSCAN_INSTALL_TEST_DOWNLOAD_BASE_URL:-}
9
- update_path=1
10
- uninstall=0
11
- marker_name=.etherscan-cli-path-added
12
- marker_content=etherscan-cli:path-added:v1
13
-
14
- usage() {
15
- cat <<'EOF'
16
- Install Etherscan CLI.
17
-
18
- Usage: install.sh [options]
19
-
20
- Options:
21
- --version VERSION Install a specific version (for example, v1.1.0).
22
- --install-dir DIRECTORY Install into DIRECTORY (default: ~/.local/bin).
23
- --no-path-update Do not update the shell profile.
24
- --uninstall Remove the CLI and saved configuration.
25
- -h, --help Show this help.
26
- EOF
27
- }
28
-
29
- die() {
30
- printf 'error: %s\n' "$*" >&2
31
- exit 1
32
- }
33
-
34
- while [ "$#" -gt 0 ]; do
35
- case "$1" in
36
- --version)
37
- [ "$#" -ge 2 ] || die "--version requires a value"
38
- version=$2
39
- shift 2
40
- ;;
41
- --install-dir)
42
- [ "$#" -ge 2 ] || die "--install-dir requires a value"
43
- install_dir=$2
44
- shift 2
45
- ;;
46
- --no-path-update)
47
- update_path=0
48
- shift
49
- ;;
50
- --uninstall)
51
- uninstall=1
52
- shift
53
- ;;
54
- -h|--help)
55
- usage
56
- exit 0
57
- ;;
58
- *)
59
- die "unknown option: $1"
60
- ;;
61
- esac
62
- done
63
-
64
- if printf '%s' "$install_dir" | LC_ALL=C grep '[[:cntrl:]]' >/dev/null 2>&1; then
65
- die "the installation directory cannot contain control characters"
66
- fi
67
-
68
- config_dir() {
69
- if [ -n "${XDG_CONFIG_HOME:-}" ]; then
70
- printf '%s\n' "$XDG_CONFIG_HOME/etherscan"
71
- else
72
- printf '%s\n' "$HOME/.etherscan"
73
- fi
74
- }
75
-
76
- profile_has_block() {
77
- profile=$1
78
- target=$2
79
- [ -f "$profile" ] && [ ! -L "$profile" ] || return 1
80
- ETHERSCAN_PROFILE_TARGET=$target awk '
81
- BEGIN { target = ENVIRON["ETHERSCAN_PROFILE_TARGET"] }
82
- previous == "# Etherscan CLI" && $0 == target { found = 1; exit }
83
- { previous = $0 }
84
- END { exit found ? 0 : 1 }
85
- ' "$profile"
86
- }
87
-
88
- remove_profile_block() {
89
- profile=$1
90
- target=$2
91
- profile_has_block "$profile" "$target" || return 0
92
- tmp=$(mktemp "${profile}.etherscan-uninstall.XXXXXX") || die "could not create a profile temporary file"
93
- if ! cp -p "$profile" "$tmp"; then
94
- rm -f "$tmp"
95
- die "could not preserve permissions for $profile"
96
- fi
97
- if ! ETHERSCAN_PROFILE_TARGET=$target awk '
98
- BEGIN { target = ENVIRON["ETHERSCAN_PROFILE_TARGET"] }
99
- {
100
- if (pending) {
101
- if ($0 == target) { pending = 0; next }
102
- print "# Etherscan CLI"
103
- pending = 0
104
- }
105
- if ($0 == "# Etherscan CLI") { pending = 1; next }
106
- print
107
- }
108
- END { if (pending) print "# Etherscan CLI" }
109
- ' "$profile" >"$tmp"; then
110
- rm -f "$tmp"
111
- die "could not update $profile"
112
- fi
113
- mv -f "$tmp" "$profile"
114
- printf 'Removed the PATH entry from %s\n' "$profile"
115
- }
116
-
117
- directory_empty_except_marker() {
118
- directory=$1
119
- marker=$2
120
- [ -d "$directory" ] || return 0
121
- for entry in "$directory"/.[!.]* "$directory"/..?* "$directory"/*; do
122
- [ -e "$entry" ] || [ -L "$entry" ] || continue
123
- [ "$entry" = "$marker" ] && continue
124
- return 1
125
- done
126
- return 0
127
- }
128
-
129
- if [ "$uninstall" -eq 1 ]; then
130
- binary="$install_dir/etherscan"
131
- marker="$install_dir/$marker_name"
132
- marker_valid=0
133
- if [ -f "$marker" ] && [ ! -L "$marker" ] && [ "$(cat "$marker")" = "$marker_content" ]; then
134
- marker_valid=1
135
- fi
136
-
137
- escaped_install_dir=$(printf '%s' "$install_dir" | sed 's/[\\"$`]/\\&/g')
138
- posix_path_line="export PATH=\"$escaped_install_dir:\$PATH\""
139
- fish_path_line="fish_add_path \"$escaped_install_dir\""
140
- legacy_provenance=0
141
- for candidate in "$HOME/.zshrc" "$HOME/.bashrc" "$HOME/.profile"; do
142
- if profile_has_block "$candidate" "$posix_path_line"; then
143
- legacy_provenance=1
144
- fi
145
- done
146
- if profile_has_block "$HOME/.config/fish/config.fish" "$fish_path_line"; then
147
- legacy_provenance=1
148
- fi
149
-
150
- removed=0
151
- if [ -e "$binary" ] || [ -L "$binary" ]; then
152
- rm -f "$binary"
153
- printf 'Removed %s\n' "$binary"
154
- removed=1
155
- fi
156
-
157
- if [ "$update_path" -eq 1 ] && { [ "$marker_valid" -eq 1 ] || [ "$legacy_provenance" -eq 1 ]; } && directory_empty_except_marker "$install_dir" "$marker"; then
158
- for candidate in "$HOME/.zshrc" "$HOME/.bashrc" "$HOME/.profile"; do
159
- remove_profile_block "$candidate" "$posix_path_line"
160
- done
161
- remove_profile_block "$HOME/.config/fish/config.fish" "$fish_path_line"
162
- if [ "$marker_valid" -eq 1 ]; then
163
- rm -f "$marker"
164
- fi
165
- rmdir "$install_dir" 2>/dev/null || true
166
- removed=1
167
- elif [ "$update_path" -eq 1 ] && [ -d "$install_dir" ]; then
168
- printf 'Left %s on PATH because ownership was not proven or the directory is shared.\n' "$install_dir"
169
- fi
170
-
171
- etherscan_config=$(config_dir)
172
- if [ -e "$etherscan_config" ] || [ -L "$etherscan_config" ]; then
173
- rm -rf "$etherscan_config"
174
- printf 'Removed %s\n' "$etherscan_config"
175
- removed=1
176
- fi
177
- if [ "$removed" -eq 1 ]; then
178
- printf 'Etherscan CLI uninstalled.\n'
179
- else
180
- printf 'Nothing to remove.\n'
181
- fi
182
- if [ -n "${ETHERSCAN_API_KEY:-}" ]; then
183
- printf 'note: ETHERSCAN_API_KEY remains set; unset it in your shell.\n' >&2
184
- fi
185
- exit 0
186
- fi
187
-
188
- fetch_stdout() {
189
- url=$1
190
- if command -v curl >/dev/null 2>&1; then
191
- curl -fsSL -A etherscan-cli-installer "$url"
192
- elif command -v wget >/dev/null 2>&1; then
193
- wget -qO- --user-agent=etherscan-cli-installer "$url"
194
- else
195
- die "curl or wget is required"
196
- fi
197
- }
198
-
199
- fetch_file() {
200
- base=$1
201
- name=$2
202
- destination=$3
203
-
204
- if [ -d "$base" ]; then
205
- cp "$base/$name" "$destination"
206
- return
207
- fi
208
-
209
- case "$base" in
210
- file://*)
211
- cp "${base#file://}/$name" "$destination"
212
- ;;
213
- https://*)
214
- if command -v curl >/dev/null 2>&1; then
215
- curl -fsSL -A etherscan-cli-installer "$base/$name" -o "$destination"
216
- elif command -v wget >/dev/null 2>&1; then
217
- wget -q --user-agent=etherscan-cli-installer "$base/$name" -O "$destination"
218
- else
219
- die "curl or wget is required"
220
- fi
221
- ;;
222
- *)
223
- die "invalid download base URL or directory: $base"
224
- ;;
225
- esac
226
- }
227
-
228
- if [ -z "$version" ] || [ "$version" = latest ]; then
229
- [ -z "$download_base" ] || die "a version is required with the installer test download source"
230
- release_json=$(fetch_stdout "https://api.github.com/repos/$repository/releases/latest")
231
- version=$(printf '%s\n' "$release_json" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | sed -n '1p')
232
- [ -n "$version" ] || die "could not resolve the latest Etherscan CLI version"
233
- fi
234
-
235
- case "$version" in
236
- v*) tag=$version; release_version=${version#v} ;;
237
- *) tag="v$version"; release_version=$version ;;
238
- esac
239
-
240
- printf '%s\n' "$tag" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$' || die "invalid release version: $tag"
241
-
242
- system_name=${ETHERSCAN_INSTALL_TEST_OS:-$(uname -s)}
243
- case "$system_name" in
244
- Linux|linux) os=linux ;;
245
- Darwin|darwin) os=darwin ;;
246
- *) die "unsupported operating system: $system_name" ;;
247
- esac
248
-
249
- machine_arch=${ETHERSCAN_INSTALL_TEST_ARCH:-$(uname -m)}
250
- case "$machine_arch" in
251
- x86_64|amd64) arch=amd64 ;;
252
- arm64|aarch64) arch=arm64 ;;
253
- *) die "unsupported architecture: $machine_arch. Etherscan CLI supports amd64 and arm64." ;;
254
- esac
255
-
256
- archive_name="etherscan_${release_version}_${os}_${arch}.tar.gz"
257
- if [ -z "$download_base" ]; then
258
- download_base="https://github.com/$repository/releases/download/$tag"
259
- fi
260
-
261
- temp_dir=$(mktemp -d 2>/dev/null || mktemp -d -t etherscan-install)
262
- trap 'rm -rf "$temp_dir"' EXIT HUP INT TERM
263
- archive_path="$temp_dir/$archive_name"
264
- checksum_path="$temp_dir/checksums.txt"
265
- source_executable="$temp_dir/etherscan"
266
-
267
- printf 'Downloading Etherscan CLI %s for %s/%s...\n' "$release_version" "$os" "$arch"
268
- fetch_file "$download_base" "$archive_name" "$archive_path"
269
- fetch_file "$download_base" checksums.txt "$checksum_path"
270
-
271
- expected_hash=$(awk -v name="$archive_name" '$2 == name || $2 == ("*" name) { print tolower($1); exit }' "$checksum_path")
272
- [ -n "$expected_hash" ] || die "no checksum was published for $archive_name"
273
- printf '%s\n' "$expected_hash" | grep -Eq '^[0-9a-f]{64}$' || die "invalid checksum published for $archive_name"
274
-
275
- if command -v sha256sum >/dev/null 2>&1; then
276
- actual_hash=$(sha256sum "$archive_path" | awk '{ print tolower($1) }')
277
- elif command -v shasum >/dev/null 2>&1; then
278
- actual_hash=$(shasum -a 256 "$archive_path" | awk '{ print tolower($1) }')
279
- else
280
- die "sha256sum or shasum is required to verify the download"
281
- fi
282
-
283
- [ "$actual_hash" = "$expected_hash" ] || die "checksum verification failed for $archive_name"
284
-
285
- entry_count=$(tar -tzf "$archive_path" | awk '$0 == "etherscan" { count++ } END { print count + 0 }')
286
- [ "$entry_count" -eq 1 ] || die "$archive_name must contain exactly one root-level etherscan"
287
- tar -xOzf "$archive_path" etherscan >"$source_executable"
288
- [ -s "$source_executable" ] || die "$archive_name contains an empty etherscan executable"
289
-
290
- mkdir -p "$install_dir"
291
- staged_executable="$install_dir/.etherscan.new.$$"
292
- cp "$source_executable" "$staged_executable"
293
- chmod 0755 "$staged_executable"
294
- mv -f "$staged_executable" "$install_dir/etherscan"
295
-
296
- path_updated=0
297
- if [ "$update_path" -eq 1 ]; then
298
- case ":$PATH:" in
299
- *:"$install_dir":*) ;;
300
- *)
301
- shell_name=${SHELL:-sh}
302
- shell_name=${shell_name##*/}
303
- escaped_install_dir=$(printf '%s' "$install_dir" | sed 's/[\\"$`]/\\&/g')
304
- if [ "$shell_name" = fish ]; then
305
- profile="$HOME/.config/fish/config.fish"
306
- mkdir -p "$(dirname "$profile")"
307
- path_line="fish_add_path \"$escaped_install_dir\""
308
- else
309
- case "$shell_name" in
310
- zsh) profile="$HOME/.zshrc" ;;
311
- bash) profile="$HOME/.bashrc" ;;
312
- *) profile="$HOME/.profile" ;;
313
- esac
314
- path_line="export PATH=\"$escaped_install_dir:\$PATH\""
315
- fi
316
-
317
- # Match the exact line we would write (whole-line, fixed-string) so an
318
- # unrelated profile line that merely contains the path does not suppress
319
- # the update, and a genuine duplicate is not appended.
320
- if ! [ -f "$profile" ] || ! grep -Fx -e "$path_line" "$profile" >/dev/null 2>&1; then
321
- {
322
- printf '\n# Etherscan CLI\n'
323
- printf '%s\n' "$path_line"
324
- } >>"$profile"
325
- path_updated=1
326
- printf '%s' "$marker_content" >"$install_dir/$marker_name"
327
- fi
328
- ;;
329
- esac
330
- fi
331
-
332
- printf '\nEtherscan CLI %s installed successfully.\n' "$release_version"
333
- printf 'Installed to: %s\n' "$install_dir/etherscan"
334
- if [ "$update_path" -eq 0 ]; then
335
- printf 'Add %s to PATH to run etherscan from any directory.\n' "$install_dir"
336
- elif [ "$path_updated" -eq 1 ]; then
337
- printf 'Open a new terminal, then run: etherscan version\n'
338
- else
339
- printf 'Run: etherscan version\n'
340
- fi