Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
defer TUI API key setup until endpoint use
  • Loading branch information
samuel-jw committed Jul 22, 2026
commit 604c7829a9c819c72e1708f40c748d19abd0177b
90 changes: 40 additions & 50 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,13 @@ func runtime(state *globalState) (resolvedRuntime, error) {
if key == "" {
return resolvedRuntime{}, errNoAPIKey
}
return buildRuntime(state, cfg, key)
}

// buildRuntime constructs the shared runtime from already-loaded configuration.
// Most CLI commands call runtime(), which rejects an empty key first. The TUI is
// the sole caller allowed to pass an empty key so users can browse before setup.
func buildRuntime(state *globalState, cfg config.File, key string) (resolvedRuntime, error) {
chainInput := firstNonEmpty(state.chain, os.Getenv("ETHERSCAN_CHAIN"), cfg.DefaultChain, "ethereum")
chain, err := chains.Resolve(chainInput)
if err != nil {
Expand Down Expand Up @@ -639,26 +646,22 @@ func interactiveTTY() bool {
}

// launchTUI resolves the runtime once and hands the interactive explorer the
// endpoint list plus an executor that reuses the existing client/call path. With
// no key resolved it runs the first-launch setup screen first; a key saved there
// lands in the config file, so the runtime resolution below picks it up.
// endpoint list plus an executor that reuses the existing client/call path. An
// empty key is allowed here so first-time users can explore locally; the TUI asks
// for and validates a key only when an API-backed endpoint is submitted.
func launchTUI(ctx context.Context, state *globalState, info BuildInfo) error {
cfg, _, err := config.Load()
if err != nil {
return err
}
if resolveKey(state, cfg) == "" {
if err := runSetup(ctx, state); err != nil {
return err
}
cfg, _, _ = config.Load()
}
rt, err := runtime(state)
key := resolveKey(state, cfg)
rt, err := buildRuntime(state, cfg, key)
if err != nil {
return err
}
baseURL := firstNonEmpty(state.baseURL, os.Getenv("ETHERSCAN_BASE_URL"), cfg.BaseURL, client.DefaultBaseURL)
keyLabel := "none"
if key := resolveKey(state, cfg); key != "" {
if key != "" {
keyLabel = maskKey(key)
}
eps, index := tuiEndpoints()
Expand All @@ -671,13 +674,39 @@ func launchTUI(ctx context.Context, state *globalState, info BuildInfo) error {
}
return chain.DisplayName, chain.ID, nil
}
saveKey := func(ctx context.Context, key string) (string, error) {
key = strings.TrimSpace(key)
if key == "" {
return "", errors.New("empty API key")
}
if err := checkKeyShape(key); err != nil {
return "", err
}
if err := validateKeyLive(ctx, state, key, rt.chain.ID, baseURL); err != nil {
return "", err
}
latest, _, err := config.Load()
if err != nil {
return "", err
}
latest.BaseURL = baseURL
latest.DefaultChain = rt.chain.Name
config.StoreAPIKey(key, &latest)
if _, err := config.Save(latest); err != nil {
return "", err
}
rt.client = rt.client.WithAPIKey(key)
return maskKey(key), nil
}
return tui.Run(ctx, tui.Config{
Endpoints: eps,
Exec: tuiExec(&rt, index),
Validate: tuiValidate(&rt, index),
ChainName: rt.chain.DisplayName,
ChainID: rt.chain.ID,
KeyLabel: keyLabel,
HasAPIKey: key != "",
SaveAPIKey: saveKey,
Chains: tuiChains(),
SwitchChain: switchChain,
})
Expand All @@ -696,45 +725,6 @@ func tuiChains() []tui.ChainInfo {
return out
}

// runSetup runs the TUI first-launch key screen. Its Save closure applies the
// same shape check, live validation, and persistence as `etherscan login`, so a
// key accepted here behaves identically to one saved via login.
func runSetup(ctx context.Context, state *globalState) error {
save := func(ctx context.Context, key string) error {
key = strings.TrimSpace(key)
if key == "" {
return errors.New("empty API key")
}
if err := checkKeyShape(key); err != nil {
return err
}
cfg, _, err := config.Load()
if err != nil {
return err
}
chain, err := chains.Resolve(firstNonEmpty(state.chain, cfg.DefaultChain, "ethereum"))
if err != nil {
return err
}
baseURL := firstNonEmpty(state.baseURL, cfg.BaseURL, client.DefaultBaseURL)
if err := validateKeyLive(ctx, state, key, chain.ID, baseURL); err != nil {
return err
}
cfg.BaseURL = baseURL
cfg.DefaultChain = chain.Name
config.StoreAPIKey(key, &cfg)
_, err = config.Save(cfg)
return err
}
if err := tui.RunSetup(ctx, tui.SetupConfig{Save: save}); err != nil {
if errors.Is(err, tui.ErrSetupAborted) {
return errNoAPIKey
}
return err
}
return nil
}

// tuiValidate builds the pre-call guard shared by the TUI form (inline errors on
// submit) and the executor: the mainnet-only check and validateParams — the SAME
// guards the normal CLI path (endpointCommand RunE) applies, so the TUI cannot
Expand Down
12 changes: 12 additions & 0 deletions internal/cli/runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,18 @@ func TestRuntimeRequiresKey(t *testing.T) {
}
}

func TestBuildRuntimeAllowsEmptyKeyForTUI(t *testing.T) {
t.Setenv("ETHERSCAN_API_KEY", "")
state := &globalState{timeout: 5 * time.Second, rate: 3}
rt, err := buildRuntime(state, config.File{}, "")
if err != nil {
t.Fatalf("keyless TUI runtime failed: %v", err)
}
if rt.client == nil || rt.chain.ID != "1" {
t.Fatalf("incomplete keyless runtime: client=%v chain=%+v", rt.client, rt.chain)
}
}

func TestRebindRuntimeChainPreservesSession(t *testing.T) {
ethereum, err := chains.Resolve("ethereum")
if err != nil {
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/tui_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ func TestTuiExecChainList(t *testing.T) {
defer srv.Close()

rt := resolvedRuntime{
client: client.New(client.Options{BaseURL: srv.URL + "/v2/api", APIKey: "k", ChainID: "1", RateLimit: 1000}),
client: client.New(client.Options{BaseURL: srv.URL + "/v2/api", ChainID: "1", RateLimit: 1000}),
chain: chains.Chain{ID: "1", Name: "ethereum"},
}
// Empty index on purpose: chainlist must not need a spec entry.
Expand Down
8 changes: 8 additions & 0 deletions internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,14 @@ func (c *Client) ForChain(chainID string) *Client {
return &clone
}

// WithAPIKey returns a client using apiKey while preserving the existing
// transport, rate limiter, chain, and diagnostic settings.
func (c *Client) WithAPIKey(apiKey string) *Client {
clone := *c
clone.apiKey = apiKey
return &clone
}

func (c *Client) Get(ctx context.Context, module, action string, params map[string]string, retryable bool) (Result, error) {
values := url.Values{}
values.Set("module", module)
Expand Down
21 changes: 21 additions & 0 deletions internal/client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,27 @@ func TestClientForChainPreservesSession(t *testing.T) {
}
}

func TestWithAPIKeyClonesWithoutMutatingOriginal(t *testing.T) {
var queries []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
queries = append(queries, r.URL.RawQuery)
fmt.Fprint(w, `{"status":"1","message":"OK","result":"1"}`)
}))
defer srv.Close()

original := New(Options{BaseURL: srv.URL, ChainID: "1", RateLimit: 1000})
withKey := original.WithAPIKey("TESTKEY")
if _, err := original.Get(context.Background(), "account", "balance", nil, false); err != nil {
t.Fatal(err)
}
if _, err := withKey.Get(context.Background(), "account", "balance", nil, false); err != nil {
t.Fatal(err)
}
if len(queries) != 2 || strings.Contains(queries[0], "apikey=") || !strings.Contains(queries[1], "apikey=TESTKEY") {
t.Fatalf("unexpected original/clone queries: %v", queries)
}
}

func TestChainList(t *testing.T) {
var gotPath, gotQuery string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down
133 changes: 0 additions & 133 deletions internal/tui/setup.go

This file was deleted.

Loading