@etherscan-npm/cli 1.0.1 → 1.0.2

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
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Etherscan
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Etherscan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -54,6 +54,8 @@ npx @etherscan-npm/cli version
54
54
 
55
55
  The npm package downloads the matching native release archive and verifies its SHA-256 checksum during installation. Lifecycle scripts must be enabled.
56
56
 
57
+ > The package is currently published as `@etherscan-npm/cli` while the `@etherscan` npm scope is being transferred. The `etherscan` command is unchanged.
58
+
57
59
  ### Installation script — macOS and Linux
58
60
 
59
61
  ```sh
@@ -1,58 +1,58 @@
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
+ "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,15 +1,81 @@
1
- "use strict";
2
-
3
- const packageInfo = require("../package.json");
4
-
5
- try {
6
- if (packageInfo.version === "0.0.0-development") {
7
- throw new Error("the development placeholder version cannot be published");
8
- }
9
- if (!/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(packageInfo.version)) {
10
- throw new Error(`invalid release version: ${packageInfo.version}`);
11
- }
12
- } catch (error) {
13
- console.error(`Refusing to publish @etherscan/cli: ${error.message}`);
14
- process.exit(1);
15
- }
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@etherscan-npm/cli",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
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",
@@ -19,7 +19,8 @@
19
19
  "postinstall": "node npm/postinstall.js",
20
20
  "prepublishOnly": "node npm/prepublish-check.js",
21
21
  "test:package": "npm pack --dry-run",
22
- "test:release": "node npm/publish.test.js"
22
+ "test:release": "node npm/publish.test.js",
23
+ "test:eol": "node npm/check-tarball-eol.js"
23
24
  },
24
25
  "files": [
25
26
  "npm/bin/etherscan.js",
@@ -1,262 +1,262 @@
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
- )
9
-
10
- $ErrorActionPreference = "Stop"
11
- $ProgressPreference = "SilentlyContinue"
12
-
13
- $Repository = "etherscan/etherscan-cli"
14
- $DownloadBaseUrl = $env:ETHERSCAN_INSTALL_TEST_DOWNLOAD_BASE_URL
15
-
16
- function Get-EtherscanArchitecture {
17
- $architecture = $env:PROCESSOR_ARCHITEW6432
18
- if ([string]::IsNullOrWhiteSpace($architecture)) {
19
- $architecture = $env:PROCESSOR_ARCHITECTURE
20
- }
21
-
22
- switch -Regex ($architecture) {
23
- "^(AMD64|x86_64)$" { return "amd64" }
24
- "^(ARM64|aarch64)$" { return "arm64" }
25
- default { throw "Unsupported Windows architecture: $architecture. Etherscan CLI supports amd64 and arm64." }
26
- }
27
- }
28
-
29
- function Get-GitHubApiHeaders {
30
- $headers = @{
31
- Accept = "application/vnd.github+json"
32
- "User-Agent" = "etherscan-cli-installer"
33
- "X-GitHub-Api-Version" = "2022-11-28"
34
- }
35
- if (-not [string]::IsNullOrWhiteSpace($env:ETHERSCAN_GITHUB_TOKEN)) {
36
- $headers.Authorization = "Bearer $($env:ETHERSCAN_GITHUB_TOKEN)"
37
- }
38
- return $headers
39
- }
40
-
41
- function Resolve-EtherscanVersion {
42
- param([string]$RequestedVersion)
43
-
44
- if (-not [string]::IsNullOrWhiteSpace($RequestedVersion) -and $RequestedVersion -ne "latest") {
45
- $tag = if ($RequestedVersion.StartsWith("v")) { $RequestedVersion } else { "v$RequestedVersion" }
46
- }
47
- else {
48
- if (-not [string]::IsNullOrWhiteSpace($DownloadBaseUrl)) {
49
- throw "A version is required when the installer test download source is used."
50
- }
51
- $release = Invoke-RestMethod `
52
- -Uri "https://api.github.com/repos/$Repository/releases/latest" `
53
- -Headers (Get-GitHubApiHeaders)
54
- $tag = [string]$release.tag_name
55
- }
56
-
57
- if ($tag -notmatch '^v[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$') {
58
- throw "Invalid release version: $tag"
59
- }
60
-
61
- return @{
62
- Tag = $tag
63
- Version = $tag.Substring(1)
64
- }
65
- }
66
-
67
- function Copy-InstallerFile {
68
- param(
69
- [string]$Base,
70
- [string]$Name,
71
- [string]$Destination
72
- )
73
-
74
- if (Test-Path -LiteralPath $Base -PathType Container) {
75
- Copy-Item -LiteralPath (Join-Path $Base $Name) -Destination $Destination
76
- return
77
- }
78
-
79
- $uri = "$($Base.TrimEnd('/'))/$Name"
80
- $parsedUri = [Uri]$uri
81
- if ($parsedUri.Scheme -ne "https") {
82
- throw "Remote downloads must use HTTPS: $uri"
83
- }
84
-
85
- $headers = @{
86
- "User-Agent" = "etherscan-cli-installer"
87
- }
88
- if ($parsedUri.Host -in @("github.com", "api.github.com") -and
89
- -not [string]::IsNullOrWhiteSpace($env:ETHERSCAN_GITHUB_TOKEN)) {
90
- $headers.Authorization = "Bearer $($env:ETHERSCAN_GITHUB_TOKEN)"
91
- }
92
-
93
- Invoke-WebRequest -Uri $uri -OutFile $Destination -Headers $headers -UseBasicParsing
94
- }
95
-
96
- function Get-SHA256FileHash {
97
- param([string]$Path)
98
-
99
- $sha256 = [Security.Cryptography.SHA256]::Create()
100
- try {
101
- $stream = [IO.File]::OpenRead($Path)
102
- try {
103
- return ([BitConverter]::ToString($sha256.ComputeHash($stream))).Replace("-", "").ToLowerInvariant()
104
- }
105
- finally {
106
- $stream.Dispose()
107
- }
108
- }
109
- finally {
110
- $sha256.Dispose()
111
- }
112
- }
113
-
114
- function Add-EtherscanToUserPath {
115
- param([string]$Directory)
116
-
117
- $fullDirectory = [IO.Path]::GetFullPath($Directory).TrimEnd('\')
118
- $userPath = [Environment]::GetEnvironmentVariable("Path", "User")
119
- $entries = @($userPath -split ';' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
120
- $alreadyPresent = $entries | Where-Object {
121
- try {
122
- $expandedEntry = [Environment]::ExpandEnvironmentVariables($_)
123
- [IO.Path]::GetFullPath($expandedEntry).TrimEnd('\').Equals($fullDirectory, [StringComparison]::OrdinalIgnoreCase)
124
- }
125
- catch {
126
- $_.TrimEnd('\').Equals($fullDirectory, [StringComparison]::OrdinalIgnoreCase)
127
- }
128
- }
129
-
130
- if (-not $alreadyPresent) {
131
- $newUserPath = if ([string]::IsNullOrWhiteSpace($userPath)) {
132
- $fullDirectory
133
- }
134
- else {
135
- "$($userPath.TrimEnd(';'));$fullDirectory"
136
- }
137
- [Environment]::SetEnvironmentVariable("Path", $newUserPath, "User")
138
- Write-Host "Added $fullDirectory to your user PATH."
139
- }
140
-
141
- $processEntries = @($env:Path -split ';')
142
- if (-not ($processEntries | Where-Object { $_.TrimEnd('\').Equals($fullDirectory, [StringComparison]::OrdinalIgnoreCase) })) {
143
- $env:Path = "$env:Path;$fullDirectory"
144
- }
145
- }
146
-
147
- if ($env:OS -ne "Windows_NT") {
148
- throw "This installer supports Windows only. Use install.sh on macOS or Linux."
149
- }
150
-
151
- if ([string]::IsNullOrWhiteSpace($InstallDir)) {
152
- $localAppData = [Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)
153
- $InstallDir = Join-Path $localAppData "Programs\Etherscan\bin"
154
- }
155
- if ($InstallDir.Contains(';')) {
156
- throw "The installation directory cannot contain a semicolon."
157
- }
158
- if ($InstallDir.IndexOfAny([char[]]"`r`n") -ge 0) {
159
- throw "The installation directory cannot contain a line break."
160
- }
161
- if ($WaitForProcessId -gt 0) {
162
- Wait-Process -Id $WaitForProcessId -ErrorAction SilentlyContinue
163
- }
164
-
165
- $resolved = Resolve-EtherscanVersion -RequestedVersion $Version
166
- $architecture = Get-EtherscanArchitecture
167
- $archiveName = "etherscan_$($resolved.Version)_windows_$architecture.zip"
168
- $baseUrl = if ([string]::IsNullOrWhiteSpace($DownloadBaseUrl)) {
169
- "https://github.com/$Repository/releases/download/$($resolved.Tag)"
170
- }
171
- else {
172
- $DownloadBaseUrl
173
- }
174
-
175
- $tempDirectory = Join-Path ([IO.Path]::GetTempPath()) "etherscan-install-$PID-$([Guid]::NewGuid().ToString('N'))"
176
- $archivePath = Join-Path $tempDirectory $archiveName
177
- $checksumPath = Join-Path $tempDirectory "checksums.txt"
178
- $sourceExecutable = Join-Path $tempDirectory "etherscan.exe"
179
-
180
- try {
181
- New-Item -ItemType Directory -Path $tempDirectory -Force | Out-Null
182
-
183
- Write-Host "Downloading Etherscan CLI $($resolved.Version) for windows/$architecture..."
184
- Copy-InstallerFile -Base $baseUrl -Name $archiveName -Destination $archivePath
185
- Copy-InstallerFile -Base $baseUrl -Name "checksums.txt" -Destination $checksumPath
186
-
187
- $pattern = '^([0-9A-Fa-f]{64})\s+\*?' + [Regex]::Escape($archiveName) + '$'
188
- $checksumLine = Get-Content -LiteralPath $checksumPath | Where-Object { $_ -match $pattern } | Select-Object -First 1
189
- if (-not $checksumLine -or $checksumLine -notmatch $pattern) {
190
- throw "No checksum was published for $archiveName."
191
- }
192
-
193
- $expectedHash = $Matches[1].ToLowerInvariant()
194
- $actualHash = Get-SHA256FileHash -Path $archivePath
195
- if ($actualHash -ne $expectedHash) {
196
- throw "Checksum verification failed for $archiveName. Expected $expectedHash, received $actualHash."
197
- }
198
-
199
- Add-Type -AssemblyName System.IO.Compression.FileSystem
200
- $zip = [IO.Compression.ZipFile]::OpenRead($archivePath)
201
- try {
202
- $executableEntries = @($zip.Entries | Where-Object { $_.FullName.Replace('\', '/') -eq "etherscan.exe" })
203
- if ($executableEntries.Count -ne 1) {
204
- throw "$archiveName must contain exactly one root-level etherscan.exe."
205
- }
206
-
207
- $inputStream = $executableEntries[0].Open()
208
- $outputStream = [IO.File]::Create($sourceExecutable)
209
- try {
210
- $inputStream.CopyTo($outputStream)
211
- }
212
- finally {
213
- $outputStream.Dispose()
214
- $inputStream.Dispose()
215
- }
216
- }
217
- finally {
218
- $zip.Dispose()
219
- }
220
-
221
- New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
222
- $targetExecutable = Join-Path $InstallDir "etherscan.exe"
223
- $stagedExecutable = Join-Path $InstallDir ".etherscan.exe.new-$PID"
224
- $backupExecutable = Join-Path $InstallDir ".etherscan.exe.old-$PID"
225
- Copy-Item -LiteralPath $sourceExecutable -Destination $stagedExecutable -Force
226
-
227
- try {
228
- if (Test-Path -LiteralPath $targetExecutable) {
229
- Move-Item -LiteralPath $targetExecutable -Destination $backupExecutable -Force
230
- }
231
- Move-Item -LiteralPath $stagedExecutable -Destination $targetExecutable -Force
232
- Remove-Item -LiteralPath $backupExecutable -Force -ErrorAction SilentlyContinue
233
- }
234
- catch {
235
- Remove-Item -LiteralPath $stagedExecutable -Force -ErrorAction SilentlyContinue
236
- if ((Test-Path -LiteralPath $backupExecutable) -and -not (Test-Path -LiteralPath $targetExecutable)) {
237
- Move-Item -LiteralPath $backupExecutable -Destination $targetExecutable -Force
238
- }
239
- throw
240
- }
241
-
242
- if (-not $NoPathUpdate) {
243
- Add-EtherscanToUserPath -Directory $InstallDir
244
- }
245
-
246
- Write-Host ""
247
- Write-Host "Etherscan CLI $($resolved.Version) installed successfully."
248
- Write-Host "Installed to: $targetExecutable"
249
- if ($NoPathUpdate) {
250
- Write-Host "Add $InstallDir to PATH to run etherscan from any directory."
251
- }
252
- else {
253
- Write-Host "Run 'etherscan version' to verify the installation."
254
- Write-Host "Open a new terminal if the command is not yet available."
255
- }
256
- }
257
- finally {
258
- Remove-Item -LiteralPath $tempDirectory -Recurse -Force -ErrorAction SilentlyContinue
259
- if ($CleanupScript -and -not [string]::IsNullOrWhiteSpace($PSCommandPath)) {
260
- Remove-Item -LiteralPath $PSCommandPath -Force -ErrorAction SilentlyContinue
261
- }
262
- }
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
+ )
9
+
10
+ $ErrorActionPreference = "Stop"
11
+ $ProgressPreference = "SilentlyContinue"
12
+
13
+ $Repository = "etherscan/etherscan-cli"
14
+ $DownloadBaseUrl = $env:ETHERSCAN_INSTALL_TEST_DOWNLOAD_BASE_URL
15
+
16
+ function Get-EtherscanArchitecture {
17
+ $architecture = $env:PROCESSOR_ARCHITEW6432
18
+ if ([string]::IsNullOrWhiteSpace($architecture)) {
19
+ $architecture = $env:PROCESSOR_ARCHITECTURE
20
+ }
21
+
22
+ switch -Regex ($architecture) {
23
+ "^(AMD64|x86_64)$" { return "amd64" }
24
+ "^(ARM64|aarch64)$" { return "arm64" }
25
+ default { throw "Unsupported Windows architecture: $architecture. Etherscan CLI supports amd64 and arm64." }
26
+ }
27
+ }
28
+
29
+ function Get-GitHubApiHeaders {
30
+ $headers = @{
31
+ Accept = "application/vnd.github+json"
32
+ "User-Agent" = "etherscan-cli-installer"
33
+ "X-GitHub-Api-Version" = "2022-11-28"
34
+ }
35
+ if (-not [string]::IsNullOrWhiteSpace($env:ETHERSCAN_GITHUB_TOKEN)) {
36
+ $headers.Authorization = "Bearer $($env:ETHERSCAN_GITHUB_TOKEN)"
37
+ }
38
+ return $headers
39
+ }
40
+
41
+ function Resolve-EtherscanVersion {
42
+ param([string]$RequestedVersion)
43
+
44
+ if (-not [string]::IsNullOrWhiteSpace($RequestedVersion) -and $RequestedVersion -ne "latest") {
45
+ $tag = if ($RequestedVersion.StartsWith("v")) { $RequestedVersion } else { "v$RequestedVersion" }
46
+ }
47
+ else {
48
+ if (-not [string]::IsNullOrWhiteSpace($DownloadBaseUrl)) {
49
+ throw "A version is required when the installer test download source is used."
50
+ }
51
+ $release = Invoke-RestMethod `
52
+ -Uri "https://api.github.com/repos/$Repository/releases/latest" `
53
+ -Headers (Get-GitHubApiHeaders)
54
+ $tag = [string]$release.tag_name
55
+ }
56
+
57
+ if ($tag -notmatch '^v[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$') {
58
+ throw "Invalid release version: $tag"
59
+ }
60
+
61
+ return @{
62
+ Tag = $tag
63
+ Version = $tag.Substring(1)
64
+ }
65
+ }
66
+
67
+ function Copy-InstallerFile {
68
+ param(
69
+ [string]$Base,
70
+ [string]$Name,
71
+ [string]$Destination
72
+ )
73
+
74
+ if (Test-Path -LiteralPath $Base -PathType Container) {
75
+ Copy-Item -LiteralPath (Join-Path $Base $Name) -Destination $Destination
76
+ return
77
+ }
78
+
79
+ $uri = "$($Base.TrimEnd('/'))/$Name"
80
+ $parsedUri = [Uri]$uri
81
+ if ($parsedUri.Scheme -ne "https") {
82
+ throw "Remote downloads must use HTTPS: $uri"
83
+ }
84
+
85
+ $headers = @{
86
+ "User-Agent" = "etherscan-cli-installer"
87
+ }
88
+ if ($parsedUri.Host -in @("github.com", "api.github.com") -and
89
+ -not [string]::IsNullOrWhiteSpace($env:ETHERSCAN_GITHUB_TOKEN)) {
90
+ $headers.Authorization = "Bearer $($env:ETHERSCAN_GITHUB_TOKEN)"
91
+ }
92
+
93
+ Invoke-WebRequest -Uri $uri -OutFile $Destination -Headers $headers -UseBasicParsing
94
+ }
95
+
96
+ function Get-SHA256FileHash {
97
+ param([string]$Path)
98
+
99
+ $sha256 = [Security.Cryptography.SHA256]::Create()
100
+ try {
101
+ $stream = [IO.File]::OpenRead($Path)
102
+ try {
103
+ return ([BitConverter]::ToString($sha256.ComputeHash($stream))).Replace("-", "").ToLowerInvariant()
104
+ }
105
+ finally {
106
+ $stream.Dispose()
107
+ }
108
+ }
109
+ finally {
110
+ $sha256.Dispose()
111
+ }
112
+ }
113
+
114
+ function Add-EtherscanToUserPath {
115
+ param([string]$Directory)
116
+
117
+ $fullDirectory = [IO.Path]::GetFullPath($Directory).TrimEnd('\')
118
+ $userPath = [Environment]::GetEnvironmentVariable("Path", "User")
119
+ $entries = @($userPath -split ';' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
120
+ $alreadyPresent = $entries | Where-Object {
121
+ try {
122
+ $expandedEntry = [Environment]::ExpandEnvironmentVariables($_)
123
+ [IO.Path]::GetFullPath($expandedEntry).TrimEnd('\').Equals($fullDirectory, [StringComparison]::OrdinalIgnoreCase)
124
+ }
125
+ catch {
126
+ $_.TrimEnd('\').Equals($fullDirectory, [StringComparison]::OrdinalIgnoreCase)
127
+ }
128
+ }
129
+
130
+ if (-not $alreadyPresent) {
131
+ $newUserPath = if ([string]::IsNullOrWhiteSpace($userPath)) {
132
+ $fullDirectory
133
+ }
134
+ else {
135
+ "$($userPath.TrimEnd(';'));$fullDirectory"
136
+ }
137
+ [Environment]::SetEnvironmentVariable("Path", $newUserPath, "User")
138
+ Write-Host "Added $fullDirectory to your user PATH."
139
+ }
140
+
141
+ $processEntries = @($env:Path -split ';')
142
+ if (-not ($processEntries | Where-Object { $_.TrimEnd('\').Equals($fullDirectory, [StringComparison]::OrdinalIgnoreCase) })) {
143
+ $env:Path = "$env:Path;$fullDirectory"
144
+ }
145
+ }
146
+
147
+ if ($env:OS -ne "Windows_NT") {
148
+ throw "This installer supports Windows only. Use install.sh on macOS or Linux."
149
+ }
150
+
151
+ if ([string]::IsNullOrWhiteSpace($InstallDir)) {
152
+ $localAppData = [Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)
153
+ $InstallDir = Join-Path $localAppData "Programs\Etherscan\bin"
154
+ }
155
+ if ($InstallDir.Contains(';')) {
156
+ throw "The installation directory cannot contain a semicolon."
157
+ }
158
+ if ($InstallDir.IndexOfAny([char[]]"`r`n") -ge 0) {
159
+ throw "The installation directory cannot contain a line break."
160
+ }
161
+ if ($WaitForProcessId -gt 0) {
162
+ Wait-Process -Id $WaitForProcessId -ErrorAction SilentlyContinue
163
+ }
164
+
165
+ $resolved = Resolve-EtherscanVersion -RequestedVersion $Version
166
+ $architecture = Get-EtherscanArchitecture
167
+ $archiveName = "etherscan_$($resolved.Version)_windows_$architecture.zip"
168
+ $baseUrl = if ([string]::IsNullOrWhiteSpace($DownloadBaseUrl)) {
169
+ "https://github.com/$Repository/releases/download/$($resolved.Tag)"
170
+ }
171
+ else {
172
+ $DownloadBaseUrl
173
+ }
174
+
175
+ $tempDirectory = Join-Path ([IO.Path]::GetTempPath()) "etherscan-install-$PID-$([Guid]::NewGuid().ToString('N'))"
176
+ $archivePath = Join-Path $tempDirectory $archiveName
177
+ $checksumPath = Join-Path $tempDirectory "checksums.txt"
178
+ $sourceExecutable = Join-Path $tempDirectory "etherscan.exe"
179
+
180
+ try {
181
+ New-Item -ItemType Directory -Path $tempDirectory -Force | Out-Null
182
+
183
+ Write-Host "Downloading Etherscan CLI $($resolved.Version) for windows/$architecture..."
184
+ Copy-InstallerFile -Base $baseUrl -Name $archiveName -Destination $archivePath
185
+ Copy-InstallerFile -Base $baseUrl -Name "checksums.txt" -Destination $checksumPath
186
+
187
+ $pattern = '^([0-9A-Fa-f]{64})\s+\*?' + [Regex]::Escape($archiveName) + '$'
188
+ $checksumLine = Get-Content -LiteralPath $checksumPath | Where-Object { $_ -match $pattern } | Select-Object -First 1
189
+ if (-not $checksumLine -or $checksumLine -notmatch $pattern) {
190
+ throw "No checksum was published for $archiveName."
191
+ }
192
+
193
+ $expectedHash = $Matches[1].ToLowerInvariant()
194
+ $actualHash = Get-SHA256FileHash -Path $archivePath
195
+ if ($actualHash -ne $expectedHash) {
196
+ throw "Checksum verification failed for $archiveName. Expected $expectedHash, received $actualHash."
197
+ }
198
+
199
+ Add-Type -AssemblyName System.IO.Compression.FileSystem
200
+ $zip = [IO.Compression.ZipFile]::OpenRead($archivePath)
201
+ try {
202
+ $executableEntries = @($zip.Entries | Where-Object { $_.FullName.Replace('\', '/') -eq "etherscan.exe" })
203
+ if ($executableEntries.Count -ne 1) {
204
+ throw "$archiveName must contain exactly one root-level etherscan.exe."
205
+ }
206
+
207
+ $inputStream = $executableEntries[0].Open()
208
+ $outputStream = [IO.File]::Create($sourceExecutable)
209
+ try {
210
+ $inputStream.CopyTo($outputStream)
211
+ }
212
+ finally {
213
+ $outputStream.Dispose()
214
+ $inputStream.Dispose()
215
+ }
216
+ }
217
+ finally {
218
+ $zip.Dispose()
219
+ }
220
+
221
+ New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
222
+ $targetExecutable = Join-Path $InstallDir "etherscan.exe"
223
+ $stagedExecutable = Join-Path $InstallDir ".etherscan.exe.new-$PID"
224
+ $backupExecutable = Join-Path $InstallDir ".etherscan.exe.old-$PID"
225
+ Copy-Item -LiteralPath $sourceExecutable -Destination $stagedExecutable -Force
226
+
227
+ try {
228
+ if (Test-Path -LiteralPath $targetExecutable) {
229
+ Move-Item -LiteralPath $targetExecutable -Destination $backupExecutable -Force
230
+ }
231
+ Move-Item -LiteralPath $stagedExecutable -Destination $targetExecutable -Force
232
+ Remove-Item -LiteralPath $backupExecutable -Force -ErrorAction SilentlyContinue
233
+ }
234
+ catch {
235
+ Remove-Item -LiteralPath $stagedExecutable -Force -ErrorAction SilentlyContinue
236
+ if ((Test-Path -LiteralPath $backupExecutable) -and -not (Test-Path -LiteralPath $targetExecutable)) {
237
+ Move-Item -LiteralPath $backupExecutable -Destination $targetExecutable -Force
238
+ }
239
+ throw
240
+ }
241
+
242
+ if (-not $NoPathUpdate) {
243
+ Add-EtherscanToUserPath -Directory $InstallDir
244
+ }
245
+
246
+ Write-Host ""
247
+ Write-Host "Etherscan CLI $($resolved.Version) installed successfully."
248
+ Write-Host "Installed to: $targetExecutable"
249
+ if ($NoPathUpdate) {
250
+ Write-Host "Add $InstallDir to PATH to run etherscan from any directory."
251
+ }
252
+ else {
253
+ Write-Host "Run 'etherscan version' to verify the installation."
254
+ Write-Host "Open a new terminal if the command is not yet available."
255
+ }
256
+ }
257
+ finally {
258
+ Remove-Item -LiteralPath $tempDirectory -Recurse -Force -ErrorAction SilentlyContinue
259
+ if ($CleanupScript -and -not [string]::IsNullOrWhiteSpace($PSCommandPath)) {
260
+ Remove-Item -LiteralPath $PSCommandPath -Force -ErrorAction SilentlyContinue
261
+ }
262
+ }
@@ -1,211 +1,211 @@
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
-
11
- usage() {
12
- cat <<'EOF'
13
- Install Etherscan CLI.
14
-
15
- Usage: install.sh [options]
16
-
17
- Options:
18
- --version VERSION Install a specific version (for example, v1.1.0).
19
- --install-dir DIRECTORY Install into DIRECTORY (default: ~/.local/bin).
20
- --no-path-update Do not update the shell profile.
21
- -h, --help Show this help.
22
- EOF
23
- }
24
-
25
- die() {
26
- printf 'error: %s\n' "$*" >&2
27
- exit 1
28
- }
29
-
30
- while [ "$#" -gt 0 ]; do
31
- case "$1" in
32
- --version)
33
- [ "$#" -ge 2 ] || die "--version requires a value"
34
- version=$2
35
- shift 2
36
- ;;
37
- --install-dir)
38
- [ "$#" -ge 2 ] || die "--install-dir requires a value"
39
- install_dir=$2
40
- shift 2
41
- ;;
42
- --no-path-update)
43
- update_path=0
44
- shift
45
- ;;
46
- -h|--help)
47
- usage
48
- exit 0
49
- ;;
50
- *)
51
- die "unknown option: $1"
52
- ;;
53
- esac
54
- done
55
-
56
- if printf '%s' "$install_dir" | LC_ALL=C grep '[[:cntrl:]]' >/dev/null 2>&1; then
57
- die "the installation directory cannot contain control characters"
58
- fi
59
-
60
- fetch_stdout() {
61
- url=$1
62
- if command -v curl >/dev/null 2>&1; then
63
- curl -fsSL -A etherscan-cli-installer "$url"
64
- elif command -v wget >/dev/null 2>&1; then
65
- wget -qO- --user-agent=etherscan-cli-installer "$url"
66
- else
67
- die "curl or wget is required"
68
- fi
69
- }
70
-
71
- fetch_file() {
72
- base=$1
73
- name=$2
74
- destination=$3
75
-
76
- if [ -d "$base" ]; then
77
- cp "$base/$name" "$destination"
78
- return
79
- fi
80
-
81
- case "$base" in
82
- file://*)
83
- cp "${base#file://}/$name" "$destination"
84
- ;;
85
- https://*)
86
- if command -v curl >/dev/null 2>&1; then
87
- curl -fsSL -A etherscan-cli-installer "$base/$name" -o "$destination"
88
- elif command -v wget >/dev/null 2>&1; then
89
- wget -q --user-agent=etherscan-cli-installer "$base/$name" -O "$destination"
90
- else
91
- die "curl or wget is required"
92
- fi
93
- ;;
94
- *)
95
- die "invalid download base URL or directory: $base"
96
- ;;
97
- esac
98
- }
99
-
100
- if [ -z "$version" ] || [ "$version" = latest ]; then
101
- [ -z "$download_base" ] || die "a version is required with the installer test download source"
102
- release_json=$(fetch_stdout "https://api.github.com/repos/$repository/releases/latest")
103
- version=$(printf '%s\n' "$release_json" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | sed -n '1p')
104
- [ -n "$version" ] || die "could not resolve the latest Etherscan CLI version"
105
- fi
106
-
107
- case "$version" in
108
- v*) tag=$version; release_version=${version#v} ;;
109
- *) tag="v$version"; release_version=$version ;;
110
- esac
111
-
112
- printf '%s\n' "$tag" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$' || die "invalid release version: $tag"
113
-
114
- system_name=${ETHERSCAN_INSTALL_TEST_OS:-$(uname -s)}
115
- case "$system_name" in
116
- Linux|linux) os=linux ;;
117
- Darwin|darwin) os=darwin ;;
118
- *) die "unsupported operating system: $system_name" ;;
119
- esac
120
-
121
- machine_arch=${ETHERSCAN_INSTALL_TEST_ARCH:-$(uname -m)}
122
- case "$machine_arch" in
123
- x86_64|amd64) arch=amd64 ;;
124
- arm64|aarch64) arch=arm64 ;;
125
- *) die "unsupported architecture: $machine_arch. Etherscan CLI supports amd64 and arm64." ;;
126
- esac
127
-
128
- archive_name="etherscan_${release_version}_${os}_${arch}.tar.gz"
129
- if [ -z "$download_base" ]; then
130
- download_base="https://github.com/$repository/releases/download/$tag"
131
- fi
132
-
133
- temp_dir=$(mktemp -d 2>/dev/null || mktemp -d -t etherscan-install)
134
- trap 'rm -rf "$temp_dir"' EXIT HUP INT TERM
135
- archive_path="$temp_dir/$archive_name"
136
- checksum_path="$temp_dir/checksums.txt"
137
- source_executable="$temp_dir/etherscan"
138
-
139
- printf 'Downloading Etherscan CLI %s for %s/%s...\n' "$release_version" "$os" "$arch"
140
- fetch_file "$download_base" "$archive_name" "$archive_path"
141
- fetch_file "$download_base" checksums.txt "$checksum_path"
142
-
143
- expected_hash=$(awk -v name="$archive_name" '$2 == name || $2 == ("*" name) { print tolower($1); exit }' "$checksum_path")
144
- [ -n "$expected_hash" ] || die "no checksum was published for $archive_name"
145
- printf '%s\n' "$expected_hash" | grep -Eq '^[0-9a-f]{64}$' || die "invalid checksum published for $archive_name"
146
-
147
- if command -v sha256sum >/dev/null 2>&1; then
148
- actual_hash=$(sha256sum "$archive_path" | awk '{ print tolower($1) }')
149
- elif command -v shasum >/dev/null 2>&1; then
150
- actual_hash=$(shasum -a 256 "$archive_path" | awk '{ print tolower($1) }')
151
- else
152
- die "sha256sum or shasum is required to verify the download"
153
- fi
154
-
155
- [ "$actual_hash" = "$expected_hash" ] || die "checksum verification failed for $archive_name"
156
-
157
- entry_count=$(tar -tzf "$archive_path" | awk '$0 == "etherscan" { count++ } END { print count + 0 }')
158
- [ "$entry_count" -eq 1 ] || die "$archive_name must contain exactly one root-level etherscan"
159
- tar -xOzf "$archive_path" etherscan >"$source_executable"
160
- [ -s "$source_executable" ] || die "$archive_name contains an empty etherscan executable"
161
-
162
- mkdir -p "$install_dir"
163
- staged_executable="$install_dir/.etherscan.new.$$"
164
- cp "$source_executable" "$staged_executable"
165
- chmod 0755 "$staged_executable"
166
- mv -f "$staged_executable" "$install_dir/etherscan"
167
-
168
- path_updated=0
169
- if [ "$update_path" -eq 1 ]; then
170
- case ":$PATH:" in
171
- *:"$install_dir":*) ;;
172
- *)
173
- shell_name=${SHELL:-sh}
174
- shell_name=${shell_name##*/}
175
- escaped_install_dir=$(printf '%s' "$install_dir" | sed 's/[\\"$`]/\\&/g')
176
- if [ "$shell_name" = fish ]; then
177
- profile="$HOME/.config/fish/config.fish"
178
- mkdir -p "$(dirname "$profile")"
179
- path_line="fish_add_path \"$escaped_install_dir\""
180
- else
181
- case "$shell_name" in
182
- zsh) profile="$HOME/.zshrc" ;;
183
- bash) profile="$HOME/.bashrc" ;;
184
- *) profile="$HOME/.profile" ;;
185
- esac
186
- path_line="export PATH=\"$escaped_install_dir:\$PATH\""
187
- fi
188
-
189
- # Match the exact line we would write (whole-line, fixed-string) so an
190
- # unrelated profile line that merely contains the path does not suppress
191
- # the update, and a genuine duplicate is not appended.
192
- if ! [ -f "$profile" ] || ! grep -Fx -e "$path_line" "$profile" >/dev/null 2>&1; then
193
- {
194
- printf '\n# Etherscan CLI\n'
195
- printf '%s\n' "$path_line"
196
- } >>"$profile"
197
- path_updated=1
198
- fi
199
- ;;
200
- esac
201
- fi
202
-
203
- printf '\nEtherscan CLI %s installed successfully.\n' "$release_version"
204
- printf 'Installed to: %s\n' "$install_dir/etherscan"
205
- if [ "$update_path" -eq 0 ]; then
206
- printf 'Add %s to PATH to run etherscan from any directory.\n' "$install_dir"
207
- elif [ "$path_updated" -eq 1 ]; then
208
- printf 'Open a new terminal, then run: etherscan version\n'
209
- else
210
- printf 'Run: etherscan version\n'
211
- fi
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
+
11
+ usage() {
12
+ cat <<'EOF'
13
+ Install Etherscan CLI.
14
+
15
+ Usage: install.sh [options]
16
+
17
+ Options:
18
+ --version VERSION Install a specific version (for example, v1.1.0).
19
+ --install-dir DIRECTORY Install into DIRECTORY (default: ~/.local/bin).
20
+ --no-path-update Do not update the shell profile.
21
+ -h, --help Show this help.
22
+ EOF
23
+ }
24
+
25
+ die() {
26
+ printf 'error: %s\n' "$*" >&2
27
+ exit 1
28
+ }
29
+
30
+ while [ "$#" -gt 0 ]; do
31
+ case "$1" in
32
+ --version)
33
+ [ "$#" -ge 2 ] || die "--version requires a value"
34
+ version=$2
35
+ shift 2
36
+ ;;
37
+ --install-dir)
38
+ [ "$#" -ge 2 ] || die "--install-dir requires a value"
39
+ install_dir=$2
40
+ shift 2
41
+ ;;
42
+ --no-path-update)
43
+ update_path=0
44
+ shift
45
+ ;;
46
+ -h|--help)
47
+ usage
48
+ exit 0
49
+ ;;
50
+ *)
51
+ die "unknown option: $1"
52
+ ;;
53
+ esac
54
+ done
55
+
56
+ if printf '%s' "$install_dir" | LC_ALL=C grep '[[:cntrl:]]' >/dev/null 2>&1; then
57
+ die "the installation directory cannot contain control characters"
58
+ fi
59
+
60
+ fetch_stdout() {
61
+ url=$1
62
+ if command -v curl >/dev/null 2>&1; then
63
+ curl -fsSL -A etherscan-cli-installer "$url"
64
+ elif command -v wget >/dev/null 2>&1; then
65
+ wget -qO- --user-agent=etherscan-cli-installer "$url"
66
+ else
67
+ die "curl or wget is required"
68
+ fi
69
+ }
70
+
71
+ fetch_file() {
72
+ base=$1
73
+ name=$2
74
+ destination=$3
75
+
76
+ if [ -d "$base" ]; then
77
+ cp "$base/$name" "$destination"
78
+ return
79
+ fi
80
+
81
+ case "$base" in
82
+ file://*)
83
+ cp "${base#file://}/$name" "$destination"
84
+ ;;
85
+ https://*)
86
+ if command -v curl >/dev/null 2>&1; then
87
+ curl -fsSL -A etherscan-cli-installer "$base/$name" -o "$destination"
88
+ elif command -v wget >/dev/null 2>&1; then
89
+ wget -q --user-agent=etherscan-cli-installer "$base/$name" -O "$destination"
90
+ else
91
+ die "curl or wget is required"
92
+ fi
93
+ ;;
94
+ *)
95
+ die "invalid download base URL or directory: $base"
96
+ ;;
97
+ esac
98
+ }
99
+
100
+ if [ -z "$version" ] || [ "$version" = latest ]; then
101
+ [ -z "$download_base" ] || die "a version is required with the installer test download source"
102
+ release_json=$(fetch_stdout "https://api.github.com/repos/$repository/releases/latest")
103
+ version=$(printf '%s\n' "$release_json" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | sed -n '1p')
104
+ [ -n "$version" ] || die "could not resolve the latest Etherscan CLI version"
105
+ fi
106
+
107
+ case "$version" in
108
+ v*) tag=$version; release_version=${version#v} ;;
109
+ *) tag="v$version"; release_version=$version ;;
110
+ esac
111
+
112
+ printf '%s\n' "$tag" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$' || die "invalid release version: $tag"
113
+
114
+ system_name=${ETHERSCAN_INSTALL_TEST_OS:-$(uname -s)}
115
+ case "$system_name" in
116
+ Linux|linux) os=linux ;;
117
+ Darwin|darwin) os=darwin ;;
118
+ *) die "unsupported operating system: $system_name" ;;
119
+ esac
120
+
121
+ machine_arch=${ETHERSCAN_INSTALL_TEST_ARCH:-$(uname -m)}
122
+ case "$machine_arch" in
123
+ x86_64|amd64) arch=amd64 ;;
124
+ arm64|aarch64) arch=arm64 ;;
125
+ *) die "unsupported architecture: $machine_arch. Etherscan CLI supports amd64 and arm64." ;;
126
+ esac
127
+
128
+ archive_name="etherscan_${release_version}_${os}_${arch}.tar.gz"
129
+ if [ -z "$download_base" ]; then
130
+ download_base="https://github.com/$repository/releases/download/$tag"
131
+ fi
132
+
133
+ temp_dir=$(mktemp -d 2>/dev/null || mktemp -d -t etherscan-install)
134
+ trap 'rm -rf "$temp_dir"' EXIT HUP INT TERM
135
+ archive_path="$temp_dir/$archive_name"
136
+ checksum_path="$temp_dir/checksums.txt"
137
+ source_executable="$temp_dir/etherscan"
138
+
139
+ printf 'Downloading Etherscan CLI %s for %s/%s...\n' "$release_version" "$os" "$arch"
140
+ fetch_file "$download_base" "$archive_name" "$archive_path"
141
+ fetch_file "$download_base" checksums.txt "$checksum_path"
142
+
143
+ expected_hash=$(awk -v name="$archive_name" '$2 == name || $2 == ("*" name) { print tolower($1); exit }' "$checksum_path")
144
+ [ -n "$expected_hash" ] || die "no checksum was published for $archive_name"
145
+ printf '%s\n' "$expected_hash" | grep -Eq '^[0-9a-f]{64}$' || die "invalid checksum published for $archive_name"
146
+
147
+ if command -v sha256sum >/dev/null 2>&1; then
148
+ actual_hash=$(sha256sum "$archive_path" | awk '{ print tolower($1) }')
149
+ elif command -v shasum >/dev/null 2>&1; then
150
+ actual_hash=$(shasum -a 256 "$archive_path" | awk '{ print tolower($1) }')
151
+ else
152
+ die "sha256sum or shasum is required to verify the download"
153
+ fi
154
+
155
+ [ "$actual_hash" = "$expected_hash" ] || die "checksum verification failed for $archive_name"
156
+
157
+ entry_count=$(tar -tzf "$archive_path" | awk '$0 == "etherscan" { count++ } END { print count + 0 }')
158
+ [ "$entry_count" -eq 1 ] || die "$archive_name must contain exactly one root-level etherscan"
159
+ tar -xOzf "$archive_path" etherscan >"$source_executable"
160
+ [ -s "$source_executable" ] || die "$archive_name contains an empty etherscan executable"
161
+
162
+ mkdir -p "$install_dir"
163
+ staged_executable="$install_dir/.etherscan.new.$$"
164
+ cp "$source_executable" "$staged_executable"
165
+ chmod 0755 "$staged_executable"
166
+ mv -f "$staged_executable" "$install_dir/etherscan"
167
+
168
+ path_updated=0
169
+ if [ "$update_path" -eq 1 ]; then
170
+ case ":$PATH:" in
171
+ *:"$install_dir":*) ;;
172
+ *)
173
+ shell_name=${SHELL:-sh}
174
+ shell_name=${shell_name##*/}
175
+ escaped_install_dir=$(printf '%s' "$install_dir" | sed 's/[\\"$`]/\\&/g')
176
+ if [ "$shell_name" = fish ]; then
177
+ profile="$HOME/.config/fish/config.fish"
178
+ mkdir -p "$(dirname "$profile")"
179
+ path_line="fish_add_path \"$escaped_install_dir\""
180
+ else
181
+ case "$shell_name" in
182
+ zsh) profile="$HOME/.zshrc" ;;
183
+ bash) profile="$HOME/.bashrc" ;;
184
+ *) profile="$HOME/.profile" ;;
185
+ esac
186
+ path_line="export PATH=\"$escaped_install_dir:\$PATH\""
187
+ fi
188
+
189
+ # Match the exact line we would write (whole-line, fixed-string) so an
190
+ # unrelated profile line that merely contains the path does not suppress
191
+ # the update, and a genuine duplicate is not appended.
192
+ if ! [ -f "$profile" ] || ! grep -Fx -e "$path_line" "$profile" >/dev/null 2>&1; then
193
+ {
194
+ printf '\n# Etherscan CLI\n'
195
+ printf '%s\n' "$path_line"
196
+ } >>"$profile"
197
+ path_updated=1
198
+ fi
199
+ ;;
200
+ esac
201
+ fi
202
+
203
+ printf '\nEtherscan CLI %s installed successfully.\n' "$release_version"
204
+ printf 'Installed to: %s\n' "$install_dir/etherscan"
205
+ if [ "$update_path" -eq 0 ]; then
206
+ printf 'Add %s to PATH to run etherscan from any directory.\n' "$install_dir"
207
+ elif [ "$path_updated" -eq 1 ]; then
208
+ printf 'Open a new terminal, then run: etherscan version\n'
209
+ else
210
+ printf 'Run: etherscan version\n'
211
+ fi