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
68 changes: 68 additions & 0 deletions authconfig/identity.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package authconfig

import (
"crypto/sha256"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"

"gopkg.in/yaml.v3"
)

// UserIdentity holds the cached identity resolved from the API key.
type UserIdentity struct {
CustomerID string `yaml:"customer_id"` // e.g. "user_123" or "team_456"
APIKeyHash string `yaml:"api_key_hash"` // SHA-256 of the API key for cache invalidation
}

// identityFileName is the file storing the cached user identity.
const identityFileName = "user_identity.yaml"

// LoadIdentity reads the cached user identity. Returns nil, nil if the file does not exist.
func LoadIdentity() (*UserIdentity, error) {
dir, err := Dir()
if err != nil {
return nil, err
}

data, err := os.ReadFile(filepath.Join(dir, identityFileName))
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil, nil
}
return nil, err
}

var id UserIdentity
if err := yaml.Unmarshal(data, &id); err != nil {
return nil, err
}
return &id, nil
}

// SaveIdentity writes the user identity cache to disk.
func SaveIdentity(id *UserIdentity) error {
dir, err := Dir()
Comment thread
ivpusic marked this conversation as resolved.
if err != nil {
return err
}

if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}

data, err := yaml.Marshal(id)
if err != nil {
return err
}

return os.WriteFile(filepath.Join(dir, identityFileName), data, 0o600)
}

// HashAPIKey returns the hex-encoded SHA-256 hash of an API key.
func HashAPIKey(apiKey string) string {
h := sha256.Sum256([]byte(apiKey))
return fmt.Sprintf("%x", h)
}
38 changes: 38 additions & 0 deletions authconfig/identity_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package authconfig_test

import (
"testing"

"github.com/duneanalytics/cli/authconfig"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestSaveAndLoadIdentity(t *testing.T) {
setupTempDir(t)

want := &authconfig.UserIdentity{
CustomerID: "user_42",
APIKeyHash: authconfig.HashAPIKey("test-api-key"),
}
require.NoError(t, authconfig.SaveIdentity(want))

got, err := authconfig.LoadIdentity()
require.NoError(t, err)
assert.Equal(t, want, got)
}

func TestLoadIdentityNonExistent(t *testing.T) {
setupTempDir(t)

id, err := authconfig.LoadIdentity()
assert.NoError(t, err)
assert.Nil(t, id)
}

func TestHashAPIKeyDeterministic(t *testing.T) {
h1 := authconfig.HashAPIKey("my-key")
h2 := authconfig.HashAPIKey("my-key")
assert.Equal(t, h1, h2)
assert.NotEqual(t, h1, authconfig.HashAPIKey("other-key"))
}
45 changes: 41 additions & 4 deletions cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"github.com/duneanalytics/cli/cmd/query"
"github.com/duneanalytics/cli/cmd/sim"
"github.com/duneanalytics/cli/cmd/usage"
"github.com/duneanalytics/cli/cmd/whoami"
"github.com/duneanalytics/cli/cmdutil"
"github.com/duneanalytics/cli/tracking"
)
Expand Down Expand Up @@ -77,6 +78,13 @@ var rootCmd = &cobra.Command{
client := dune.NewDuneClient(env)
cmdutil.SetClient(cmd, client)

// Resolve customer identity for analytics (best-effort, never blocks the CLI).
if tr := cmdutil.TrackerFromCmd(cmd); tr != nil {
if customerID := resolveCustomerID(client, env.APIKey); customerID != "" {
tr.SetUserID(customerID)
}
}
Comment thread
ivpusic marked this conversation as resolved.
Comment thread
ivpusic marked this conversation as resolved.

return nil
},
PersistentPostRunE: func(cmd *cobra.Command, _ []string) error {
Expand All @@ -93,7 +101,8 @@ var rootCmd = &cobra.Command{
commandPath = parts[1]
}

tr.Track(commandPath, tracking.StatusSuccess, "", durationMs)
isSim := strings.HasPrefix(commandPath, "sim")
tr.Track(commandPath, tracking.StatusSuccess, "", durationMs, isSim)
return nil
},
}
Expand All @@ -107,6 +116,7 @@ func init() {
rootCmd.AddCommand(query.NewQueryCmd())
rootCmd.AddCommand(execution.NewExecutionCmd())
rootCmd.AddCommand(usage.NewUsageCmd())
rootCmd.AddCommand(whoami.NewWhoAmICmd())
rootCmd.AddCommand(sim.NewSimCmd())
}

