Skip to content

Commit 180e409

Browse files
authored
feat: add YAML config file support via koanf (#40)
* feat: add YAML config file support via koanf Add ~/.config/hypeman/cli.yaml support so the CLI can read base_url and api_key from a config file instead of requiring environment variables. Config precedence: CLI flags > env vars > config file. This pairs with the server-side config migration in kernel/hypeman to enable a zero-config local experience after running install.sh. * fix: unify credential resolution across all commands - Add resolveBaseURL() and resolveAPIKey() helpers to config.go - Update exec, cp, and push commands to use shared helpers - All commands now consistently use: flag > env > config file - WebSocket commands no longer bypass cli.yaml configuration * fix: unify auth precedence in SDK and WebSocket paths Update getDefaultRequestOptions to use resolveBaseURL() and resolveAPIKey() so SDK calls and WebSocket calls share the same HYPEMAN_BEARER_TOKEN > HYPEMAN_API_KEY > config file precedence. * fix: give HYPEMAN_API_KEY precedence over HYPEMAN_BEARER_TOKEN HYPEMAN_API_KEY is the documented primary env var. HYPEMAN_BEARER_TOKEN is a legacy fallback and should not override it. * chore: remove internal process comment from cmdutil.go * refactor: use koanf env provider for HYPEMAN_ env vars Load HYPEMAN_BASE_URL and HYPEMAN_API_KEY via koanf's env provider in loadCLIConfig() instead of manual os.Getenv checks. This gives consistent precedence (env > config file) in one place. HYPEMAN_BEARER_TOKEN remains as a legacy fallback checked separately. * refactor: remove legacy HYPEMAN_BEARER_TOKEN support
1 parent 6e38b47 commit 180e409

File tree

7 files changed

+119
-33
lines changed

7 files changed

+119
-33
lines changed

go.mod

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ require (
1111
github.com/gorilla/websocket v1.5.3
1212
github.com/itchyny/json2yaml v0.1.4
1313
github.com/kernel/hypeman-go v0.11.0
14+
github.com/knadh/koanf/parsers/yaml v1.1.0
15+
github.com/knadh/koanf/providers/file v1.2.1
16+
github.com/knadh/koanf/v2 v2.3.2
1417
github.com/muesli/reflow v0.3.0
1518
github.com/stretchr/testify v1.11.1
1619
github.com/tidwall/gjson v1.18.0
@@ -42,15 +45,21 @@ require (
4245
github.com/docker/go-units v0.5.0 // indirect
4346
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
4447
github.com/felixge/httpsnoop v1.0.4 // indirect
48+
github.com/fsnotify/fsnotify v1.9.0 // indirect
4549
github.com/go-logr/logr v1.4.3 // indirect
4650
github.com/go-logr/stdr v1.2.2 // indirect
51+
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
4752
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect
4853
github.com/klauspost/compress v1.18.1 // indirect
54+
github.com/knadh/koanf/maps v0.1.2 // indirect
55+
github.com/knadh/koanf/providers/env v1.1.0 // indirect
4956
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
5057
github.com/mattn/go-isatty v0.0.20 // indirect
5158
github.com/mattn/go-localereader v0.0.1 // indirect
5259
github.com/mattn/go-runewidth v0.0.16 // indirect
60+
github.com/mitchellh/copystructure v1.2.0 // indirect
5361
github.com/mitchellh/go-homedir v1.1.0 // indirect
62+
github.com/mitchellh/reflectwalk v1.0.2 // indirect
5463
github.com/moby/docker-image-spec v1.3.1 // indirect
5564
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
5665
github.com/muesli/cancelreader v0.2.2 // indirect
@@ -70,6 +79,7 @@ require (
7079
go.opentelemetry.io/otel v1.38.0 // indirect
7180
go.opentelemetry.io/otel/metric v1.38.0 // indirect
7281
go.opentelemetry.io/otel/trace v1.38.0 // indirect
82+
go.yaml.in/yaml/v3 v3.0.4 // indirect
7383
golang.org/x/sync v0.18.0 // indirect
7484
golang.org/x/text v0.31.0 // indirect
7585
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect

go.sum

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,15 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6
5555
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
5656
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
5757
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
58+
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
59+
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
5860
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
5961
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
6062
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
6163
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
6264
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
65+
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
66+
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
6367
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
6468
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
6569
github.com/google/go-containerregistry v0.20.7 h1:24VGNpS0IwrOZ2ms2P1QE3Xa5X9p4phx0aUgzYzHW6I=
@@ -76,6 +80,16 @@ github.com/kernel/hypeman-go v0.11.0 h1:hCXNUHtrhGKswJapzyWyozBOXhKK/oreKvm0AXHu
7680
github.com/kernel/hypeman-go v0.11.0/go.mod h1:guRrhyP9QW/ebUS1UcZ0uZLLJeGAAhDNzSi68U4M9hI=
7781
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
7882
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
83+
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
84+
github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
85+
github.com/knadh/koanf/parsers/yaml v1.1.0 h1:3ltfm9ljprAHt4jxgeYLlFPmUaunuCgu1yILuTXRdM4=
86+
github.com/knadh/koanf/parsers/yaml v1.1.0/go.mod h1:HHmcHXUrp9cOPcuC+2wrr44GTUB0EC+PyfN3HZD9tFg=
87+
github.com/knadh/koanf/providers/env v1.1.0 h1:U2VXPY0f+CsNDkvdsG8GcsnK4ah85WwWyJgef9oQMSc=
88+
github.com/knadh/koanf/providers/env v1.1.0/go.mod h1:QhHHHZ87h9JxJAn2czdEl6pdkNnDh/JS1Vtsyt65hTY=
89+
github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP3AESOJYp9wM=
90+
github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA=
91+
github.com/knadh/koanf/v2 v2.3.2 h1:Ee6tuzQYFwcZXQpc2MiVeC6qHMandf5SMUJJNoFp/c4=
92+
github.com/knadh/koanf/v2 v2.3.2/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28=
7993
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
8094
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
8195
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -89,8 +103,12 @@ github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+Ei
89103
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
90104
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
91105
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
106+
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
107+
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
92108
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
93109
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
110+
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
111+
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
94112
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
95113
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
96114
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
@@ -169,6 +187,8 @@ go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJr
169187
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
170188
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
171189
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
190+
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
191+
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
172192
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
173193
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
174194
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=

pkg/cmd/cmdutil.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,14 @@ func getDefaultRequestOptions(cmd *cli.Command) []option.RequestOption {
2727
option.WithHeader("User-Agent", fmt.Sprintf("Hypeman/CLI %s", Version)),
2828
}
2929

30-
// Override base URL if the --base-url flag is provided
31-
if baseURL := cmd.String("base-url"); baseURL != "" {
30+
if baseURL := resolveBaseURL(cmd); baseURL != "" {
3231
opts = append(opts, option.WithBaseURL(baseURL))
3332
}
3433

34+
if apiKey := resolveAPIKey(); apiKey != "" {
35+
opts = append(opts, option.WithAPIKey(apiKey))
36+
}
37+
3538
return opts
3639
}
3740

pkg/cmd/config.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package cmd
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
8+
"github.com/knadh/koanf/parsers/yaml"
9+
"github.com/knadh/koanf/providers/env"
10+
"github.com/knadh/koanf/providers/file"
11+
"github.com/knadh/koanf/v2"
12+
"github.com/urfave/cli/v3"
13+
)
14+
15+
// CLIConfig holds CLI configuration loaded from cli.yaml
16+
type CLIConfig struct {
17+
BaseURL string `koanf:"base_url"`
18+
APIKey string `koanf:"api_key"`
19+
}
20+
21+
// getCLIConfigPath returns the path to the CLI config file.
22+
// The CLI uses ~/.config/hypeman/cli.yaml on all platforms.
23+
func getCLIConfigPath() string {
24+
home, err := os.UserHomeDir()
25+
if err != nil {
26+
return ""
27+
}
28+
return filepath.Join(home, ".config", "hypeman", "cli.yaml")
29+
}
30+
31+
// loadCLIConfig loads CLI configuration from the config file, then
32+
// overlays HYPEMAN_-prefixed environment variables (highest precedence).
33+
// HYPEMAN_BASE_URL -> base_url, HYPEMAN_API_KEY -> api_key.
34+
// Returns an empty config if the file doesn't exist or can't be parsed.
35+
func loadCLIConfig() *CLIConfig {
36+
cfg := &CLIConfig{}
37+
k := koanf.New(".")
38+
39+
configPath := getCLIConfigPath()
40+
if configPath != "" {
41+
_ = k.Load(file.Provider(configPath), yaml.Parser())
42+
}
43+
44+
// Overlay HYPEMAN_-prefixed env vars: HYPEMAN_BASE_URL -> base_url
45+
_ = k.Load(env.ProviderWithValue("HYPEMAN_", ".", func(key string, value string) (string, interface{}) {
46+
if value == "" {
47+
return "", nil
48+
}
49+
return strings.ToLower(strings.TrimPrefix(key, "HYPEMAN_")), value
50+
}), nil)
51+
52+
_ = k.Unmarshal("", cfg)
53+
return cfg
54+
}
55+
56+
// resolveBaseURL returns the effective base URL with precedence:
57+
// CLI flag > HYPEMAN_BASE_URL env > config file > default.
58+
func resolveBaseURL(cmd *cli.Command) string {
59+
if u := cmd.Root().String("base-url"); u != "" {
60+
return u
61+
}
62+
cfg := loadCLIConfig()
63+
if cfg.BaseURL != "" {
64+
return cfg.BaseURL
65+
}
66+
return "http://localhost:8080"
67+
}
68+
69+
// resolveAPIKey returns the effective API key with precedence:
70+
// HYPEMAN_API_KEY env > config file.
71+
func resolveAPIKey() string {
72+
cfg := loadCLIConfig()
73+
return cfg.APIKey
74+
}

pkg/cmd/cp.go

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -145,18 +145,12 @@ func handleCp(ctx context.Context, cmd *cli.Command) error {
145145
return err
146146
}
147147

148-
// Get base URL and API key
149-
baseURL := cmd.Root().String("base-url")
150-
if baseURL == "" {
151-
baseURL = os.Getenv("HYPEMAN_BASE_URL")
152-
}
153-
if baseURL == "" {
154-
baseURL = "http://localhost:8080"
155-
}
148+
// Get base URL and API key (flag > env > config file)
149+
baseURL := resolveBaseURL(cmd)
156150

157-
apiKey := os.Getenv("HYPEMAN_API_KEY")
151+
apiKey := resolveAPIKey()
158152
if apiKey == "" {
159-
return fmt.Errorf("HYPEMAN_API_KEY environment variable required")
153+
return fmt.Errorf("API key required: set HYPEMAN_API_KEY or configure api_key in ~/.config/hypeman/cli.yaml")
160154
}
161155

162156
archive := cmd.Bool("archive")

pkg/cmd/exec.go

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -145,18 +145,12 @@ func handleExec(ctx context.Context, cmd *cli.Command) error {
145145
return fmt.Errorf("failed to marshal request: %w", err)
146146
}
147147

148-
// Get base URL and API key
149-
baseURL := cmd.Root().String("base-url")
150-
if baseURL == "" {
151-
baseURL = os.Getenv("HYPEMAN_BASE_URL")
152-
}
153-
if baseURL == "" {
154-
baseURL = "http://localhost:8080"
155-
}
148+
// Get base URL and API key (flag > env > config file)
149+
baseURL := resolveBaseURL(cmd)
156150

157-
apiKey := os.Getenv("HYPEMAN_API_KEY")
151+
apiKey := resolveAPIKey()
158152
if apiKey == "" {
159-
return fmt.Errorf("HYPEMAN_API_KEY environment variable required")
153+
return fmt.Errorf("API key required: set HYPEMAN_API_KEY or configure api_key in ~/.config/hypeman/cli.yaml")
160154
}
161155

162156
// Build WebSocket URL

pkg/cmd/push.go

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,7 @@ func handlePush(ctx context.Context, cmd *cli.Command) error {
3535
targetName = args[1]
3636
}
3737

38-
baseURL := cmd.String("base-url")
39-
if baseURL == "" {
40-
baseURL = os.Getenv("HYPEMAN_BASE_URL")
41-
}
42-
if baseURL == "" {
43-
baseURL = "http://localhost:8080"
44-
}
38+
baseURL := resolveBaseURL(cmd)
4539

4640
parsedURL, err := url.Parse(baseURL)
4741
if err != nil {
@@ -71,10 +65,7 @@ func handlePush(ctx context.Context, cmd *cli.Command) error {
7165
return fmt.Errorf("invalid target: %w", err)
7266
}
7367

74-
token := os.Getenv("HYPEMAN_BEARER_TOKEN")
75-
if token == "" {
76-
token = os.Getenv("HYPEMAN_API_KEY")
77-
}
68+
token := resolveAPIKey()
7869

7970
// Use custom transport that always sends Basic auth header
8071
transport := &authTransport{

0 commit comments

Comments
 (0)