Skip to content
Open
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
1 change: 1 addition & 0 deletions docs/reference/manual/hcloud_config.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions docs/reference/manual/hcloud_context_create.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions internal/cmd/config/helptext/other.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | |
32 changes: 18 additions & 14 deletions internal/cmd/config/helptext/other.txt
Original file line number Diff line number Diff line change
@@ -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 │ │ │ │ │
└───────────────┴──────────────────────┴────────┴────────────────┴──────────────────────┴───────────┘
12 changes: 7 additions & 5 deletions internal/cmd/context/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import (

func NewCreateCommand(s state.State) *cobra.Command {
cmd := &cobra.Command{
Use: "create [--token-from-env] <name>",
Use: "create [options] <name>",
Short: "Create a new context",
Args: util.Validate,
TraverseChildren: true,
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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")
}
Expand All @@ -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)
Expand Down
14 changes: 9 additions & 5 deletions internal/state/config/config.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package config

import (
"bytes"
"errors"
"fmt"
"io"
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
}
Expand Down
16 changes: 9 additions & 7 deletions internal/state/config/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,27 @@ 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 {
return ctx.ContextName
}

// 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
}
Expand Down
9 changes: 9 additions & 0 deletions internal/state/config/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
56 changes: 56 additions & 0 deletions internal/state/config/token.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package config

import (
"fmt"
"os"
"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(c, 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(c Config, 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)
}
Comment on lines +40 to +44
Copy link
Copy Markdown
Member

@jooola jooola May 12, 2026

Choose a reason for hiding this comment

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

A few questions:

  • How can one define this config globally and pick the right context? See point below.
  • Why not pass down more details about the current context? E.g. as environment variables?
  • Why use a shell at all? I'd leave this for the users to pick a shell if needed
  • Could we store the command as list of strings, instead of a string? This makes passing down the arguments cleaner. Or we implement a similar approach like the docker CMD: If we have a list of string, no shell, if we have a string command, wrap it inside a shell?

Copy link
Copy Markdown
Contributor Author

@phm07 phm07 May 15, 2026

Choose a reason for hiding this comment

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

How can one define this config globally and pick the right context? See point below.
Why not pass down more details about the current context? E.g. as environment variables?

Good point, env variables would fix this

Why use a shell at all? I'd leave this for the users to pick a shell if needed

A shell offers more features like pipes, input/output file redirects etc. Also it allows passing the command as one string instead of a string slice, see below.

Could we store the command as list of strings, instead of a string? This makes passing down the arguments cleaner. Or we implement a similar approach like the docker CMD: If we have a list of string, no shell, if we have a string command, wrap it inside a shell?

List of strings would be fine, except the only problem would be how to configure it using the hcloud context create command. Passing it as a slice would mean passing --token-command multiple times which would be very cumbersome or to separate the parts with commas which would mean you couldn't use a comma in your token command. Accepting both a string and a string slice would be fine but I don't know if that would play well with config parsing.


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)
}
Comment on lines +49 to +52
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should we forward the command stderr to the cli stderr?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good idea

tok := strings.TrimSpace(string(out))
cmdCache[cmdStr] = tok
return tok, nil
}
2 changes: 1 addition & 1 deletion internal/state/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion internal/state/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down