Skip to content

Commit 0066b40

Browse files
committed
add uninstall
1 parent eb2f5ec commit 0066b40

15 files changed

Lines changed: 1470 additions & 33 deletions

‎README.md‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,30 @@ Ensure your Go binary directory (`GOBIN`, or `GOPATH/bin` by default) is on `PAT
9494

9595
Download the archive for your operating system and architecture plus `checksums.txt` from [GitHub Releases](https://github.com/etherscan/etherscan-cli/releases/latest). Verify the archive's SHA-256 checksum, extract it, and place `etherscan` (or `etherscan.exe`) on your `PATH`.
9696

97+
## Uninstall
98+
99+
Run:
100+
101+
```sh
102+
etherscan uninstall
103+
```
104+
105+
The command shows the exact package-manager action or executable and configuration paths before asking for confirmation. Use `--yes` to skip the prompt. Homebrew and npm installations are removed through their owning package manager. Installer, Go, manual, and source builds remove only the exact running executable; custom directories and `PATH` entries are left alone unless the installer recorded that it added them and the directory contains no other files.
106+
107+
The command never requests administrator privileges. If a manually copied binary is in a protected location, it leaves the saved configuration intact and prints the exact command needed to remove the binary. Manually created aliases or separate symlinks may also need to be removed by hand.
108+
109+
The installer scripts can remove a script installation without downloading a release archive:
110+
111+
```sh
112+
curl -fsSL https://raw.githubusercontent.com/etherscan/etherscan-cli/master/scripts/install.sh | sh -s -- --uninstall
113+
```
114+
115+
```powershell
116+
& ([scriptblock]::Create((irm https://raw.githubusercontent.com/etherscan/etherscan-cli/master/scripts/install.ps1))) -Uninstall
117+
```
118+
119+
Uninstall removes the saved configuration directory. If `ETHERSCAN_API_KEY` is set, unset it separately in the shell or environment where it is defined.
120+
97121
## Get started
98122

99123
### 1. Create an API key
@@ -223,7 +247,7 @@ etherscan contract verify --help
223247
| `etherscan tui` | Launch the interactive explorer |
224248
| `etherscan login` | Validate and store an API key |
225249
| `etherscan logout` | Remove the stored API key |
226-
| `etherscan uninstall` | Remove all CLI configuration |
250+
| `etherscan uninstall` | Remove the CLI and saved configuration |
227251
| `etherscan update` | Update a Homebrew or installer-script installation |
228252
| `etherscan whoami` | Show the active chain and masked API key |
229253
| `etherscan config` | Get, list, or set CLI configuration |

‎internal/cli/root.go‎

Lines changed: 49 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ type updateManager interface {
5757
Skip(string) error
5858
DetectMethod() string
5959
Upgrade(context.Context, string, string, io.Writer, io.Writer) (bool, error)
60+
Uninstall(context.Context, string, io.Writer, io.Writer) (bool, error)
6061
}
6162

6263
func NewRootCommand(info BuildInfo) *cobra.Command {
@@ -104,7 +105,7 @@ func newRootCommand(info BuildInfo, updates updateManager) *cobra.Command {
104105
return err
105106
})
106107

107-
root.AddCommand(loginCommand(state), logoutCommand(state), uninstallCommand(state), configCommand(state), chainsCommand(state), whoamiCommand(state), versionCommand(info), updateCommand(info, updates), tuiCommand(state, info, updates), completionCommand(root))
108+
root.AddCommand(loginCommand(state), logoutCommand(state), uninstallCommand(state, updates), configCommand(state), chainsCommand(state), whoamiCommand(state), versionCommand(info), updateCommand(info, updates), tuiCommand(state, info, updates), completionCommand(root))
108109
addEndpointCommands(root, state)
109110
return root
110111
}
@@ -162,7 +163,7 @@ func endpointCommand(state *globalState, spec EndpointSpec) *cobra.Command {
162163
return fmt.Errorf("%s/%s is only supported on Ethereum mainnet", spec.Module, spec.Action)
163164
}
164165
if spec.Sensitive && !state.yes {
165-
if err := confirm(cmd.Context(), fmt.Sprintf("Submit %s/%s?", spec.Module, spec.Action)); err != nil {
166+
if err := confirm(cmd.Context(), fmt.Sprintf("Submit %s/%s?", spec.Module, spec.Action), cmd.InOrStdin(), cmd.ErrOrStderr()); err != nil {
166167
return err
167168
}
168169
}
@@ -440,40 +441,69 @@ func logoutCommand(state *globalState) *cobra.Command {
440441
}
441442
}
442443

443-
func uninstallCommand(state *globalState) *cobra.Command {
444+
func uninstallCommand(state *globalState, updates updateManager) *cobra.Command {
444445
return &cobra.Command{
445446
Use: "uninstall",
446-
Short: "Remove all etherscan CLI configuration (API key and settings)",
447+
Short: "Remove Etherscan CLI and its saved configuration",
447448
Args: cobra.NoArgs,
448449
RunE: func(cmd *cobra.Command, args []string) error {
449-
path, err := config.DefaultPath()
450+
method := updates.DetectMethod()
451+
prompt, err := uninstallPrompt(method)
450452
if err != nil {
451453
return err
452454
}
453-
dir := filepath.Dir(path)
454-
if _, err := os.Stat(dir); errors.Is(err, os.ErrNotExist) {
455-
fmt.Fprintln(os.Stdout, "Nothing to remove; no configuration found.")
456-
return nil
457-
} else if err != nil {
458-
return err
459-
}
460455
if !state.yes {
461-
if err := confirm(cmd.Context(), fmt.Sprintf("Remove all configuration in %s?", dir)); err != nil {
456+
if err := confirm(cmd.Context(), prompt, cmd.InOrStdin(), cmd.ErrOrStderr()); err != nil {
462457
return err
463458
}
464459
}
465-
if err := os.RemoveAll(dir); err != nil {
460+
background, err := updates.Uninstall(cmd.Context(), method, cmd.OutOrStdout(), cmd.ErrOrStderr())
461+
if err != nil {
466462
return err
467463
}
468-
fmt.Fprintf(os.Stdout, "Removed %s\n", dir)
464+
if background {
465+
fmt.Fprintln(cmd.OutOrStdout(), "Uninstall scheduled; removal will finish after this process exits.")
466+
} else {
467+
fmt.Fprintln(cmd.OutOrStdout(), "Etherscan CLI uninstalled.")
468+
}
469+
if method == updater.MethodScript {
470+
fmt.Fprintln(cmd.ErrOrStderr(), "note: manually created aliases or symlinks may still need to be removed.")
471+
}
469472
if os.Getenv("ETHERSCAN_API_KEY") != "" {
470-
fmt.Fprintln(os.Stderr, "note: ETHERSCAN_API_KEY is still set in your environment; unset it in your shell to fully remove the key.")
473+
fmt.Fprintln(cmd.ErrOrStderr(), "note: ETHERSCAN_API_KEY is still set in your environment; unset it in your shell to fully remove the key.")
471474
}
472475
return nil
473476
},
474477
}
475478
}
476479

480+
func uninstallPrompt(method string) (string, error) {
481+
configPath, err := config.DefaultPath()
482+
if err != nil {
483+
return "", err
484+
}
485+
var action string
486+
switch method {
487+
case updater.MethodHomebrew:
488+
action = " run brew uninstall etherscan/etherscan-cli/etherscan"
489+
case updater.MethodNPM:
490+
packageName, err := updater.NPMPackageName()
491+
if err != nil {
492+
return "", err
493+
}
494+
action = " run npm uninstall -g " + packageName
495+
case updater.MethodScript:
496+
executable, err := os.Executable()
497+
if err != nil {
498+
return "", fmt.Errorf("locate current executable: %w", err)
499+
}
500+
action = fmt.Sprintf(" binary %s\n PATH removed only with installer provenance and no shared files", executable)
501+
default:
502+
return "", fmt.Errorf("unsupported uninstall method %q", method)
503+
}
504+
return fmt.Sprintf("This will remove Etherscan CLI:\n%s\n config %s\n\nProceed?", action, filepath.Dir(configPath)), nil
505+
}
506+
477507
func configCommand(state *globalState) *cobra.Command {
478508
cmd := &cobra.Command{Use: "config", Short: "Manage CLI configuration"}
479509
cmd.AddCommand(&cobra.Command{Use: "list", Short: "Show configuration", RunE: func(cmd *cobra.Command, args []string) error {
@@ -1267,11 +1297,11 @@ func populateFileParam(state *globalState, params map[string]string) error {
12671297
return nil
12681298
}
12691299

1270-
func confirm(ctx context.Context, prompt string) error {
1271-
fmt.Fprintf(os.Stderr, "%s [y/N] ", prompt)
1300+
func confirm(ctx context.Context, prompt string, input io.Reader, output io.Writer) error {
1301+
fmt.Fprintf(output, "%s [y/N] ", prompt)
12721302
done := make(chan string, 1)
12731303
go func() {
1274-
reader := bufio.NewReader(os.Stdin)
1304+
reader := bufio.NewReader(input)
12751305
line, _ := reader.ReadString('\n')
12761306
done <- strings.TrimSpace(strings.ToLower(line))
12771307
}()

‎internal/cli/uninstall_test.go‎

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package cli
2+
3+
import (
4+
"bytes"
5+
"errors"
6+
"strings"
7+
"testing"
8+
9+
"github.com/etherscan/etherscan-cli/internal/updater"
10+
)
11+
12+
func TestUninstallWithYesUsesDetectedMethod(t *testing.T) {
13+
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
14+
manager := &fakeUpdateManager{method: updater.MethodScript}
15+
root := newRootCommand(BuildInfo{Version: "1.2.0"}, manager)
16+
var out bytes.Buffer
17+
root.SetOut(&out)
18+
root.SetErr(&out)
19+
root.SetArgs([]string{"uninstall", "--yes"})
20+
if err := root.Execute(); err != nil {
21+
t.Fatal(err)
22+
}
23+
if manager.uninstallCalls != 1 || manager.uninstalledMethod != updater.MethodScript {
24+
t.Fatalf("unexpected uninstall: %+v", manager)
25+
}
26+
if !strings.Contains(out.String(), "uninstalled") || !strings.Contains(out.String(), "aliases or symlinks") {
27+
t.Fatalf("unexpected output: %q", out.String())
28+
}
29+
}
30+
31+
func TestUninstallConfirmationDeclinedMakesNoChanges(t *testing.T) {
32+
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
33+
manager := &fakeUpdateManager{method: updater.MethodHomebrew}
34+
root := newRootCommand(BuildInfo{}, manager)
35+
var out bytes.Buffer
36+
root.SetIn(strings.NewReader("no\n"))
37+
root.SetOut(&out)
38+
root.SetErr(&out)
39+
root.SetArgs([]string{"uninstall"})
40+
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "cancelled") {
41+
t.Fatalf("Execute() error = %v", err)
42+
}
43+
if manager.uninstallCalls != 0 {
44+
t.Fatalf("uninstall ran %d times", manager.uninstallCalls)
45+
}
46+
if !strings.Contains(out.String(), "brew uninstall") || !strings.Contains(out.String(), "config") {
47+
t.Fatalf("prompt did not list targets: %q", out.String())
48+
}
49+
}
50+
51+
func TestUninstallConfirmationAccepted(t *testing.T) {
52+
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
53+
manager := &fakeUpdateManager{method: updater.MethodHomebrew}
54+
root := newRootCommand(BuildInfo{}, manager)
55+
root.SetIn(strings.NewReader("yes\n"))
56+
root.SetOut(&bytes.Buffer{})
57+
root.SetErr(&bytes.Buffer{})
58+
root.SetArgs([]string{"uninstall"})
59+
if err := root.Execute(); err != nil {
60+
t.Fatal(err)
61+
}
62+
if manager.uninstallCalls != 1 {
63+
t.Fatalf("uninstall ran %d times", manager.uninstallCalls)
64+
}
65+
}
66+
67+
func TestUninstallFailureIsNotReportedAsSuccess(t *testing.T) {
68+
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
69+
manager := &fakeUpdateManager{method: updater.MethodScript, uninstallErr: errors.New("permission denied")}
70+
root := newRootCommand(BuildInfo{}, manager)
71+
var out bytes.Buffer
72+
root.SetOut(&out)
73+
root.SetErr(&out)
74+
root.SetArgs([]string{"uninstall", "--yes"})
75+
if err := root.Execute(); err == nil {
76+
t.Fatal("expected uninstall failure")
77+
}
78+
if strings.Contains(out.String(), "CLI uninstalled") {
79+
t.Fatalf("failure reported success: %q", out.String())
80+
}
81+
}
82+
83+
func TestUninstallNPMRejectsUntrustedPackageBeforeConfirmation(t *testing.T) {
84+
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
85+
t.Setenv(updater.NPMInstallPackageEnv, "malicious-package")
86+
manager := &fakeUpdateManager{method: updater.MethodNPM}
87+
root := newRootCommand(BuildInfo{}, manager)
88+
root.SetIn(strings.NewReader("yes\n"))
89+
root.SetOut(&bytes.Buffer{})
90+
root.SetErr(&bytes.Buffer{})
91+
root.SetArgs([]string{"uninstall"})
92+
if err := root.Execute(); err == nil {
93+
t.Fatal("expected untrusted package error")
94+
}
95+
if manager.uninstallCalls != 0 {
96+
t.Fatal("uninstall ran for untrusted npm package")
97+
}
98+
}

‎internal/cli/update_test.go‎

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,16 @@ import (
1111
)
1212

1313
type fakeUpdateManager struct {
14-
result updater.Result
15-
checkErr error
16-
method string
17-
skipped string
18-
upgradedMethod string
19-
upgradedVersion string
20-
background bool
14+
result updater.Result
15+
checkErr error
16+
method string
17+
skipped string
18+
upgradedMethod string
19+
upgradedVersion string
20+
background bool
21+
uninstalledMethod string
22+
uninstallCalls int
23+
uninstallErr error
2124
}
2225

2326
func (f *fakeUpdateManager) Check(context.Context, string, bool) (updater.Result, error) {
@@ -37,6 +40,12 @@ func (f *fakeUpdateManager) Upgrade(_ context.Context, method, version string, _
3740
return f.background, nil
3841
}
3942

43+
func (f *fakeUpdateManager) Uninstall(_ context.Context, method string, _, _ io.Writer) (bool, error) {
44+
f.uninstallCalls++
45+
f.uninstalledMethod = method
46+
return f.background, f.uninstallErr
47+
}
48+
4049
func TestOfferUpdateChoices(t *testing.T) {
4150
result := updater.Result{
4251
Current: "1.1.0",

0 commit comments

Comments
 (0)