Skip to content

Commit acea182

Browse files
authored
Added whoami (#42)
* Added tracking based on whoami id * rename user to customer id * don't drop annon events * normalize user ids * dedicated flag for sim tracking
1 parent 3d18cf2 commit acea182

8 files changed

Lines changed: 321 additions & 70 deletions

File tree

authconfig/identity.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package authconfig
2+
3+
import (
4+
"crypto/sha256"
5+
"errors"
6+
"fmt"
7+
"io/fs"
8+
"os"
9+
"path/filepath"
10+
11+
"gopkg.in/yaml.v3"
12+
)
13+
14+
// UserIdentity holds the cached identity resolved from the API key.
15+
type UserIdentity struct {
16+
CustomerID string `yaml:"customer_id"` // e.g. "user_123" or "team_456"
17+
APIKeyHash string `yaml:"api_key_hash"` // SHA-256 of the API key for cache invalidation
18+
}
19+
20+
// identityFileName is the file storing the cached user identity.
21+
const identityFileName = "user_identity.yaml"
22+
23+
// LoadIdentity reads the cached user identity. Returns nil, nil if the file does not exist.
24+
func LoadIdentity() (*UserIdentity, error) {
25+
dir, err := Dir()
26+
if err != nil {
27+
return nil, err
28+
}
29+
30+
data, err := os.ReadFile(filepath.Join(dir, identityFileName))
31+
if err != nil {
32+
if errors.Is(err, fs.ErrNotExist) {
33+
return nil, nil
34+
}
35+
return nil, err
36+
}
37+
38+
var id UserIdentity
39+
if err := yaml.Unmarshal(data, &id); err != nil {
40+
return nil, err
41+
}
42+
return &id, nil
43+
}
44+
45+
// SaveIdentity writes the user identity cache to disk.
46+
func SaveIdentity(id *UserIdentity) error {
47+
dir, err := Dir()
48+
if err != nil {
49+
return err
50+
}
51+
52+
if err := os.MkdirAll(dir, 0o700); err != nil {
53+
return err
54+
}
55+
56+
data, err := yaml.Marshal(id)
57+
if err != nil {
58+
return err
59+
}
60+
61+
return os.WriteFile(filepath.Join(dir, identityFileName), data, 0o600)
62+
}
63+
64+
// HashAPIKey returns the hex-encoded SHA-256 hash of an API key.
65+
func HashAPIKey(apiKey string) string {
66+
h := sha256.Sum256([]byte(apiKey))
67+
return fmt.Sprintf("%x", h)
68+
}

authconfig/identity_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package authconfig_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/duneanalytics/cli/authconfig"
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func TestSaveAndLoadIdentity(t *testing.T) {
12+
setupTempDir(t)
13+
14+
want := &authconfig.UserIdentity{
15+
CustomerID: "user_42",
16+
APIKeyHash: authconfig.HashAPIKey("test-api-key"),
17+
}
18+
require.NoError(t, authconfig.SaveIdentity(want))
19+
20+
got, err := authconfig.LoadIdentity()
21+
require.NoError(t, err)
22+
assert.Equal(t, want, got)
23+
}
24+
25+
func TestLoadIdentityNonExistent(t *testing.T) {
26+
setupTempDir(t)
27+
28+
id, err := authconfig.LoadIdentity()
29+
assert.NoError(t, err)
30+
assert.Nil(t, id)
31+
}
32+
33+
func TestHashAPIKeyDeterministic(t *testing.T) {
34+
h1 := authconfig.HashAPIKey("my-key")
35+
h2 := authconfig.HashAPIKey("my-key")
36+
assert.Equal(t, h1, h2)
37+
assert.NotEqual(t, h1, authconfig.HashAPIKey("other-key"))
38+
}

cli/root.go

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"github.com/duneanalytics/cli/cmd/query"
2323
"github.com/duneanalytics/cli/cmd/sim"
2424
"github.com/duneanalytics/cli/cmd/usage"
25+
"github.com/duneanalytics/cli/cmd/whoami"
2526
"github.com/duneanalytics/cli/cmdutil"
2627
"github.com/duneanalytics/cli/tracking"
2728
)
@@ -77,6 +78,13 @@ var rootCmd = &cobra.Command{
7778
client := dune.NewDuneClient(env)
7879
cmdutil.SetClient(cmd, client)
7980

81+
// Resolve customer identity for analytics (best-effort, never blocks the CLI).
82+
if tr := cmdutil.TrackerFromCmd(cmd); tr != nil {
83+
if customerID := resolveCustomerID(client, env.APIKey); customerID != "" {
84+
tr.SetUserID(customerID)
85+
}
86+
}
87+
8088
return nil
8189
},
8290
PersistentPostRunE: func(cmd *cobra.Command, _ []string) error {
@@ -93,7 +101,8 @@ var rootCmd = &cobra.Command{
93101
commandPath = parts[1]
94102
}
95103

96-
tr.Track(commandPath, tracking.StatusSuccess, "", durationMs)
104+
isSim := strings.HasPrefix(commandPath, "sim")
105+
tr.Track(commandPath, tracking.StatusSuccess, "", durationMs, isSim)
97106
return nil
98107
},
99108
}
@@ -107,6 +116,7 @@ func init() {
107116
rootCmd.AddCommand(query.NewQueryCmd())
108117
rootCmd.AddCommand(execution.NewExecutionCmd())
109118
rootCmd.AddCommand(usage.NewUsageCmd())
119+
rootCmd.AddCommand(whoami.NewWhoAmICmd())
110120
rootCmd.AddCommand(sim.NewSimCmd())
111121
}
112122

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

