diff --git a/go.mod b/go.mod index 72b6685..f591003 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,9 @@ require ( github.com/gorilla/websocket v1.5.3 github.com/itchyny/json2yaml v0.1.4 github.com/kernel/hypeman-go v0.11.0 + github.com/knadh/koanf/parsers/yaml v1.1.0 + github.com/knadh/koanf/providers/file v1.2.1 + github.com/knadh/koanf/v2 v2.3.2 github.com/muesli/reflow v0.3.0 github.com/stretchr/testify v1.11.1 github.com/tidwall/gjson v1.18.0 @@ -42,15 +45,21 @@ require ( github.com/docker/go-units v0.5.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect github.com/klauspost/compress v1.18.1 // indirect + github.com/knadh/koanf/maps v0.1.2 // indirect + github.com/knadh/koanf/providers/env v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect @@ -70,6 +79,7 @@ require ( go.opentelemetry.io/otel v1.38.0 // indirect go.opentelemetry.io/otel/metric v1.38.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/sync v0.18.0 // indirect golang.org/x/text v0.31.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect diff --git a/go.sum b/go.sum index 9c99ded..9c04859 100644 --- a/go.sum +++ b/go.sum @@ -55,11 +55,15 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 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 github.com/kernel/hypeman-go v0.11.0/go.mod h1:guRrhyP9QW/ebUS1UcZ0uZLLJeGAAhDNzSi68U4M9hI= github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= +github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= +github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= +github.com/knadh/koanf/parsers/yaml v1.1.0 h1:3ltfm9ljprAHt4jxgeYLlFPmUaunuCgu1yILuTXRdM4= +github.com/knadh/koanf/parsers/yaml v1.1.0/go.mod h1:HHmcHXUrp9cOPcuC+2wrr44GTUB0EC+PyfN3HZD9tFg= +github.com/knadh/koanf/providers/env v1.1.0 h1:U2VXPY0f+CsNDkvdsG8GcsnK4ah85WwWyJgef9oQMSc= +github.com/knadh/koanf/providers/env v1.1.0/go.mod h1:QhHHHZ87h9JxJAn2czdEl6pdkNnDh/JS1Vtsyt65hTY= +github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP3AESOJYp9wM= +github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA= +github.com/knadh/koanf/v2 v2.3.2 h1:Ee6tuzQYFwcZXQpc2MiVeC6qHMandf5SMUJJNoFp/c4= +github.com/knadh/koanf/v2 v2.3.2/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 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 github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= 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 go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= diff --git a/pkg/cmd/cmdutil.go b/pkg/cmd/cmdutil.go index 8093413..e50b2e0 100644 --- a/pkg/cmd/cmdutil.go +++ b/pkg/cmd/cmdutil.go @@ -27,11 +27,14 @@ func getDefaultRequestOptions(cmd *cli.Command) []option.RequestOption { option.WithHeader("User-Agent", fmt.Sprintf("Hypeman/CLI %s", Version)), } - // Override base URL if the --base-url flag is provided - if baseURL := cmd.String("base-url"); baseURL != "" { + if baseURL := resolveBaseURL(cmd); baseURL != "" { opts = append(opts, option.WithBaseURL(baseURL)) } + if apiKey := resolveAPIKey(); apiKey != "" { + opts = append(opts, option.WithAPIKey(apiKey)) + } + return opts } diff --git a/pkg/cmd/config.go b/pkg/cmd/config.go new file mode 100644 index 0000000..2cd4146 --- /dev/null +++ b/pkg/cmd/config.go @@ -0,0 +1,74 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + + "github.com/knadh/koanf/parsers/yaml" + "github.com/knadh/koanf/providers/env" + "github.com/knadh/koanf/providers/file" + "github.com/knadh/koanf/v2" + "github.com/urfave/cli/v3" +) + +// CLIConfig holds CLI configuration loaded from cli.yaml +type CLIConfig struct { + BaseURL string `koanf:"base_url"` + APIKey string `koanf:"api_key"` +} + +// getCLIConfigPath returns the path to the CLI config file. +// The CLI uses ~/.config/hypeman/cli.yaml on all platforms. +func getCLIConfigPath() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".config", "hypeman", "cli.yaml") +} + +// loadCLIConfig loads CLI configuration from the config file, then +// overlays HYPEMAN_-prefixed environment variables (highest precedence). +// HYPEMAN_BASE_URL -> base_url, HYPEMAN_API_KEY -> api_key. +// Returns an empty config if the file doesn't exist or can't be parsed. +func loadCLIConfig() *CLIConfig { + cfg := &CLIConfig{} + k := koanf.New(".") + + configPath := getCLIConfigPath() + if configPath != "" { + _ = k.Load(file.Provider(configPath), yaml.Parser()) + } + + // Overlay HYPEMAN_-prefixed env vars: HYPEMAN_BASE_URL -> base_url + _ = k.Load(env.ProviderWithValue("HYPEMAN_", ".", func(key string, value string) (string, interface{}) { + if value == "" { + return "", nil + } + return strings.ToLower(strings.TrimPrefix(key, "HYPEMAN_")), value + }), nil) + + _ = k.Unmarshal("", cfg) + return cfg +} + +// resolveBaseURL returns the effective base URL with precedence: +// CLI flag > HYPEMAN_BASE_URL env > config file > default. +func resolveBaseURL(cmd *cli.Command) string { + if u := cmd.Root().String("base-url"); u != "" { + return u + } + cfg := loadCLIConfig() + if cfg.BaseURL != "" { + return cfg.BaseURL + } + return "http://localhost:8080" +} + +// resolveAPIKey returns the effective API key with precedence: +// HYPEMAN_API_KEY env > config file. +func resolveAPIKey() string { + cfg := loadCLIConfig() + return cfg.APIKey +} diff --git a/pkg/cmd/cp.go b/pkg/cmd/cp.go index 97d0410..43a653b 100644 --- a/pkg/cmd/cp.go +++ b/pkg/cmd/cp.go @@ -145,18 +145,12 @@ func handleCp(ctx context.Context, cmd *cli.Command) error { return err } - // Get base URL and API key - baseURL := cmd.Root().String("base-url") - if baseURL == "" { - baseURL = os.Getenv("HYPEMAN_BASE_URL") - } - if baseURL == "" { - baseURL = "http://localhost:8080" - } + // Get base URL and API key (flag > env > config file) + baseURL := resolveBaseURL(cmd) - apiKey := os.Getenv("HYPEMAN_API_KEY") + apiKey := resolveAPIKey() if apiKey == "" { - return fmt.Errorf("HYPEMAN_API_KEY environment variable required") + return fmt.Errorf("API key required: set HYPEMAN_API_KEY or configure api_key in ~/.config/hypeman/cli.yaml") } archive := cmd.Bool("archive") diff --git a/pkg/cmd/exec.go b/pkg/cmd/exec.go index a58ea75..d53efe5 100644 --- a/pkg/cmd/exec.go +++ b/pkg/cmd/exec.go @@ -145,18 +145,12 @@ func handleExec(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("failed to marshal request: %w", err) } - // Get base URL and API key - baseURL := cmd.Root().String("base-url") - if baseURL == "" { - baseURL = os.Getenv("HYPEMAN_BASE_URL") - } - if baseURL == "" { - baseURL = "http://localhost:8080" - } + // Get base URL and API key (flag > env > config file) + baseURL := resolveBaseURL(cmd) - apiKey := os.Getenv("HYPEMAN_API_KEY") + apiKey := resolveAPIKey() if apiKey == "" { - return fmt.Errorf("HYPEMAN_API_KEY environment variable required") + return fmt.Errorf("API key required: set HYPEMAN_API_KEY or configure api_key in ~/.config/hypeman/cli.yaml") } // Build WebSocket URL diff --git a/pkg/cmd/push.go b/pkg/cmd/push.go index e66f61e..da8f30e 100644 --- a/pkg/cmd/push.go +++ b/pkg/cmd/push.go @@ -35,13 +35,7 @@ func handlePush(ctx context.Context, cmd *cli.Command) error { targetName = args[1] } - baseURL := cmd.String("base-url") - if baseURL == "" { - baseURL = os.Getenv("HYPEMAN_BASE_URL") - } - if baseURL == "" { - baseURL = "http://localhost:8080" - } + baseURL := resolveBaseURL(cmd) parsedURL, err := url.Parse(baseURL) if err != nil { @@ -71,10 +65,7 @@ func handlePush(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("invalid target: %w", err) } - token := os.Getenv("HYPEMAN_BEARER_TOKEN") - if token == "" { - token = os.Getenv("HYPEMAN_API_KEY") - } + token := resolveAPIKey() // Use custom transport that always sends Basic auth header transport := &authTransport{