Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions authconfig/authconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
// Config holds the persisted CLI configuration.
type Config struct {
APIKey string `yaml:"api_key"`
SimAPIKey string `yaml:"sim_api_key,omitempty"`
Telemetry *bool `yaml:"telemetry,omitempty"`
}

Expand Down
2 changes: 2 additions & 0 deletions cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"github.com/duneanalytics/cli/cmd/docs"
"github.com/duneanalytics/cli/cmd/execution"
"github.com/duneanalytics/cli/cmd/query"
"github.com/duneanalytics/cli/cmd/sim"
"github.com/duneanalytics/cli/cmd/usage"
"github.com/duneanalytics/cli/cmdutil"
"github.com/duneanalytics/cli/tracking"
Expand Down Expand Up @@ -106,6 +107,7 @@ func init() {
rootCmd.AddCommand(query.NewQueryCmd())
rootCmd.AddCommand(execution.NewExecutionCmd())
rootCmd.AddCommand(usage.NewUsageCmd())
rootCmd.AddCommand(sim.NewSimCmd())
}

// Execute runs the root command via Fang.
Expand Down
63 changes: 63 additions & 0 deletions cmd/sim/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package sim

import (
"bufio"
"fmt"
"os"
"strings"

"github.com/spf13/cobra"

"github.com/duneanalytics/cli/authconfig"
)

// NewAuthCmd returns the `sim auth` command.
func NewAuthCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "auth",
Short: "Authenticate with the Sim API",
Long: "Save your Sim API key so you don't need to pass --sim-api-key or set DUNE_SIM_API_KEY every time.",
Annotations: map[string]string{"skipSimAuth": "true"},
RunE: runSimAuth,
}

cmd.Flags().String("api-key", "", "Sim API key to save")

return cmd
}

func runSimAuth(cmd *cobra.Command, _ []string) error {
key, _ := cmd.Flags().GetString("api-key")

if key == "" {
key = os.Getenv("DUNE_SIM_API_KEY")
}

if key == "" {
fmt.Fprint(cmd.ErrOrStderr(), "Enter your Sim API key: ")
scanner := bufio.NewScanner(cmd.InOrStdin())
if scanner.Scan() {
key = strings.TrimSpace(scanner.Text())
}
}

if key == "" {
return fmt.Errorf("no API key provided")
}

cfg, err := authconfig.Load()
if err != nil {
return fmt.Errorf("loading existing config: %w", err)
}
if cfg == nil {
cfg = &authconfig.Config{}
}
cfg.SimAPIKey = key
if err := authconfig.Save(cfg); err != nil {
return fmt.Errorf("saving config: %w", err)
}

p, _ := authconfig.Path()
fmt.Fprintf(cmd.OutOrStdout(), "Sim API key saved to %s\n", p)
return nil
}
124 changes: 124 additions & 0 deletions cmd/sim/auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package sim_test

import (
"bytes"
"context"
"os"
"path/filepath"
"strings"
"testing"

"github.com/duneanalytics/cli/authconfig"
"github.com/duneanalytics/cli/cmd/sim"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)

func setupAuthTest(t *testing.T) string {
t.Helper()
dir := t.TempDir()
authconfig.SetDirFunc(func() (string, error) { return dir, nil })
t.Cleanup(authconfig.ResetDirFunc)
// Clear env var so it doesn't interfere with tests.
t.Setenv("DUNE_SIM_API_KEY", "")
return dir
}

func newSimAuthRoot() *cobra.Command {
root := &cobra.Command{Use: "dune"}
root.SetContext(context.Background())

simCmd := sim.NewSimCmd()
root.AddCommand(simCmd)

return root
}

func TestSimAuth_WithFlag(t *testing.T) {
dir := setupAuthTest(t)

root := newSimAuthRoot()
var buf bytes.Buffer
root.SetOut(&buf)
root.SetArgs([]string{"sim", "auth", "--api-key", "sk_sim_flag_key"})
require.NoError(t, root.Execute())

data, err := os.ReadFile(filepath.Join(dir, "config.yaml"))
require.NoError(t, err)
assert.Contains(t, string(data), "sk_sim_flag_key")
assert.Contains(t, buf.String(), "Sim API key saved to")
}

func TestSimAuth_WithEnvVar(t *testing.T) {
dir := setupAuthTest(t)

t.Setenv("DUNE_SIM_API_KEY", "sk_sim_env_key")

root := newSimAuthRoot()
var buf bytes.Buffer
root.SetOut(&buf)
root.SetArgs([]string{"sim", "auth"})
require.NoError(t, root.Execute())

data, err := os.ReadFile(filepath.Join(dir, "config.yaml"))
require.NoError(t, err)
assert.Contains(t, string(data), "sk_sim_env_key")
}