117127
telemetryEnabled := duneconfig.IsTelemetryEnabled()
118-
configDir, _ := authconfig.Dir()
119128
tracker := tracking.New(tracking.Config{
120129
AmplitudeKey: amplitudeKey,
121130
CLIVersion: version,
122-
ConfigDir: configDir,
123131
Enabled: telemetryEnabled,
124132
})
125133
defer tracker.Shutdown()
@@ -132,7 +140,8 @@ func Execute(version, commit, date, amplitudeKey string) {
132140
); err != nil {
133141
// Build best-effort command path from os.Args (strip flags).
134142
commandPath := commandPathFromArgs(os.Args)
135-
tracker.Track(commandPath, tracking.StatusError, err.Error(), 0)
143+
isSim := strings.HasPrefix(commandPath, "sim")
144+
tracker.Track(commandPath, tracking.StatusError, err.Error(), 0, isSim)
136145
// Flush the event before exiting — os.Exit does not run deferred funcs,
137146
// so defer tracker.Shutdown() above would never fire.
138147
tracker.Shutdown()
@@ -142,6 +151,34 @@ func Execute(version, commit, date, amplitudeKey string) {
142151
}
143152
}
144153

154+
// resolveCustomerID returns the customer_id associated with the given API key.
155+
// The customer_id may represent a user ("user_123") or a team ("team_456").
156+
// It uses a local cache to avoid calling /api/whoami on every invocation.
157+
// On any error it returns "" silently — analytics should never block the CLI.
158+
func resolveCustomerID(client dune.DuneClient, apiKey string) string {
159+
keyHash := authconfig.HashAPIKey(apiKey)
160+
161+
// Try the cache first.
162+
cached, err := authconfig.LoadIdentity()
163+
if err == nil && cached != nil && cached.APIKeyHash == keyHash && cached.CustomerID != "" {
164+
return cached.CustomerID
165+
}
166+
167+
// Cache miss or stale — call the API.
168+
resp, err := client.WhoAmI()
169+
if err != nil || resp == nil || resp.CustomerID == "" {
170+
return ""
171+
}
172+
173+
// Persist for next time (best-effort).
174+
_ = authconfig.SaveIdentity(&authconfig.UserIdentity{
175+
CustomerID: resp.CustomerID,
176+
APIKeyHash: keyHash,
177+
})
178+
179+
return resp.CustomerID
180+
}
181+
145182
// commandPathFromArgs extracts the subcommand path from os.Args, skipping
146183
// the binary name, flags, and flag values so the tracked path is e.g.
147184
// "query list" even when invoked as "dune --api-key KEY query list --limit 10".

