Skip to content

Commit 6dbef8b

Browse files
committed
Merge branch 'authenticate'
2 parents d53e561 + 78554c3 commit 6dbef8b

36 files changed

Lines changed: 2754 additions & 54 deletions

.github/workflows/release.yml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
name: Release
2+
3+
on:
4+
push:
5+
tags:
6+
- "v*"
7+
8+
permissions:
9+
contents: write
10+
11+
jobs:
12+
release:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- name: Checkout
16+
uses: actions/checkout@v4
17+
with:
18+
fetch-depth: 0
19+
20+
- name: Set up Go
21+
uses: actions/setup-go@v5
22+
with:
23+
go-version-file: go.mod
24+
25+
- name: Run GoReleaser
26+
uses: goreleaser/goreleaser-action@v6
27+
with:
28+
distribution: goreleaser
29+
version: "~> v2"
30+
args: release --clean
31+
env:
32+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

.goreleaser.yml

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
version: 2
2+
3+
project_name: dune-cli
4+
5+
before:
6+
hooks:
7+
- go mod tidy
8+
9+
builds:
10+
- main: ./cmd
11+
binary: dune
12+
env:
13+
- CGO_ENABLED=0
14+
ldflags:
15+
- -s -w
16+
- -X main.version={{.Version}}
17+
- -X main.commit={{.Commit}}
18+
- -X main.date={{.Date}}
19+
goos:
20+
- linux
21+
- darwin
22+
- windows
23+
goarch:
24+
- amd64
25+
- arm64
26+
27+
archives:
28+
- format: tar.gz
29+
name_template: >-
30+
{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}
31+
format_overrides:
32+
- goos: windows
33+
format: zip
34+
35+
checksum:
36+
name_template: "checksums.txt"
37+
38+
changelog:
39+
sort: asc
40+
filters:
41+
exclude:
42+
- "^docs:"
43+
- "^test:"
44+
- "^ci:"
45+
- "^chore:"
46+
47+
release:
48+
github:
49+
owner: duneanalytics
50+
name: cli
51+
draft: false
52+
prerelease: auto
53+
extra_files:
54+
- glob: install.sh

authconfig/authconfig.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package authconfig
2+
3+
import (
4+
"errors"
5+
"io/fs"
6+
"os"
7+
"path/filepath"
8+
"sync"
9+
10+
"gopkg.in/yaml.v3"
11+
)
12+
13+
// Config holds the persisted CLI configuration.
14+
type Config struct {
15+
APIKey string `yaml:"api_key"`
16+
}
17+
18+
// configDirFunc allows tests to override the config directory.
19+
var (
20+
configDirFunc = defaultDir
21+
configMu sync.RWMutex
22+
)
23+
24+
func defaultDir() (string, error) {
25+
home, err := os.UserHomeDir()
26+
if err != nil {
27+
return "", err
28+
}
29+
return filepath.Join(home, ".config", "dune"), nil
30+
}
31+
32+
// SetDirFunc overrides the config directory function (for testing).
33+
func SetDirFunc(fn func() (string, error)) {
34+
configMu.Lock()
35+
defer configMu.Unlock()
36+
configDirFunc = fn
37+
}
38+
39+
// ResetDirFunc restores the default config directory function.
40+
func ResetDirFunc() {
41+
configMu.Lock()
42+
defer configMu.Unlock()
43+
configDirFunc = defaultDir
44+
}
45+
46+
// Dir returns the config directory path ($HOME/.config/dune).
47+
func Dir() (string, error) {
48+
configMu.RLock()
49+
defer configMu.RUnlock()
50+
return configDirFunc()
51+
}
52+
53+
// Path returns the full path to the config file.
54+
func Path() (string, error) {
55+
dir, err := Dir()
56+
if err != nil {
57+
return "", err
58+
}
59+
return filepath.Join(dir, "config.yaml"), nil
60+
}
61+
62+
// Load reads and parses the config file. Returns nil, nil if the file does not exist.
63+
func Load() (*Config, error) {
64+
p, err := Path()
65+
if err != nil {
66+
return nil, err
67+
}
68+
69+
data, err := os.ReadFile(p)
70+
if err != nil {
71+
if errors.Is(err, fs.ErrNotExist) {
72+
return nil, nil
73+
}
74+
return nil, err
75+
}
76+
77+
var cfg Config
78+
if err := yaml.Unmarshal(data, &cfg); err != nil {
79+
return nil, err
80+
}
81+
return &cfg, nil
82+
}
83+
84+
// Save writes the config to disk, creating the directory (0700) and file (0600) as needed.
85+
func Save(cfg *Config) error {
86+
p, err := Path()
87+
if err != nil {
88+
return err
89+
}
90+
91+
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
92+
return err
93+
}
94+
95+
data, err := yaml.Marshal(cfg)
96+
if err != nil {
97+
return err
98+
}
99+
100+
return os.WriteFile(p, data, 0o600)
101+
}