func TestSimAuth_WithPrompt(t *testing.T) {
dir := setupAuthTest(t)

root := newSimAuthRoot()
var buf bytes.Buffer
root.SetOut(&buf)
root.SetIn(strings.NewReader("sk_sim_prompt_key\n"))
root.SetArgs([]string{"sim", "auth"})
require.NoError(t, root.Execute())

data, err := os.ReadFile(filepath.Join(dir, "config.yaml"))
require.NoError(t, err)
assert.Contains(t, string(data), "sk_sim_prompt_key")
}

func TestSimAuth_EmptyInput(t *testing.T) {
setupAuthTest(t)

root := newSimAuthRoot()
root.SetIn(strings.NewReader("\n"))
root.SetArgs([]string{"sim", "auth"})
err := root.Execute()
assert.Error(t, err)
assert.Contains(t, err.Error(), "no API key provided")
}

func TestSimAuth_PreservesExistingConfig(t *testing.T) {
dir := setupAuthTest(t)

// Pre-populate config with existing fields.
existing := &authconfig.Config{
APIKey: "existing_dune_key",
}
telemetryTrue := true
existing.Telemetry = &telemetryTrue
require.NoError(t, authconfig.Save(existing))

root := newSimAuthRoot()
var buf bytes.Buffer
root.SetOut(&buf)
root.SetArgs([]string{"sim", "auth", "--api-key", "sk_sim_new"})
require.NoError(t, root.Execute())

// Verify all fields are preserved.
data, err := os.ReadFile(filepath.Join(dir, "config.yaml"))
require.NoError(t, err)

var cfg authconfig.Config
require.NoError(t, yaml.Unmarshal(data, &cfg))

assert.Equal(t, "existing_dune_key", cfg.APIKey, "existing api_key should be preserved")
assert.Equal(t, "sk_sim_new", cfg.SimAPIKey, "sim_api_key should be set")
require.NotNil(t, cfg.Telemetry, "telemetry should be preserved")
assert.True(t, *cfg.Telemetry, "telemetry value should be preserved")
}
110 changes: 110 additions & 0 deletions cmd/sim/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package sim

import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
)

const defaultBaseURL = "https://api.sim.dune.com"

// SimClient is a lightweight HTTP client for the Sim API.
type SimClient struct {
baseURL string
apiKey string
httpClient *http.Client
}

// NewSimClient creates a new Sim API client with the given API key.
func NewSimClient(apiKey string) *SimClient {
return &SimClient{
baseURL: defaultBaseURL,
apiKey: apiKey,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}

// Get performs a GET request to the Sim API and returns the raw JSON response body.
// The path should include the leading slash (e.g. "/v1/evm/supported-chains").
// Query parameters are appended from params.
func (c *SimClient) Get(ctx context.Context, path string, params url.Values) ([]byte, error) {
u, err := url.Parse(c.baseURL + path)
if err != nil {
return nil, fmt.Errorf("invalid URL: %w", err)
}
if params != nil {
u.RawQuery = params.Encode()
}

req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("X-Sim-Api-Key", c.apiKey)
req.Header.Set("Accept", "application/json")

resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()

body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("reading response: %w", err)
}

if resp.StatusCode >= 400 {
return nil, httpError(resp.StatusCode, body)
}

return body, nil
}

// httpError returns a user-friendly error for HTTP error status codes.
func httpError(status int, body []byte) error {
// Try to extract a message from the JSON error response.
var errResp struct {
Error string `json:"error"`
Message string `json:"message"`
}
msg := ""
if json.Unmarshal(body, &errResp) == nil {
if errResp.Error != "" {
msg = errResp.Error
} else if errResp.Message != "" {
msg = errResp.Message
}
}

switch status {
case http.StatusBadRequest:
if msg != "" {
return fmt.Errorf("bad request: %s", msg)
}
return fmt.Errorf("bad request")
case http.StatusUnauthorized:
return fmt.Errorf("authentication failed: check your Sim API key")
case http.StatusNotFound:
if msg != "" {
return fmt.Errorf("not found: %s", msg)
}
return fmt.Errorf("not found")
case http.StatusTooManyRequests:
return fmt.Errorf("rate limit exceeded: try again later")
default:
if status >= 500 {
return fmt.Errorf("Sim API server error (HTTP %d): try again later", status)
}
if msg != "" {
return fmt.Errorf("Sim API error (HTTP %d): %s", status, msg)
}
return fmt.Errorf("Sim API error (HTTP %d)", status)
}
}
20 changes: 20 additions & 0 deletions cmd/sim/evm/evm.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package evm

import (
"github.com/spf13/cobra"
)

// NewEvmCmd returns the `sim evm` parent command.
func NewEvmCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "evm",
Short: "Query EVM chain data (balances, activity, transactions, etc.)",
Long: "Access real-time EVM blockchain data including token balances, activity feeds,\n" +
"transaction history, NFT collectibles, token metadata, token holders,\n" +
"and DeFi positions.",
}

// Subcommands will be added here as they are implemented.

return cmd
}
Loading
Loading