cmd/whoami/whoami.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package whoami
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/duneanalytics/cli/cmdutil"
7+
"github.com/duneanalytics/cli/output"
8+
"github.com/spf13/cobra"
9+
)
10+
11+
// NewWhoAmICmd returns the "whoami" command.
12+
func NewWhoAmICmd() *cobra.Command {
13+
cmd := &cobra.Command{
14+
Use: "whoami",
15+
Short: "Show the identity associated with the current API key",
16+
Long: "Display the handle, customer ID, and API key ID of the currently\n" +
17+
"configured API key. Useful for verifying which account is active.\n\n" +
18+
"Examples:\n" +
19+
" dune whoami\n" +
20+
" dune whoami --output json",
21+
RunE: runWhoAmI,
22+
}
23+
24+
output.AddFormatFlag(cmd, "text")
25+
26+
return cmd
27+
}
28+
29+
func runWhoAmI(cmd *cobra.Command, _ []string) error {
30+
client := cmdutil.ClientFromCmd(cmd)
31+
32+
resp, err := client.WhoAmI()
33+
if err != nil {
34+
return fmt.Errorf("identifying API key: %w", err)
35+
}
36+
37+
w := cmd.OutOrStdout()
38+
switch output.FormatFromCmd(cmd) {
39+
case output.FormatJSON:
40+
return output.PrintJSON(w, resp)
41+
default:
42+
fmt.Fprintf(w, "Handle: %s\n", resp.Handle)
43+
fmt.Fprintf(w, "Customer ID: %s\n", resp.CustomerID)
44+
fmt.Fprintf(w, "API Key ID: %s\n", resp.APIKeyID)
45+
return nil
46+
}
47+
}

go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@ go 1.25.6
55
require (
66
github.com/amplitude/analytics-go v1.3.0
77
github.com/charmbracelet/fang v0.4.4
8-
github.com/duneanalytics/duneapi-client-go v0.4.3
8+
github.com/duneanalytics/duneapi-client-go v0.4.4
99
github.com/modelcontextprotocol/go-sdk v1.4.0
1010
github.com/spf13/cobra v1.10.2
1111
github.com/stretchr/testify v1.11.1
12+
golang.org/x/term v0.40.0
1213
gopkg.in/yaml.v3 v3.0.1
1314
)
1415

@@ -45,6 +46,5 @@ require (
4546
golang.org/x/oauth2 v0.34.0 // indirect
4647
golang.org/x/sync v0.17.0 // indirect
4748
golang.org/x/sys v0.41.0 // indirect
48-
golang.org/x/term v0.40.0 // indirect
4949
golang.org/x/text v0.24.0 // indirect
5050
)

go.sum

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsV
3131
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
3232
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
3333
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
34-
github.com/duneanalytics/duneapi-client-go v0.4.3 h1:3meDMQPwGk6jrVntN4o2nm4CWuzobKnD4nmG52hWZ+0=
35-
github.com/duneanalytics/duneapi-client-go v0.4.3/go.mod h1:7pXXufWvR/Mh2KOehdyBaunJXmHI+pzjUmyQTQhJjdE=
34+
github.com/duneanalytics/duneapi-client-go v0.4.4 h1:vNI1musSDuqlICNXLdf059J3YwwonTi/GX5NhTrLdqI=
35+
github.com/duneanalytics/duneapi-client-go v0.4.4/go.mod h1:7pXXufWvR/Mh2KOehdyBaunJXmHI+pzjUmyQTQhJjdE=
3636
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
3737
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
3838
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -87,8 +87,6 @@ golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
8787
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
8888
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
8989
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
90-
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
91-
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
9290
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
9391
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
9492
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=

0 commit comments

Comments
 (0)