authconfig/authconfig_test.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package authconfig_test
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/duneanalytics/cli/authconfig"
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func setupTempDir(t *testing.T) string {
14+
t.Helper()
15+
dir := t.TempDir()
16+
authconfig.SetDirFunc(func() (string, error) { return dir, nil })
17+
t.Cleanup(authconfig.ResetDirFunc)
18+
return dir
19+
}
20+
21+
func TestSaveAndLoad(t *testing.T) {
22+
setupTempDir(t)
23+
24+
want := &authconfig.Config{APIKey: "dune_test_key_123"}
25+
require.NoError(t, authconfig.Save(want))
26+
27+
got, err := authconfig.Load()
28+
require.NoError(t, err)
29+
assert.Equal(t, want.APIKey, got.APIKey)
30+
}
31+
32+
func TestLoadNonExistent(t *testing.T) {
33+
setupTempDir(t)
34+
35+
cfg, err := authconfig.Load()
36+
assert.NoError(t, err)
37+
assert.Nil(t, cfg)
38+
}
39+
40+
func TestLoadMalformedYAML(t *testing.T) {
41+
dir := setupTempDir(t)
42+
43+
err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(":\tbad\nyaml{["), 0o600)
44+
require.NoError(t, err)
45+
46+
_, err = authconfig.Load()
47+
assert.Error(t, err)
48+
}
49+
50+
func TestSaveCreatesDir(t *testing.T) {
51+
tmp := t.TempDir()
52+
nested := filepath.Join(tmp, "sub", "dir")
53+
authconfig.SetDirFunc(func() (string, error) { return nested, nil })
54+
t.Cleanup(authconfig.ResetDirFunc)
55+
56+
require.NoError(t, authconfig.Save(&authconfig.Config{APIKey: "key"}))
57+
58+
info, err := os.Stat(nested)
59+
require.NoError(t, err)
60+
assert.True(t, info.IsDir())
61+
}
62+
63+
func TestFilePermissions(t *testing.T) {
64+
tmp := t.TempDir()
65+
dir := filepath.Join(tmp, "newdir")
66+
authconfig.SetDirFunc(func() (string, error) { return dir, nil })
67+
t.Cleanup(authconfig.ResetDirFunc)
68+
69+
require.NoError(t, authconfig.Save(&authconfig.Config{APIKey: "key"}))
70+
71+
dirInfo, err := os.Stat(dir)
72+
require.NoError(t, err)
73+
assert.Equal(t, os.FileMode(0o700), dirInfo.Mode().Perm())
74+
75+
fileInfo, err := os.Stat(filepath.Join(dir, "config.yaml"))
76+
require.NoError(t, err)
77+
assert.Equal(t, os.FileMode(0o600), fileInfo.Mode().Perm())
78+
}
Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,24 +4,31 @@ import (
44
"context"
55
"fmt"
66
"os"
7+
"strings"
78

89
"github.com/charmbracelet/fang"
10+
"github.com/duneanalytics/cli/authconfig"
11+
"github.com/duneanalytics/cli/cmd/auth"
12+
"github.com/duneanalytics/cli/cmd/execution"
913
"github.com/duneanalytics/cli/cmd/query"
14+
"github.com/duneanalytics/cli/cmdutil"
1015
"github.com/duneanalytics/duneapi-client-go/config"
1116
"github.com/duneanalytics/duneapi-client-go/dune"
1217
"github.com/spf13/cobra"
1318
)
1419

15-
type clientKey struct{}
16-
1720
var apiKeyFlag string
1821

1922
var rootCmd = &cobra.Command{
2023
Use: "dune",
2124
Short: "Dune CLI — interact with the Dune Analytics API",
2225
Long: "A command-line interface for interacting with the Dune Analytics API.\n" +
2326
"Manage queries, execute them, and retrieve results.",
24-
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
27+
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
28+
if cmd.Annotations["skipAuth"] == "true" {
29+
return nil
30+
}
31+
2532
var env *config.Env
2633

2734
switch {
@@ -31,28 +38,39 @@ var rootCmd = &cobra.Command{
3138
var err error
3239
env, err = config.FromEnvVars()
3340
if err != nil {
34-
return fmt.Errorf("missing API key: set DUNE_API_KEY or pass --api-key")
41+
cfg, cfgErr := authconfig.Load()
42+
if cfgErr != nil {
43+
return fmt.Errorf("reading auth config: %w", cfgErr)
44+
}
45+
if cfg != nil {
46+
key := strings.TrimSpace(cfg.APIKey)
47+
if key == "" {
48+
return fmt.Errorf("empty API key in config: run dune auth --api-key <key>")
49+
}
50+
env = config.FromAPIKey(key)
51+
} else {
52+
return fmt.Errorf("missing API key: set DUNE_API_KEY, pass --api-key, or run dune auth")
53+
}
3554
}
3655
}
3756

3857
client := dune.NewDuneClient(env)
39-
cmd.SetContext(context.WithValue(cmd.Context(), clientKey{}, dune.DuneClient(client)))
58+
cmdutil.SetClient(cmd, client)
4059
return nil
4160
},
4261
}
4362

4463
func init() {
4564
rootCmd.PersistentFlags().StringVar(&apiKeyFlag, "api-key", "", "Dune API key (overrides DUNE_API_KEY env var)")
65+
rootCmd.AddCommand(auth.NewAuthCmd())
4666
rootCmd.AddCommand(query.NewQueryCmd())
47-
}
48-
49-
// ClientFromCmd extracts the DuneClient stored in the command's context.
50-
func ClientFromCmd(cmd *cobra.Command) dune.DuneClient {
51-
return cmd.Context().Value(clientKey{}).(dune.DuneClient)
67+
rootCmd.AddCommand(execution.NewExecutionCmd())
5268
}
5369

5470
// Execute runs the root command via Fang.
55-
func Execute() {
71+
func Execute(version, commit, date string) {
72+
rootCmd.Version = fmt.Sprintf("%s (commit: %s, built: %s)", version, commit, date)
73+
5674
if err := fang.Execute(context.Background(), rootCmd); err != nil {
5775
fmt.Fprintln(os.Stderr, err)
5876
os.Exit(1)

0 commit comments

Comments
 (0)