Expand All @@ -115,11 +125,9 @@ func Execute(version, commit, date, amplitudeKey string) {
versionStr := fmt.Sprintf("%s (commit: %s, built: %s)", version, commit, date)

telemetryEnabled := duneconfig.IsTelemetryEnabled()
configDir, _ := authconfig.Dir()
tracker := tracking.New(tracking.Config{
AmplitudeKey: amplitudeKey,
CLIVersion: version,
ConfigDir: configDir,
Enabled: telemetryEnabled,
})
defer tracker.Shutdown()
Expand All @@ -132,7 +140,8 @@ func Execute(version, commit, date, amplitudeKey string) {
); err != nil {
// Build best-effort command path from os.Args (strip flags).
commandPath := commandPathFromArgs(os.Args)
tracker.Track(commandPath, tracking.StatusError, err.Error(), 0)
isSim := strings.HasPrefix(commandPath, "sim")
tracker.Track(commandPath, tracking.StatusError, err.Error(), 0, isSim)
// Flush the event before exiting — os.Exit does not run deferred funcs,
// so defer tracker.Shutdown() above would never fire.
tracker.Shutdown()
Expand All @@ -142,6 +151,34 @@ func Execute(version, commit, date, amplitudeKey string) {
}
}

// resolveCustomerID returns the customer_id associated with the given API key.
// The customer_id may represent a user ("user_123") or a team ("team_456").
// It uses a local cache to avoid calling /api/whoami on every invocation.
// On any error it returns "" silently — analytics should never block the CLI.
func resolveCustomerID(client dune.DuneClient, apiKey string) string {
keyHash := authconfig.HashAPIKey(apiKey)

// Try the cache first.
cached, err := authconfig.LoadIdentity()
if err == nil && cached != nil && cached.APIKeyHash == keyHash && cached.CustomerID != "" {
return cached.CustomerID
}

// Cache miss or stale — call the API.
resp, err := client.WhoAmI()
if err != nil || resp == nil || resp.CustomerID == "" {
return ""
}

// Persist for next time (best-effort).
_ = authconfig.SaveIdentity(&authconfig.UserIdentity{
CustomerID: resp.CustomerID,
APIKeyHash: keyHash,
})

return resp.CustomerID
}

// commandPathFromArgs extracts the subcommand path from os.Args, skipping
// the binary name, flags, and flag values so the tracked path is e.g.
// "query list" even when invoked as "dune --api-key KEY query list --limit 10".
Expand Down
47 changes: 47 additions & 0 deletions cmd/whoami/whoami.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package whoami

import (
"fmt"

"github.com/duneanalytics/cli/cmdutil"
"github.com/duneanalytics/cli/output"
"github.com/spf13/cobra"
)

// NewWhoAmICmd returns the "whoami" command.
func NewWhoAmICmd() *cobra.Command {
cmd := &cobra.Command{
Use: "whoami",
Short: "Show the identity associated with the current API key",
Long: "Display the handle, customer ID, and API key ID of the currently\n" +
"configured API key. Useful for verifying which account is active.\n\n" +
"Examples:\n" +
" dune whoami\n" +
" dune whoami --output json",
RunE: runWhoAmI,
}

output.AddFormatFlag(cmd, "text")

return cmd
}

func runWhoAmI(cmd *cobra.Command, _ []string) error {
client := cmdutil.ClientFromCmd(cmd)

resp, err := client.WhoAmI()
if err != nil {
return fmt.Errorf("identifying API key: %w", err)
}

w := cmd.OutOrStdout()
switch output.FormatFromCmd(cmd) {
case output.FormatJSON:
return output.PrintJSON(w, resp)
default:
fmt.Fprintf(w, "Handle: %s\n", resp.Handle)
fmt.Fprintf(w, "Customer ID: %s\n", resp.CustomerID)
fmt.Fprintf(w, "API Key ID: %s\n", resp.APIKeyID)
return nil
}
}
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ go 1.25.6
require (
github.com/amplitude/analytics-go v1.3.0
github.com/charmbracelet/fang v0.4.4
github.com/duneanalytics/duneapi-client-go v0.4.3
github.com/duneanalytics/duneapi-client-go v0.4.4
github.com/modelcontextprotocol/go-sdk v1.4.0
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
golang.org/x/term v0.40.0
gopkg.in/yaml.v3 v3.0.1
)

Expand Down Expand Up @@ -45,6 +46,5 @@ require (
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/term v0.40.0 // indirect
golang.org/x/text v0.24.0 // indirect
)
6 changes: 2 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsV
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/duneanalytics/duneapi-client-go v0.4.3 h1:3meDMQPwGk6jrVntN4o2nm4CWuzobKnD4nmG52hWZ+0=
github.com/duneanalytics/duneapi-client-go v0.4.3/go.mod h1:7pXXufWvR/Mh2KOehdyBaunJXmHI+pzjUmyQTQhJjdE=
github.com/duneanalytics/duneapi-client-go v0.4.4 h1:vNI1musSDuqlICNXLdf059J3YwwonTi/GX5NhTrLdqI=
github.com/duneanalytics/duneapi-client-go v0.4.4/go.mod h1:7pXXufWvR/Mh2KOehdyBaunJXmHI+pzjUmyQTQhJjdE=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
Expand Down Expand Up @@ -87,8 +87,6 @@ golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
Expand Down
Loading
Loading