From 94d07d65491518007a796b55100f764a539d1f3c Mon Sep 17 00:00:00 2001 From: Pierre Gambarotto Date: Tue, 16 Jun 2026 08:49:02 +0200 Subject: [PATCH 1/7] execute command to retreive token --- internal/cli/auth.go | 109 ++++++++++++++++++++++++++++++--- internal/cli/auth_test.go | 90 ++++++++++++++++++++++++++- internal/config/config.go | 29 ++++++++- internal/config/config_test.go | 95 ++++++++++++++++++++++++++++ 4 files changed, 312 insertions(+), 11 deletions(-) diff --git a/internal/cli/auth.go b/internal/cli/auth.go index 0d3b78f..5b04aef 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -2,7 +2,9 @@ package cli import ( "bufio" + "bytes" "fmt" + "io" "os" "strings" @@ -12,6 +14,7 @@ import ( "golang.org/x/term" ) + var authCmd = &cobra.Command{ Use: "auth", Short: "Manage authentication", @@ -53,13 +56,11 @@ func authLoginCmd() *cobra.Command { if !interactive { return fmt.Errorf("--token is required in non-interactive mode") } - _, _ = fmt.Fprintf(os.Stderr, "Token for %s: ", domain) - raw, err := term.ReadPassword(int(os.Stdin.Fd())) - _, _ = fmt.Fprintln(os.Stderr) // newline after hidden input + var err error + token, err = readTokenInteractive(domain) if err != nil { return fmt.Errorf("reading token: %w", err) } - token = strings.TrimSpace(string(raw)) if token == "" { return fmt.Errorf("token cannot be empty") } @@ -80,6 +81,96 @@ func authLoginCmd() *cobra.Command { return cmd } +// readTokenInteractive prompts for a token in raw mode. +// Pressing Ctrl+E as the first key switches to command mode (stored as "!cmd"). +func readTokenInteractive(domain string) (string, error) { + const ctrlE = 0x05 + + fd := int(os.Stdin.Fd()) + _, _ = fmt.Fprintf(os.Stderr, "Token for %s (Ctrl+E first for command): ", domain) + + oldState, err := term.MakeRaw(fd) + if err != nil { + return "", fmt.Errorf("setting raw mode: %w", err) + } + + ch, err := readOneByte(os.Stdin) + if err != nil { + _ = term.Restore(fd, oldState) + _, _ = fmt.Fprintln(os.Stderr) + return "", err + } + + if ch == ctrlE { + _ = term.Restore(fd, oldState) + _, _ = fmt.Fprintln(os.Stderr) + return readCommandInteractive(domain) + } + + r := io.MultiReader(bytes.NewReader([]byte{ch}), os.Stdin) + return readRawToken(fd, oldState, r) +} + +func readOneByte(r io.Reader) (byte, error) { + b := make([]byte, 1) + _, err := r.Read(b) + return b[0], err +} + +// readRawToken accumulates a token character by character in raw mode. +// Always restores the terminal before returning. +func readRawToken(fd int, oldState *term.State, r io.Reader) (string, error) { + const ( + ctrlC = 0x03 + ctrlD = 0x04 + enter = 0x0D + newline = 0x0A + backspace = 0x7F + del = 0x08 + printable = 0x20 + ) + defer func() { + _ = term.Restore(fd, oldState) + _, _ = fmt.Fprintln(os.Stderr) + }() + + var buf []byte + b := make([]byte, 1) + for { + if _, err := r.Read(b); err != nil { + return "", err + } + + switch b[0] { + case ctrlC, ctrlD: + return "", fmt.Errorf("interrupted") + case enter, newline: + return strings.TrimSpace(string(buf)), nil + case backspace, del: + if len(buf) > 0 { + buf = buf[:len(buf)-1] + } + default: + if b[0] >= printable { + buf = append(buf, b[0]) + } + } + } +} + +// readCommandInteractive prompts the user to enter a shell command +// whose output will be used as the token at runtime. +// Returns the command prefixed with "!" for storage in the config. +func readCommandInteractive(domain string) (string, error) { + _, _ = fmt.Fprintf(os.Stderr, "Command for token (e.g. rbw get %s): ", domain) + line, _ := bufio.NewReader(os.Stdin).ReadString('\n') + cmd := strings.TrimSpace(line) + if cmd == "" { + return "", fmt.Errorf("command cannot be empty") + } + return "!" + cmd, nil +} + func authStatusCmd() *cobra.Command { return &cobra.Command{ Use: "status", @@ -111,7 +202,11 @@ func authStatusCmd() *cobra.Command { sources = append(sources, "env") } if cfgSection.Token != "" { - sources = append(sources, "config") + if cfgSection.TokenExec != "" { + sources = append(sources, fmt.Sprintf("config (cmd: %s)", cfgSection.TokenExec)) + } else { + sources = append(sources, "config") + } } status := "no token" @@ -121,9 +216,9 @@ func authStatusCmd() *cobra.Command { forgeType := cfgSection.Type if forgeType != "" { - _, _ = fmt.Fprintf(os.Stdout, "%s (%s): %s\n", d, forgeType, status) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s (%s): %s\n", d, forgeType, status) } else { - _, _ = fmt.Fprintf(os.Stdout, "%s: %s\n", d, status) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n", d, status) } } diff --git a/internal/cli/auth_test.go b/internal/cli/auth_test.go index 150c5d8..64a6326 100644 --- a/internal/cli/auth_test.go +++ b/internal/cli/auth_test.go @@ -101,7 +101,6 @@ func TestAuthStatus(t *testing.T) { config.ResetCache() defer config.ResetCache() - // Write a config with a domain cfgDir := filepath.Join(dir, "forge") _ = os.MkdirAll(cfgDir, 0700) _ = os.WriteFile(filepath.Join(cfgDir, "config"), []byte(`[gitea.example.com] @@ -114,8 +113,95 @@ token = some_token rootCmd.SetErr(&buf) rootCmd.SetArgs([]string{"auth", "status"}) - err := rootCmd.Execute() + if err := rootCmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := buf.String() + if !strings.Contains(out, "gitea.example.com") { + t.Errorf("expected domain in output, got: %s", out) + } + if !strings.Contains(out, "token from config") { + t.Errorf("expected token source in output, got: %s", out) + } +} + +func TestAuthStatusWithTokenCmd(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + config.ResetCache() + defer config.ResetCache() + + cfgDir := filepath.Join(dir, "forge") + _ = os.MkdirAll(cfgDir, 0700) + _ = os.WriteFile(filepath.Join(cfgDir, "config"), []byte(`[gitlab.example.com] +type = gitlab +token = !echo secret +`), 0600) + + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{"auth", "status"}) + + if err := rootCmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := buf.String() + if !strings.Contains(out, "cmd: !echo secret") { + t.Errorf("expected command source in output, got: %s", out) + } +} + +func TestReadOneByte(t *testing.T) { + b, err := readOneByte(bytes.NewReader([]byte{'x'})) if err != nil { t.Fatalf("unexpected error: %v", err) } + if b != 'x' { + t.Errorf("expected 'x', got %q", b) + } +} + +func TestReadCommandInteractive(t *testing.T) { + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + origStdin := os.Stdin + os.Stdin = r + defer func() { os.Stdin = origStdin; _ = r.Close() }() + + _, _ = w.WriteString("rbw get github-token\n") + _ = w.Close() + + result, err := readCommandInteractive("github.com") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != "!rbw get github-token" { + t.Errorf("expected %q, got %q", "!rbw get github-token", result) + } +} + +func TestReadCommandInteractiveEmpty(t *testing.T) { + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + origStdin := os.Stdin + os.Stdin = r + defer func() { os.Stdin = origStdin; _ = r.Close() }() + + _, _ = w.WriteString("\n") + _ = w.Close() + + _, err = readCommandInteractive("github.com") + if err == nil { + t.Fatal("expected error for empty command") + } + if !strings.Contains(err.Error(), "cannot be empty") { + t.Errorf("expected empty command error, got: %v", err) + } } diff --git a/internal/config/config.go b/internal/config/config.go index d7b9085..edcc3cc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "os" + "os/exec" "path/filepath" "runtime" "strings" @@ -30,7 +31,8 @@ type DefaultSection struct { type DomainSection struct { Type string // github, gitlab, gitea, forgejo, bitbucket - Token string // only from user config, never .forge + Token string // resolved token value; only from user config, never .forge + TokenExec string // non-empty when token came from a "!cmd" reference (stores the raw value) SSHHost string // alternate host for git-over-ssh; the section name remains the API host GitProtocol string // https or ssh; overrides default } @@ -94,6 +96,20 @@ func parseGitProtocol(v string) (string, error) { } } +// execValue runs cmd via sh -c and returns its trimmed stdout. +// Shell features (pipes, quotes, substitutions) are supported. +func execValue(cmd string) (string, error) { + cmd = strings.TrimSpace(cmd) + if cmd == "" { + return "", fmt.Errorf("empty command") + } + out, err := exec.Command("sh", "-c", cmd).Output() + if err != nil { + return "", fmt.Errorf("%q: %w", cmd, err) + } + return strings.TrimSpace(string(out)), nil +} + // ResetCache clears the cached config. Only useful in tests. func ResetCache() { once = sync.Once{} @@ -176,7 +192,16 @@ func loadFile(cfg *Config, path string, allowTokens bool) error { } if allowTokens { if v, ok := kv["token"]; ok { - ds.Token = v + if strings.HasPrefix(v, "!") { + resolved, err := execValue(v[1:]) + if err != nil { + return fmt.Errorf("%s: [%s] token command: %w", path, name, err) + } + ds.Token = resolved + ds.TokenExec = v + } else { + ds.Token = v + } } } cfg.Domains[name] = ds diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 6e8a073..adae3e6 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -483,6 +483,101 @@ token = old_token } } +func TestLoadFileTokenCommand(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config") + _ = os.WriteFile(path, []byte(`[github.com] +token = !echo mytoken +`), 0600) + + cfg := &Config{Domains: make(map[string]DomainSection)} + if err := loadFile(cfg, path, true); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + ds := cfg.Domains["github.com"] + if ds.Token != "mytoken" { + t.Errorf("expected resolved token %q, got %q", "mytoken", ds.Token) + } + if ds.TokenExec != "!echo mytoken" { + t.Errorf("expected TokenExec=%q, got %q", "!echo mytoken", ds.TokenExec) + } +} + +func TestLoadFileTokenCommandFails(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config") + _ = os.WriteFile(path, []byte(`[github.com] +token = !false +`), 0600) + + cfg := &Config{Domains: make(map[string]DomainSection)} + err := loadFile(cfg, path, true) + if err == nil { + t.Fatal("expected error from failing command, got nil") + } + if !strings.Contains(err.Error(), "token command") { + t.Errorf("expected error to mention token command, got: %v", err) + } +} + +func TestLoadFileTokenCommandMissingBinary(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config") + _ = os.WriteFile(path, []byte(`[github.com] +token = !no-such-binary-xyz +`), 0600) + + cfg := &Config{Domains: make(map[string]DomainSection)} + err := loadFile(cfg, path, true) + if err == nil { + t.Fatal("expected error for missing binary, got nil") + } +} + +func TestLoadFileTokenCommandNotExecutedInProjectConfig(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".forge") + _ = os.WriteFile(path, []byte(`[github.com] +token = !echo secret +`), 0644) + + cfg := &Config{Domains: make(map[string]DomainSection)} + // allowTokens=false: command must not be executed, token must stay empty + if err := loadFile(cfg, path, false); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + ds := cfg.Domains["github.com"] + if ds.Token != "" { + t.Errorf("project config should not resolve token commands, got %q", ds.Token) + } + if ds.TokenExec != "" { + t.Errorf("project config should not set TokenExec, got %q", ds.TokenExec) + } +} + +func TestLoadFileLiteralTokenUnchanged(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config") + _ = os.WriteFile(path, []byte(`[github.com] +token = ghp_literal +`), 0600) + + cfg := &Config{Domains: make(map[string]DomainSection)} + if err := loadFile(cfg, path, true); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + ds := cfg.Domains["github.com"] + if ds.Token != "ghp_literal" { + t.Errorf("expected literal token, got %q", ds.Token) + } + if ds.TokenExec != "" { + t.Errorf("expected empty TokenExec for literal token, got %q", ds.TokenExec) + } +} + func TestGitProtocolFor(t *testing.T) { ResetCache() defer ResetCache() From 4cdbca4ffa86e75f853888f132de3cfe5f93c999 Mon Sep 17 00:00:00 2001 From: Pierre Gambarotto Date: Tue, 16 Jun 2026 08:54:55 +0200 Subject: [PATCH 2/7] doc for retreive token's command --- README.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/README.md b/README.md index 87fbfd6..3705d48 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,15 @@ forge auth login --domain github.com --token ghp_abc123 forge auth login --domain gitea.example.com --token abc123 --type gitea ``` +When prompted for a token interactively, press **Ctrl+E** as the first key +to enter a command instead. The command's output will be used as the token +at runtime: + +``` +Token for github.com (Ctrl+E first for command): +Command for token (e.g. rbw get github.com): rbw get github-token +``` + Check what's configured with `forge auth status`. Tokens are resolved in this order: CLI flags, environment variables (`FORGE_TOKEN`, `GITHUB_TOKEN`/`GH_TOKEN`, `GITLAB_TOKEN`, `FORGEJO_TOKEN`/`GITEA_TOKEN`, `BITBUCKET_TOKEN`), then the config file at `~/.config/forge/config`. The target host is inferred from the current directory's git remote; use `--host` or `FORGE_HOST` to override it (for example `forge --host gitea.com repo list someone`). @@ -66,6 +75,25 @@ type = gitea token = abc123 ``` +Token values can be replaced with a shell command prefixed by `!`. The command +is executed each time forge needs the token and its stdout is used as the value. +This lets you fetch secrets from a password manager instead of storing them in +plain text: + +```ini +[github.com] +token = !rbw get github-token + +[gitlab.com] +token = !pass show forge/gitlab + +[myhostedgitlab.example.com] +token = !rbw get --raw myhostedgitlab | jq -r '.fields | map(select(.name == "token"))[0].value' +``` + +`forge auth login` sets this up interactively (Ctrl+E at the token prompt). +`forge auth status` shows the command source instead of the resolved value. + `.forge` in the repo root is for per-project settings, committed to the repo, no tokens: ```ini From 8ddcfcfc5d638851a229dc061fff0fbef24bc397 Mon Sep 17 00:00:00 2001 From: Pierre Gambarotto Date: Tue, 16 Jun 2026 09:34:57 +0200 Subject: [PATCH 3/7] fmt ! --- internal/cli/auth.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/cli/auth.go b/internal/cli/auth.go index 5b04aef..cd0168b 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -14,7 +14,6 @@ import ( "golang.org/x/term" ) - var authCmd = &cobra.Command{ Use: "auth", Short: "Manage authentication", From 35f47406c199fb4f4e5a37426068b1a31cbcb667 Mon Sep 17 00:00:00 2001 From: Pierre Gambarotto Date: Wed, 17 Jun 2026 17:33:22 +0200 Subject: [PATCH 4/7] token command has FORGE_DOMAIN set --- README.md | 11 +++++++++++ internal/config/config.go | 9 ++++++--- internal/config/config_test.go | 18 ++++++++++++++++++ 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 3705d48..54ba590 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,17 @@ token = !pass show forge/gitlab token = !rbw get --raw myhostedgitlab | jq -r '.fields | map(select(.name == "token"))[0].value' ``` +The variable `FORGE_DOMAIN` is set to the domain name when the command runs, +so a single command can serve multiple domains: + +```ini +[github.com] +token = !pass show forge/$FORGE_DOMAIN + +[myhostedgitlab.example.com] +token = !pass show forge/$FORGE_DOMAIN +``` + `forge auth login` sets this up interactively (Ctrl+E at the token prompt). `forge auth status` shows the command source instead of the resolved value. diff --git a/internal/config/config.go b/internal/config/config.go index edcc3cc..e8438be 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -98,12 +98,15 @@ func parseGitProtocol(v string) (string, error) { // execValue runs cmd via sh -c and returns its trimmed stdout. // Shell features (pipes, quotes, substitutions) are supported. -func execValue(cmd string) (string, error) { +// FORGE_DOMAIN is set to domain in the command environment. +func execValue(cmd, domain string) (string, error) { cmd = strings.TrimSpace(cmd) if cmd == "" { return "", fmt.Errorf("empty command") } - out, err := exec.Command("sh", "-c", cmd).Output() + c := exec.Command("sh", "-c", cmd) + c.Env = append(os.Environ(), "FORGE_DOMAIN="+domain) + out, err := c.Output() if err != nil { return "", fmt.Errorf("%q: %w", cmd, err) } @@ -193,7 +196,7 @@ func loadFile(cfg *Config, path string, allowTokens bool) error { if allowTokens { if v, ok := kv["token"]; ok { if strings.HasPrefix(v, "!") { - resolved, err := execValue(v[1:]) + resolved, err := execValue(v[1:], name) if err != nil { return fmt.Errorf("%s: [%s] token command: %w", path, name, err) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index adae3e6..ecad3ed 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -504,6 +504,24 @@ token = !echo mytoken } } +func TestLoadFileTokenCommandForgeDomain(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config") + _ = os.WriteFile(path, []byte(`[gitlab.example.com] +token = !echo $FORGE_DOMAIN +`), 0600) + + cfg := &Config{Domains: make(map[string]DomainSection)} + if err := loadFile(cfg, path, true); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + ds := cfg.Domains["gitlab.example.com"] + if ds.Token != "gitlab.example.com" { + t.Errorf("expected FORGE_DOMAIN=gitlab.example.com, got %q", ds.Token) + } +} + func TestLoadFileTokenCommandFails(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config") From 805878f9851aef96310c69de7896c57ca1abda8a Mon Sep 17 00:00:00 2001 From: Pierre Gambarotto Date: Fri, 26 Jun 2026 11:48:58 +0200 Subject: [PATCH 5/7] resolve token command lazily at resolution time, not at config load --- README.md | 8 ++++---- internal/cli/auth.go | 10 ++++------ internal/config/config.go | 25 +++++++++++++++-------- internal/config/config_test.go | 36 ++++++++++++++++++++++++---------- internal/resolve/resolve.go | 3 ++- 5 files changed, 53 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 54ba590..70bb268 100644 --- a/README.md +++ b/README.md @@ -75,10 +75,10 @@ type = gitea token = abc123 ``` -Token values can be replaced with a shell command prefixed by `!`. The command -is executed each time forge needs the token and its stdout is used as the value. -This lets you fetch secrets from a password manager instead of storing them in -plain text: +Token values can be replaced with a shell command prefixed by `!` (Unix only). +The command is executed via `sh -c` each time forge needs the token and its +stdout is used as the value. This lets you fetch secrets from a password manager +instead of storing them in plain text: ```ini [github.com] diff --git a/internal/cli/auth.go b/internal/cli/auth.go index cd0168b..b585cc4 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -200,12 +200,10 @@ func authStatusCmd() *cobra.Command { if envToken != "" { sources = append(sources, "env") } - if cfgSection.Token != "" { - if cfgSection.TokenExec != "" { - sources = append(sources, fmt.Sprintf("config (cmd: %s)", cfgSection.TokenExec)) - } else { - sources = append(sources, "config") - } + if cfgSection.TokenExec != "" { + sources = append(sources, fmt.Sprintf("config (cmd: %s)", cfgSection.TokenExec)) + } else if cfgSection.Token != "" { + sources = append(sources, "config") } status := "no token" diff --git a/internal/config/config.go b/internal/config/config.go index e8438be..972a353 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -37,6 +37,15 @@ type DomainSection struct { GitProtocol string // https or ssh; overrides default } +// ResolveToken returns the token for this domain. If TokenExec is set, it +// executes the command and returns its output; otherwise it returns Token. +func (ds DomainSection) ResolveToken(domain string) (string, error) { + if ds.TokenExec != "" { + return execValue(ds.TokenExec[1:], domain) + } + return ds.Token, nil +} + // DomainForSSHHost returns the API domain (the section name) whose ssh_host // matches the given host, or "" if none. Self-hosted GitLab in particular can // serve git-over-ssh on a different host than the web/API, so a remote URL like @@ -99,18 +108,23 @@ func parseGitProtocol(v string) (string, error) { // execValue runs cmd via sh -c and returns its trimmed stdout. // Shell features (pipes, quotes, substitutions) are supported. // FORGE_DOMAIN is set to domain in the command environment. +// Stdin and stderr are wired to the terminal so interactive prompts +// (e.g. pinentry, rbw unlock) work and error output is visible directly. func execValue(cmd, domain string) (string, error) { cmd = strings.TrimSpace(cmd) if cmd == "" { return "", fmt.Errorf("empty command") } + var stdout strings.Builder c := exec.Command("sh", "-c", cmd) c.Env = append(os.Environ(), "FORGE_DOMAIN="+domain) - out, err := c.Output() - if err != nil { + c.Stdin = os.Stdin + c.Stdout = &stdout + c.Stderr = os.Stderr + if err := c.Run(); err != nil { return "", fmt.Errorf("%q: %w", cmd, err) } - return strings.TrimSpace(string(out)), nil + return strings.TrimSpace(stdout.String()), nil } // ResetCache clears the cached config. Only useful in tests. @@ -196,11 +210,6 @@ func loadFile(cfg *Config, path string, allowTokens bool) error { if allowTokens { if v, ok := kv["token"]; ok { if strings.HasPrefix(v, "!") { - resolved, err := execValue(v[1:], name) - if err != nil { - return fmt.Errorf("%s: [%s] token command: %w", path, name, err) - } - ds.Token = resolved ds.TokenExec = v } else { ds.Token = v diff --git a/internal/config/config_test.go b/internal/config/config_test.go index ecad3ed..627e499 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -496,12 +496,20 @@ token = !echo mytoken } ds := cfg.Domains["github.com"] - if ds.Token != "mytoken" { - t.Errorf("expected resolved token %q, got %q", "mytoken", ds.Token) + if ds.Token != "" { + t.Errorf("loadFile should not resolve token command, got Token=%q", ds.Token) } if ds.TokenExec != "!echo mytoken" { t.Errorf("expected TokenExec=%q, got %q", "!echo mytoken", ds.TokenExec) } + + resolved, err := ds.ResolveToken("github.com") + if err != nil { + t.Fatalf("ResolveToken: %v", err) + } + if resolved != "mytoken" { + t.Errorf("expected resolved token %q, got %q", "mytoken", resolved) + } } func TestLoadFileTokenCommandForgeDomain(t *testing.T) { @@ -516,9 +524,12 @@ token = !echo $FORGE_DOMAIN t.Fatalf("unexpected error: %v", err) } - ds := cfg.Domains["gitlab.example.com"] - if ds.Token != "gitlab.example.com" { - t.Errorf("expected FORGE_DOMAIN=gitlab.example.com, got %q", ds.Token) + resolved, err := cfg.Domains["gitlab.example.com"].ResolveToken("gitlab.example.com") + if err != nil { + t.Fatalf("ResolveToken: %v", err) + } + if resolved != "gitlab.example.com" { + t.Errorf("expected FORGE_DOMAIN=gitlab.example.com, got %q", resolved) } } @@ -530,13 +541,14 @@ token = !false `), 0600) cfg := &Config{Domains: make(map[string]DomainSection)} - err := loadFile(cfg, path, true) + if err := loadFile(cfg, path, true); err != nil { + t.Fatalf("loadFile should not fail on bad command, got: %v", err) + } + + _, err := cfg.Domains["github.com"].ResolveToken("github.com") if err == nil { t.Fatal("expected error from failing command, got nil") } - if !strings.Contains(err.Error(), "token command") { - t.Errorf("expected error to mention token command, got: %v", err) - } } func TestLoadFileTokenCommandMissingBinary(t *testing.T) { @@ -547,7 +559,11 @@ token = !no-such-binary-xyz `), 0600) cfg := &Config{Domains: make(map[string]DomainSection)} - err := loadFile(cfg, path, true) + if err := loadFile(cfg, path, true); err != nil { + t.Fatalf("loadFile should not fail on missing binary, got: %v", err) + } + + _, err := cfg.Domains["github.com"].ResolveToken("github.com") if err == nil { t.Fatal("expected error for missing binary, got nil") } diff --git a/internal/resolve/resolve.go b/internal/resolve/resolve.go index 93c7191..e55dcc7 100644 --- a/internal/resolve/resolve.go +++ b/internal/resolve/resolve.go @@ -291,7 +291,8 @@ func TokenForDomain(domain string) string { if err != nil || cfg == nil { return "" } - return cfg.Domains[domain].Token + token, _ := cfg.Domains[domain].ResolveToken(domain) + return token } // TokenForDomainEnv looks up a token from environment variables only. From 9b258e80d3321d4f0354cd2f2ada8ee06984fe23 Mon Sep 17 00:00:00 2001 From: Pierre Gambarotto Date: Fri, 26 Jun 2026 11:59:02 +0200 Subject: [PATCH 6/7] do not store ! in the token command --- internal/cli/auth.go | 5 ++++- internal/cli/auth_test.go | 2 +- internal/config/config.go | 4 ++-- internal/config/config_test.go | 4 ++-- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/internal/cli/auth.go b/internal/cli/auth.go index b585cc4..d9597a6 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -162,7 +162,10 @@ func readRawToken(fd int, oldState *term.State, r io.Reader) (string, error) { // Returns the command prefixed with "!" for storage in the config. func readCommandInteractive(domain string) (string, error) { _, _ = fmt.Fprintf(os.Stderr, "Command for token (e.g. rbw get %s): ", domain) - line, _ := bufio.NewReader(os.Stdin).ReadString('\n') + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + if err != nil && line == "" { + return "", fmt.Errorf("reading command: %w", err) + } cmd := strings.TrimSpace(line) if cmd == "" { return "", fmt.Errorf("command cannot be empty") diff --git a/internal/cli/auth_test.go b/internal/cli/auth_test.go index 64a6326..81d4fd3 100644 --- a/internal/cli/auth_test.go +++ b/internal/cli/auth_test.go @@ -149,7 +149,7 @@ token = !echo secret } out := buf.String() - if !strings.Contains(out, "cmd: !echo secret") { + if !strings.Contains(out, "cmd: echo secret") { t.Errorf("expected command source in output, got: %s", out) } } diff --git a/internal/config/config.go b/internal/config/config.go index 972a353..e5b98fa 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -41,7 +41,7 @@ type DomainSection struct { // executes the command and returns its output; otherwise it returns Token. func (ds DomainSection) ResolveToken(domain string) (string, error) { if ds.TokenExec != "" { - return execValue(ds.TokenExec[1:], domain) + return execValue(ds.TokenExec, domain) } return ds.Token, nil } @@ -210,7 +210,7 @@ func loadFile(cfg *Config, path string, allowTokens bool) error { if allowTokens { if v, ok := kv["token"]; ok { if strings.HasPrefix(v, "!") { - ds.TokenExec = v + ds.TokenExec = v[1:] } else { ds.Token = v } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 627e499..1ace421 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -499,8 +499,8 @@ token = !echo mytoken if ds.Token != "" { t.Errorf("loadFile should not resolve token command, got Token=%q", ds.Token) } - if ds.TokenExec != "!echo mytoken" { - t.Errorf("expected TokenExec=%q, got %q", "!echo mytoken", ds.TokenExec) + if ds.TokenExec != "echo mytoken" { + t.Errorf("expected TokenExec=%q, got %q", "echo mytoken", ds.TokenExec) } resolved, err := ds.ResolveToken("github.com") From 3ca8572b5a058687c00094c80c5455e54100a770 Mon Sep 17 00:00:00 2001 From: Pierre Gambarotto Date: Fri, 26 Jun 2026 12:09:59 +0200 Subject: [PATCH 7/7] adds flag --token-cmd --- README.md | 9 ++++++-- internal/cli/auth.go | 24 +++++++++++++++++++-- internal/cli/auth_test.go | 45 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 70bb268..84df023 100644 --- a/README.md +++ b/README.md @@ -42,11 +42,14 @@ Store tokens with `forge auth login`: forge auth login # interactive: asks domain + token forge auth login --domain github.com --token ghp_abc123 forge auth login --domain gitea.example.com --token abc123 --type gitea +forge auth login --domain github.com --token-cmd 'rbw get github-token' ``` +`--token-cmd` stores a shell command instead of a literal token; the command +is run each time the token is needed (see [token commands](#token-commands) below). + When prompted for a token interactively, press **Ctrl+E** as the first key -to enter a command instead. The command's output will be used as the token -at runtime: +to enter a command instead: ``` Token for github.com (Ctrl+E first for command): @@ -75,6 +78,8 @@ type = gitea token = abc123 ``` +### Token commands + Token values can be replaced with a shell command prefixed by `!` (Unix only). The command is executed via `sh -c` each time forge needs the token and its stdout is used as the value. This lets you fetch secrets from a password manager diff --git a/internal/cli/auth.go b/internal/cli/auth.go index d9597a6..4b26a1a 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -23,12 +23,14 @@ func init() { rootCmd.AddCommand(authCmd) authCmd.AddCommand(authLoginCmd()) authCmd.AddCommand(authStatusCmd()) + authCmd.AddCommand(authTokenCmd()) } func authLoginCmd() *cobra.Command { var ( domain string token string + tokenCmd string forgeType string ) @@ -51,9 +53,12 @@ func authLoginCmd() *cobra.Command { } } - if token == "" { + switch { + case tokenCmd != "": + token = "!" + tokenCmd + case token == "": if !interactive { - return fmt.Errorf("--token is required in non-interactive mode") + return fmt.Errorf("--token or --token-cmd is required in non-interactive mode") } var err error token, err = readTokenInteractive(domain) @@ -76,7 +81,11 @@ func authLoginCmd() *cobra.Command { cmd.Flags().StringVar(&domain, "domain", "", "Forge domain (e.g. github.com, gitea.example.com)") cmd.Flags().StringVar(&token, "token", "", "API token") + cmd.Flags().StringVar(&tokenCmd, "token-cmd", "", "Shell command whose stdout is used as the token (Unix only)") cmd.Flags().StringVar(&forgeType, "type", "", "Forge type: github, gitlab, gitea, forgejo, bitbucket") + cmd.MarkFlagsMutuallyExclusive("token", "token-cmd") + return cmd +} return cmd } @@ -124,6 +133,7 @@ func readRawToken(fd int, oldState *term.State, r io.Reader) (string, error) { ctrlD = 0x04 enter = 0x0D newline = 0x0A + esc = 0x1B backspace = 0x7F del = 0x08 printable = 0x20 @@ -149,6 +159,16 @@ func readRawToken(fd int, oldState *term.State, r io.Reader) (string, error) { if len(buf) > 0 { buf = buf[:len(buf)-1] } + case esc: + // Consume the rest of the escape sequence (e.g. arrow keys: \x1b[D). + for { + if _, err := r.Read(b); err != nil { + return "", err + } + if b[0] >= 'A' && b[0] <= '~' { + break + } + } default: if b[0] >= printable { buf = append(buf, b[0]) diff --git a/internal/cli/auth_test.go b/internal/cli/auth_test.go index 81d4fd3..4d0ba78 100644 --- a/internal/cli/auth_test.go +++ b/internal/cli/auth_test.go @@ -95,6 +95,51 @@ func TestAuthLoginNonInteractive(t *testing.T) { } } +func TestAuthLoginTokenCmd(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + config.ResetCache() + defer config.ResetCache() + + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{ + "auth", "login", + "--domain", "github.com", + "--token-cmd", "rbw get github-token", + }) + + if err := rootCmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data, err := os.ReadFile(filepath.Join(dir, "forge", "config")) + if err != nil { + t.Fatalf("reading config: %v", err) + } + content := string(data) + if !strings.Contains(content, "token = !rbw get github-token") { + t.Errorf("expected token command in config, got:\n%s", content) + } +} + +func TestAuthLoginTokenAndTokenCmdMutuallyExclusive(t *testing.T) { + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{ + "auth", "login", + "--domain", "github.com", + "--token", "ghp_abc", + "--token-cmd", "rbw get github-token", + }) + + if err := rootCmd.Execute(); err == nil { + t.Fatal("expected error when both --token and --token-cmd are set") + } +} + func TestAuthStatus(t *testing.T) { dir := t.TempDir() t.Setenv("XDG_CONFIG_HOME", dir)