From 108749d4b0a4bfbed3b44f58578b07fbe13abac6 Mon Sep 17 00:00:00 2001 From: phm07 <22707808+phm07@users.noreply.github.com> Date: Fri, 8 May 2026 11:09:40 +0200 Subject: [PATCH 1/2] feat: allow fetching token from shell command --- docs/reference/manual/hcloud_config.md | 1 + .../reference/manual/hcloud_context_create.md | 5 +- internal/cmd/config/helptext/other.md | 1 + internal/cmd/config/helptext/other.txt | 32 +++++++----- internal/cmd/context/create.go | 12 +++-- internal/state/config/config.go | 14 +++-- internal/state/config/context.go | 16 +++--- internal/state/config/options.go | 9 ++++ internal/state/config/token.go | 51 +++++++++++++++++++ internal/state/helpers.go | 2 +- internal/state/state.go | 2 +- 11 files changed, 110 insertions(+), 35 deletions(-) create mode 100644 internal/state/config/token.go diff --git a/docs/reference/manual/hcloud_config.md b/docs/reference/manual/hcloud_config.md index 5e9d48bee..3552c9983 100644 --- a/docs/reference/manual/hcloud_config.md +++ b/docs/reference/manual/hcloud_config.md @@ -29,6 +29,7 @@ for each context. Below is a list of all non-preference options: | config | Config file path (default "~/.config/hcloud/cli.toml") | string | | HCLOUD\_CONFIG | --config | | context | Currently active context | string | active\_context | HCLOUD\_CONTEXT | --context | | token | Hetzner Cloud API token | string | token | HCLOUD\_TOKEN | | +| token\_command | Command to retrieve Hetzner Cloud API token | string | token\_command | HCLOUD\_TOKEN\_COMMAND | | Since the above options are not preferences, they cannot be modified with 'hcloud config set' or 'hcloud config unset'. However, you are able to retrieve them using 'hcloud config get' and 'hcloud config list'. diff --git a/docs/reference/manual/hcloud_context_create.md b/docs/reference/manual/hcloud_context_create.md index 29d0284a4..28b601d2f 100644 --- a/docs/reference/manual/hcloud_context_create.md +++ b/docs/reference/manual/hcloud_context_create.md @@ -9,8 +9,9 @@ hcloud context create [--token-from-env] ### Options ``` - -h, --help help for create - --token-from-env If true, the HCLOUD_TOKEN from the environment will be used without asking + -h, --help help for create + --token-from-command string If set, this command will be executed to retrieve the token on each run + --token-from-env If true, the HCLOUD_TOKEN from the environment will be used without asking ``` ### Options inherited from parent commands diff --git a/internal/cmd/config/helptext/other.md b/internal/cmd/config/helptext/other.md index 19828fe87..405038d3e 100644 --- a/internal/cmd/config/helptext/other.md +++ b/internal/cmd/config/helptext/other.md @@ -3,3 +3,4 @@ | config | Config file path (default "~/.config/hcloud/cli.toml") | string | | HCLOUD\_CONFIG | --config | | context | Currently active context | string | active\_context | HCLOUD\_CONTEXT | --context | | token | Hetzner Cloud API token | string | token | HCLOUD\_TOKEN | | +| token\_command | Command to retrieve Hetzner Cloud API token | string | token\_command | HCLOUD\_TOKEN\_COMMAND | | diff --git a/internal/cmd/config/helptext/other.txt b/internal/cmd/config/helptext/other.txt index b5eb56ab6..87d3af89f 100644 --- a/internal/cmd/config/helptext/other.txt +++ b/internal/cmd/config/helptext/other.txt @@ -1,14 +1,18 @@ -┌─────────┬──────────────────────┬────────┬────────────────┬──────────────────────┬───────────┐ -│ OPTION │ DESCRIPTION │ TYPE │ CONFIG KEY │ ENVIRONMENT VARIABLE │ FLAG │ -├─────────┼──────────────────────┼────────┼────────────────┼──────────────────────┼───────────┤ -│ config │ Config file path │ string │ │ HCLOUD_CONFIG │ --config │ -│ │ (default │ │ │ │ │ -│ │ "~/.config/hcloud/cl │ │ │ │ │ -│ │ i.toml") │ │ │ │ │ -├─────────┼──────────────────────┼────────┼────────────────┼──────────────────────┼───────────┤ -│ context │ Currently active │ string │ active_context │ HCLOUD_CONTEXT │ --context │ -│ │ context │ │ │ │ │ -├─────────┼──────────────────────┼────────┼────────────────┼──────────────────────┼───────────┤ -│ token │ Hetzner Cloud API │ string │ token │ HCLOUD_TOKEN │ │ -│ │ token │ │ │ │ │ -└─────────┴──────────────────────┴────────┴────────────────┴──────────────────────┴───────────┘ +┌───────────────┬──────────────────────┬────────┬────────────────┬──────────────────────┬───────────┐ +│ OPTION │ DESCRIPTION │ TYPE │ CONFIG KEY │ ENVIRONMENT VARIABLE │ FLAG │ +├───────────────┼──────────────────────┼────────┼────────────────┼──────────────────────┼───────────┤ +│ config │ Config file path │ string │ │ HCLOUD_CONFIG │ --config │ +│ │ (default │ │ │ │ │ +│ │ "~/.config/hcloud/cl │ │ │ │ │ +│ │ i.toml") │ │ │ │ │ +├───────────────┼──────────────────────┼────────┼────────────────┼──────────────────────┼───────────┤ +│ context │ Currently active │ string │ active_context │ HCLOUD_CONTEXT │ --context │ +│ │ context │ │ │ │ │ +├───────────────┼──────────────────────┼────────┼────────────────┼──────────────────────┼───────────┤ +│ token │ Hetzner Cloud API │ string │ token │ HCLOUD_TOKEN │ │ +│ │ token │ │ │ │ │ +├───────────────┼──────────────────────┼────────┼────────────────┼──────────────────────┼───────────┤ +│ token_command │ Command to retrieve │ string │ token_command │ HCLOUD_TOKEN_COMMAND │ │ +│ │ Hetzner Cloud API │ │ │ │ │ +│ │ token │ │ │ │ │ +└───────────────┴──────────────────────┴────────┴────────────────┴──────────────────────┴───────────┘ diff --git a/internal/cmd/context/create.go b/internal/cmd/context/create.go index b73b467cd..b41ea4abb 100644 --- a/internal/cmd/context/create.go +++ b/internal/cmd/context/create.go @@ -17,7 +17,7 @@ import ( func NewCreateCommand(s state.State) *cobra.Command { cmd := &cobra.Command{ - Use: "create [--token-from-env] ", + Use: "create [options] ", Short: "Create a new context", Args: util.Validate, TraverseChildren: true, @@ -26,11 +26,14 @@ func NewCreateCommand(s state.State) *cobra.Command { RunE: state.Wrap(s, runCreate), } cmd.Flags().Bool("token-from-env", false, "If true, the HCLOUD_TOKEN from the environment will be used without asking") + cmd.Flags().String("token-from-command", "", "If set, this command will be executed to retrieve the token on each run") + cmd.MarkFlagsMutuallyExclusive("token-from-env", "token-from-command") return cmd } func runCreate(s state.State, cmd *cobra.Command, args []string) error { tokenFromEnv, _ := cmd.Flags().GetBool("token-from-env") + tokenCmd, _ := cmd.Flags().GetString("token-from-command") cfg := s.Config() if !s.Terminal().StdoutIsTerminal() && !tokenFromEnv { @@ -47,8 +50,7 @@ func runCreate(s state.State, cmd *cobra.Command, args []string) error { var token string - envToken := os.Getenv("HCLOUD_TOKEN") - if envToken != "" { + if envToken := os.Getenv("HCLOUD_TOKEN"); tokenCmd == "" && envToken != "" { switch { case len(envToken) != 64: if tokenFromEnv { @@ -67,7 +69,7 @@ func runCreate(s state.State, cmd *cobra.Command, args []string) error { } } - if token == "" { + if tokenCmd == "" && token == "" { if tokenFromEnv { return errors.New("no token provided") } @@ -92,7 +94,7 @@ func runCreate(s state.State, cmd *cobra.Command, args []string) error { } } - context := config.NewContext(name, token) + context := config.NewContext(name, token, tokenCmd) cfg.SetContexts(append(cfg.Contexts(), context)) cfg.SetActiveContext(context) diff --git a/internal/state/config/config.go b/internal/state/config/config.go index b90822072..1d0b25c08 100644 --- a/internal/state/config/config.go +++ b/internal/state/config/config.go @@ -1,7 +1,6 @@ package config import ( - "bytes" "errors" "fmt" "io" @@ -150,11 +149,13 @@ func (cfg *config) Read(f any) error { } if cfg.schema.ActiveContext != "" { - // ReadConfig resets the current config and reads the new values + // We set the currently active context using viper.MergeConfigMap. // We don't use viper.Set here because of the value hierarchy. We want the env and flags to // be able to override the currently active context. viper.Set would take precedence over // env and flags. - err = cfg.v.ReadConfig(bytes.NewReader([]byte(fmt.Sprintf("context = %q\n", cfg.schema.ActiveContext)))) + err = cfg.v.MergeConfigMap(map[string]any{ + "context": cfg.schema.ActiveContext, + }) if err != nil { return err } @@ -189,10 +190,13 @@ func (cfg *config) Read(f any) error { if err = cfg.activeContext.ContextPreferences.merge(cfg.v); err != nil { return err } - // Merge token into viper + // Merge token and token_cmd into viper // We use viper.MergeConfig here for the same reason as above, except for // that we merge the config instead of replacing it. - if err = cfg.v.MergeConfig(bytes.NewReader([]byte(fmt.Sprintf(`token = "%s"`, cfg.activeContext.ContextToken)))); err != nil { + if err = cfg.v.MergeConfigMap(map[string]any{ + "token": cfg.activeContext.ContextToken, + "token_command": cfg.activeContext.ContextTokenCommand, + }); err != nil { return err } } diff --git a/internal/state/config/context.go b/internal/state/config/context.go index 9127f6890..2f32137ca 100644 --- a/internal/state/config/context.go +++ b/internal/state/config/context.go @@ -6,17 +6,19 @@ type Context interface { Preferences() Preferences } -func NewContext(name, token string) Context { +func NewContext(name, token, tokenCmd string) Context { return &context{ - ContextName: name, - ContextToken: token, + ContextName: name, + ContextToken: token, + ContextTokenCommand: tokenCmd, } } type context struct { - ContextName string `toml:"name"` - ContextToken string `toml:"token"` - ContextPreferences Preferences `toml:"preferences"` + ContextName string `toml:"name"` + ContextToken string `toml:"token,omitempty"` + ContextTokenCommand string `toml:"token_command,omitempty"` + ContextPreferences Preferences `toml:"preferences"` } func (ctx *context) Name() string { @@ -24,7 +26,7 @@ func (ctx *context) Name() string { } // Token returns the token for the context. -// If you just need the token regardless of the context, please use [OptionToken] instead. +// If you just need the token regardless of the context, please use [config.RetrieveToken] instead. func (ctx *context) Token() string { return ctx.ContextToken } diff --git a/internal/state/config/options.go b/internal/state/config/options.go index 7f9c21d69..bfb4327d1 100644 --- a/internal/state/config/options.go +++ b/internal/state/config/options.go @@ -99,6 +99,15 @@ var ( nil, ) + OptionTokenCommand = newOpt( + "token_command", + "Command to retrieve Hetzner Cloud API token", + "", + OptionFlagConfig|OptionFlagEnv, + nil, + nil, + ) + OptionContext = newOpt( "context", "Currently active context", diff --git a/internal/state/config/token.go b/internal/state/config/token.go new file mode 100644 index 000000000..5c275c514 --- /dev/null +++ b/internal/state/config/token.go @@ -0,0 +1,51 @@ +package config + +import ( + "fmt" + "os/exec" + "runtime" + "strings" +) + +func RetrieveToken(c Config) (string, error) { + tok, err := OptionToken.Get(c) + if err != nil { + return "", err + } + if tok != "" { + return tok, nil + } + + cmdStr, err := OptionTokenCommand.Get(c) + if err != nil { + return "", err + } + if cmdStr != "" { + return tokenFromCommand(cmdStr) + } + return "", nil +} + +// We might need to run the command to retrieve HCLOUD_TOKEN multiple times, for example when running tests. +// Since the command is provided by the user and might be expensive, we cache the command result after the first successful run. +var cmdCache = make(map[string]string) + +func tokenFromCommand(cmdStr string) (string, error) { + if tok, ok := cmdCache[cmdStr]; ok { + return tok, nil + } + + var cmd *exec.Cmd + if runtime.GOOS == "windows" { + cmd = exec.Command("cmd", "/C", cmdStr) + } else { + cmd = exec.Command("sh", "-c", cmdStr) + } + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("could not retrieve token: %w", err) + } + tok := strings.TrimSpace(string(out)) + cmdCache[cmdStr] = tok + return tok, nil +} diff --git a/internal/state/helpers.go b/internal/state/helpers.go index f18165660..0fa76f548 100644 --- a/internal/state/helpers.go +++ b/internal/state/helpers.go @@ -15,7 +15,7 @@ func Wrap(s State, f func(State, *cobra.Command, []string) error) func(*cobra.Co } func (c *state) EnsureToken(_ *cobra.Command, _ []string) error { - token, err := config.OptionToken.Get(c.config) + token, err := config.RetrieveToken(c.config) if err != nil { return err } diff --git a/internal/state/state.go b/internal/state/state.go index 4e883c93d..a2207abb7 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -64,7 +64,7 @@ func (c *state) Terminal() terminal.Terminal { } func (c *state) newClient() (hcapi2.Client, error) { - tok, err := config.OptionToken.Get(c.config) + tok, err := config.RetrieveToken(c.config) if err != nil { return nil, err } From a6994576db032890974a300c48e6616168f44b25 Mon Sep 17 00:00:00 2001 From: phm07 <22707808+phm07@users.noreply.github.com> Date: Fri, 15 May 2026 16:39:19 +0200 Subject: [PATCH 2/2] set HCLOUD_CONTEXT env var and redirect stderr --- internal/state/config/token.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/internal/state/config/token.go b/internal/state/config/token.go index 5c275c514..b6e91ccea 100644 --- a/internal/state/config/token.go +++ b/internal/state/config/token.go @@ -2,6 +2,7 @@ package config import ( "fmt" + "os" "os/exec" "runtime" "strings" @@ -21,7 +22,7 @@ func RetrieveToken(c Config) (string, error) { return "", err } if cmdStr != "" { - return tokenFromCommand(cmdStr) + return tokenFromCommand(c, cmdStr) } return "", nil } @@ -30,7 +31,7 @@ func RetrieveToken(c Config) (string, error) { // Since the command is provided by the user and might be expensive, we cache the command result after the first successful run. var cmdCache = make(map[string]string) -func tokenFromCommand(cmdStr string) (string, error) { +func tokenFromCommand(c Config, cmdStr string) (string, error) { if tok, ok := cmdCache[cmdStr]; ok { return tok, nil } @@ -41,6 +42,10 @@ func tokenFromCommand(cmdStr string) (string, error) { } else { cmd = exec.Command("sh", "-c", cmdStr) } + + cmd.Env = append(cmd.Environ(), fmt.Sprintf("HCLOUD_CONTEXT=%s", c.ActiveContext().Name())) + cmd.Stderr = os.Stderr + out, err := cmd.Output() if err != nil { return "", fmt.Errorf("could not retrieve token: %w", err)