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

import (
"errors"
"io/fs"
"os"
"path/filepath"
"sync"

"gopkg.in/yaml.v3"
)

// Config holds the persisted CLI configuration.
type Config struct {
APIKey string `yaml:"api_key"`
}

// configDirFunc allows tests to override the config directory.
var (
configDirFunc = defaultDir
configMu sync.RWMutex
)

func defaultDir() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".config", "dune"), nil
Copy link
Copy Markdown
Collaborator

@norbertdurcansk norbertdurcansk Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I think tools usually use this path, wdyt ?
~/.toolname/config

~/.aws/config

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's quite depends. checked the tools that I use, and they are more to the "convention" that is implemented here, eg: gh / github cli tool

}

// SetDirFunc overrides the config directory function (for testing).
func SetDirFunc(fn func() (string, error)) {
configMu.Lock()
defer configMu.Unlock()
configDirFunc = fn
}

// ResetDirFunc restores the default config directory function.
func ResetDirFunc() {
configMu.Lock()
defer configMu.Unlock()
configDirFunc = defaultDir
}

// Dir returns the config directory path ($HOME/.config/dune).
func Dir() (string, error) {
configMu.RLock()
defer configMu.RUnlock()
return configDirFunc()
}

// Path returns the full path to the config file.
func Path() (string, error) {
dir, err := Dir()
if err != nil {
return "", err
}
return filepath.Join(dir, "config.yaml"), nil
}

// Load reads and parses the config file. Returns nil, nil if the file does not exist.
func Load() (*Config, error) {
p, err := Path()
if err != nil {
return nil, err
}

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

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

// Save writes the config to disk, creating the directory (0700) and file (0600) as needed.
func Save(cfg *Config) error {
p, err := Path()
if err != nil {
return err
}

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

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

return os.WriteFile(p, data, 0o600)
}
78 changes: 78 additions & 0 deletions authconfig/authconfig_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package authconfig_test

import (
"os"
"path/filepath"
"testing"

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

func setupTempDir(t *testing.T) string {
t.Helper()
dir := t.TempDir()
authconfig.SetDirFunc(func() (string, error) { return dir, nil })
t.Cleanup(authconfig.ResetDirFunc)
return dir
}

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

want := &authconfig.Config{APIKey: "dune_test_key_123"}
require.NoError(t, authconfig.Save(want))

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

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

cfg, err := authconfig.Load()
assert.NoError(t, err)
assert.Nil(t, cfg)
}

func TestLoadMalformedYAML(t *testing.T) {
dir := setupTempDir(t)

err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(":\tbad\nyaml{["), 0o600)
require.NoError(t, err)

_, err = authconfig.Load()
assert.Error(t, err)
}

func TestSaveCreatesDir(t *testing.T) {
tmp := t.TempDir()
nested := filepath.Join(tmp, "sub", "dir")
authconfig.SetDirFunc(func() (string, error) { return nested, nil })
t.Cleanup(authconfig.ResetDirFunc)

require.NoError(t, authconfig.Save(&authconfig.Config{APIKey: "key"}))

info, err := os.Stat(nested)
require.NoError(t, err)
assert.True(t, info.IsDir())
}

func TestFilePermissions(t *testing.T) {
tmp := t.TempDir()
dir := filepath.Join(tmp, "newdir")
authconfig.SetDirFunc(func() (string, error) { return dir, nil })
t.Cleanup(authconfig.ResetDirFunc)

require.NoError(t, authconfig.Save(&authconfig.Config{APIKey: "key"}))

dirInfo, err := os.Stat(dir)
require.NoError(t, err)
assert.Equal(t, os.FileMode(0o700), dirInfo.Mode().Perm())

fileInfo, err := os.Stat(filepath.Join(dir, "config.yaml"))
require.NoError(t, err)
assert.Equal(t, os.FileMode(0o600), fileInfo.Mode().Perm())
}
22 changes: 21 additions & 1 deletion cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ import (
"context"
"fmt"
"os"
"strings"

"github.com/charmbracelet/fang"
"github.com/duneanalytics/cli/authconfig"
"github.com/duneanalytics/cli/cmd/auth"
"github.com/duneanalytics/cli/cmd/execution"
"github.com/duneanalytics/cli/cmd/query"
"github.com/duneanalytics/cli/cmdutil"
Expand All @@ -22,6 +25,10 @@ var rootCmd = &cobra.Command{
Long: "A command-line interface for interacting with the Dune Analytics API.\n" +
"Manage queries, execute them, and retrieve results.",
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
if cmd.Annotations["skipAuth"] == "true" {
return nil
}

var env *config.Env

switch {
Expand All @@ -31,7 +38,19 @@ var rootCmd = &cobra.Command{
var err error
env, err = config.FromEnvVars()
if err != nil {
return fmt.Errorf("missing API key: set DUNE_API_KEY or pass --api-key")
cfg, cfgErr := authconfig.Load()
if cfgErr != nil {
return fmt.Errorf("reading auth config: %w", cfgErr)
}
if cfg != nil {
key := strings.TrimSpace(cfg.APIKey)
if key == "" {
return fmt.Errorf("empty API key in config: run dune auth --api-key <key>")
}
env = config.FromAPIKey(key)
} else {
return fmt.Errorf("missing API key: set DUNE_API_KEY, pass --api-key, or run dune auth")
}
}
}

Expand All @@ -43,6 +62,7 @@ var rootCmd = &cobra.Command{

func init() {
rootCmd.PersistentFlags().StringVar(&apiKeyFlag, "api-key", "", "Dune API key (overrides DUNE_API_KEY env var)")
rootCmd.AddCommand(auth.NewAuthCmd())
rootCmd.AddCommand(query.NewQueryCmd())
rootCmd.AddCommand(execution.NewExecutionCmd())
}
Expand Down
71 changes: 71 additions & 0 deletions cli/root_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package cli

import (
"os"
"path/filepath"
"testing"

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

func setupRootTest(t *testing.T) string {
t.Helper()
dir := t.TempDir()
authconfig.SetDirFunc(func() (string, error) { return dir, nil })
t.Cleanup(authconfig.ResetDirFunc)
prevAPIKey, hadAPIKey := os.LookupEnv("DUNE_API_KEY")
require.NoError(t, os.Unsetenv("DUNE_API_KEY"))
t.Cleanup(func() {
if hadAPIKey {
_ = os.Setenv("DUNE_API_KEY", prevAPIKey)
return
}
_ = os.Unsetenv("DUNE_API_KEY")
})
apiKeyFlag = ""
return dir
}

func TestPersistentPreRunESkipAuth(t *testing.T) {
setupRootTest(t)

cmd := rootCmd
cmd.Annotations = map[string]string{"skipAuth": "true"}
err := rootCmd.PersistentPreRunE(cmd, nil)
require.NoError(t, err)
}

func TestPersistentPreRunEMalformedConfig(t *testing.T) {
dir := setupRootTest(t)
err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(":\tbad\nyaml{["), 0o600)
require.NoError(t, err)

cmd := rootCmd
cmd.Annotations = nil
err = rootCmd.PersistentPreRunE(cmd, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "reading auth config")
}

func TestPersistentPreRunEEmptyConfig(t *testing.T) {
setupRootTest(t)
require.NoError(t, authconfig.Save(&authconfig.Config{APIKey: " "}))

cmd := rootCmd
cmd.Annotations = nil
err := rootCmd.PersistentPreRunE(cmd, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "empty API key in config")
}

func TestPersistentPreRunEConfigFallback(t *testing.T) {
setupRootTest(t)
require.NoError(t, authconfig.Save(&authconfig.Config{APIKey: "saved_key"}))

cmd := rootCmd
cmd.Annotations = nil
err := rootCmd.PersistentPreRunE(cmd, nil)
require.NoError(t, err)
}
50 changes: 50 additions & 0 deletions cmd/auth/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package auth

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

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

// NewAuthCmd returns the `auth` command.
func NewAuthCmd() *cobra.Command {
return &cobra.Command{
Use: "auth",
Short: "Authenticate with the Dune API",
Long: "Save your Dune API key to ~/.config/dune/config.yaml so you don't need to pass it every time.",
Annotations: map[string]string{"skipAuth": "true"},
RunE: runAuth,
}
}

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

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

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

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

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

p, _ := authconfig.Path()
fmt.Fprintf(cmd.OutOrStdout(), "API key saved to %s\n", p)
return nil
}
Loading