From cea4eb4a44c34c7fea3e5a50d49c74a012384930 Mon Sep 17 00:00:00 2001 From: Pierre Gambarotto Date: Tue, 16 Jun 2026 08:49:02 +0200 Subject: [PATCH 01/11] 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 6cbb5a5892443621c29d2a7dedbaec527320f1f6 Mon Sep 17 00:00:00 2001 From: Pierre Gambarotto Date: Tue, 16 Jun 2026 08:54:55 +0200 Subject: [PATCH 02/11] 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 446b5588d66b85b4e1a979a44d0def02450b8a74 Mon Sep 17 00:00:00 2001 From: Pierre Gambarotto Date: Tue, 16 Jun 2026 09:34:57 +0200 Subject: [PATCH 03/11] 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 cc1b2e8944598a541b3764f5047dcfa99457c471 Mon Sep 17 00:00:00 2001 From: Pierre Gambarotto Date: Wed, 17 Jun 2026 17:33:22 +0200 Subject: [PATCH 04/11] 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 e410a903873702841621b84c2a85b5820d37cfc8 Mon Sep 17 00:00:00 2001 From: Pierre Gambarotto Date: Fri, 26 Jun 2026 11:48:58 +0200 Subject: [PATCH 05/11] 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 9a893e3..af0c346 100644 --- a/internal/resolve/resolve.go +++ b/internal/resolve/resolve.go @@ -317,7 +317,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 3c47b010d5a16721375aad7bb982ee72f8e45062 Mon Sep 17 00:00:00 2001 From: Pierre Gambarotto Date: Fri, 26 Jun 2026 11:59:02 +0200 Subject: [PATCH 06/11] 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 bfeafbb35291f4aa5b71ac9b64ecd80b0f9ec31d Mon Sep 17 00:00:00 2001 From: Pierre Gambarotto Date: Fri, 26 Jun 2026 12:09:59 +0200 Subject: [PATCH 07/11] 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) From 37c010838482ce048d502b7e93f8c60dff2e8032 Mon Sep 17 00:00:00 2001 From: Pierre Gambarotto Date: Sun, 28 Jun 2026 10:49:37 +0200 Subject: [PATCH 08/11] revert authTokenCmd local stuff accidentaly added ! --- internal/cli/auth.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/cli/auth.go b/internal/cli/auth.go index 4b26a1a..549ce10 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -23,7 +23,6 @@ func init() { rootCmd.AddCommand(authCmd) authCmd.AddCommand(authLoginCmd()) authCmd.AddCommand(authStatusCmd()) - authCmd.AddCommand(authTokenCmd()) } func authLoginCmd() *cobra.Command { From d70af89c7a5a2e7f60887ae3f47218c210660484 Mon Sep 17 00:00:00 2001 From: Pierre Gambarotto Date: Sun, 28 Jun 2026 11:17:24 +0200 Subject: [PATCH 09/11] fixed syntax --- internal/cli/auth.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/internal/cli/auth.go b/internal/cli/auth.go index 549ce10..84f62b9 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -85,8 +85,6 @@ func authLoginCmd() *cobra.Command { cmd.MarkFlagsMutuallyExclusive("token", "token-cmd") return cmd } - return cmd -} // readTokenInteractive prompts for a token in raw mode. // Pressing Ctrl+E as the first key switches to command mode (stored as "!cmd"). From 0a9d4554cba93eb14f196589d596b60ce731fe80 Mon Sep 17 00:00:00 2001 From: Pierre Gambarotto Date: Sun, 28 Jun 2026 11:35:43 +0200 Subject: [PATCH 10/11] fix: reset shared rootCmd flag state between tests `rootCmd`` is a package-level variable in interal/cli/root.go, shared across all tests in the cli package. Cobra does not reset flag values or their Changed state between `Execute()` calls, so a flag set by one test leaks into the next. This commit adds a `resetCmd` helper that recursively walks the command tree and restores each flag to its default value and `Changed=false`. Let's call it at the start of each test that invokes `rootCmd.Execute()`. --- internal/cli/auth_test.go | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/internal/cli/auth_test.go b/internal/cli/auth_test.go index 4d0ba78..0412a9a 100644 --- a/internal/cli/auth_test.go +++ b/internal/cli/auth_test.go @@ -8,8 +8,24 @@ import ( "testing" "github.com/git-pkgs/forge/internal/config" + "github.com/spf13/cobra" + "github.com/spf13/pflag" ) +// resetCmd resets flag values and Changed state across the command tree so +// tests using the shared rootCmd don't leak flag state into each other. +func resetCmd(cmd *cobra.Command) { + reset := func(f *pflag.Flag) { + f.Changed = false + _ = f.Value.Set(f.DefValue) + } + cmd.Flags().VisitAll(reset) + cmd.PersistentFlags().VisitAll(reset) + for _, sub := range cmd.Commands() { + resetCmd(sub) + } +} + func TestAuthCmd(t *testing.T) { cmd := authCmd if cmd.Use != "auth" { @@ -36,6 +52,7 @@ func TestAuthCmd(t *testing.T) { } func TestAuthLoginRequiresDomainNonInteractive(t *testing.T) { + resetCmd(rootCmd) // Replace stdin with a pipe so term.IsTerminal returns false origStdin := os.Stdin r, w, _ := os.Pipe() @@ -58,6 +75,7 @@ func TestAuthLoginRequiresDomainNonInteractive(t *testing.T) { } func TestAuthLoginNonInteractive(t *testing.T) { + resetCmd(rootCmd) dir := t.TempDir() t.Setenv("XDG_CONFIG_HOME", dir) config.ResetCache() @@ -96,6 +114,7 @@ func TestAuthLoginNonInteractive(t *testing.T) { } func TestAuthLoginTokenCmd(t *testing.T) { + resetCmd(rootCmd) dir := t.TempDir() t.Setenv("XDG_CONFIG_HOME", dir) config.ResetCache() @@ -125,6 +144,7 @@ func TestAuthLoginTokenCmd(t *testing.T) { } func TestAuthLoginTokenAndTokenCmdMutuallyExclusive(t *testing.T) { + resetCmd(rootCmd) var buf bytes.Buffer rootCmd.SetOut(&buf) rootCmd.SetErr(&buf) @@ -141,6 +161,7 @@ func TestAuthLoginTokenAndTokenCmdMutuallyExclusive(t *testing.T) { } func TestAuthStatus(t *testing.T) { + resetCmd(rootCmd) dir := t.TempDir() t.Setenv("XDG_CONFIG_HOME", dir) config.ResetCache() @@ -172,6 +193,7 @@ token = some_token } func TestAuthStatusWithTokenCmd(t *testing.T) { + resetCmd(rootCmd) dir := t.TempDir() t.Setenv("XDG_CONFIG_HOME", dir) config.ResetCache() From 3e1836f7918fe79ada1299a3a267af70d42d723c Mon Sep 17 00:00:00 2001 From: Pierre Gambarotto Date: Sun, 28 Jun 2026 16:29:01 +0200 Subject: [PATCH 11/11] separate token and token-cmd in config file --- README.md | 12 ++++++------ internal/cli/auth.go | 32 ++++++++++++++++++------------ internal/cli/auth_test.go | 8 ++++---- internal/config/config.go | 25 +++++++++++++++-------- internal/config/config_test.go | 36 +++++++++++++++++++++++++--------- 5 files changed, 73 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 84df023..09da32e 100644 --- a/README.md +++ b/README.md @@ -80,20 +80,20 @@ token = abc123 ### Token commands -Token values can be replaced with a shell command prefixed by `!` (Unix only). +Instead of a literal `token`, use `token-cmd` to specify a shell command (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] -token = !rbw get github-token +token-cmd = rbw get github-token [gitlab.com] -token = !pass show forge/gitlab +token-cmd = pass show forge/gitlab [myhostedgitlab.example.com] -token = !rbw get --raw myhostedgitlab | jq -r '.fields | map(select(.name == "token"))[0].value' +token-cmd = 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, @@ -101,10 +101,10 @@ so a single command can serve multiple domains: ```ini [github.com] -token = !pass show forge/$FORGE_DOMAIN +token-cmd = pass show forge/$FORGE_DOMAIN [myhostedgitlab.example.com] -token = !pass show forge/$FORGE_DOMAIN +token-cmd = pass show forge/$FORGE_DOMAIN ``` `forge auth login` sets this up interactively (Ctrl+E at the token prompt). diff --git a/internal/cli/auth.go b/internal/cli/auth.go index 84f62b9..da7f2cb 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -54,26 +54,30 @@ func authLoginCmd() *cobra.Command { switch { case tokenCmd != "": - token = "!" + tokenCmd + // tokenCmd is already set from --token-cmd flag case token == "": if !interactive { return fmt.Errorf("--token or --token-cmd is required in non-interactive mode") } var err error - token, err = readTokenInteractive(domain) + token, tokenCmd, err = readTokenInteractive(domain) if err != nil { return fmt.Errorf("reading token: %w", err) } - if token == "" { + if token == "" && tokenCmd == "" { return fmt.Errorf("token cannot be empty") } } - if err := config.SetDomain(domain, token, forgeType); err != nil { + if err := config.SetDomain(domain, token, tokenCmd, forgeType); err != nil { return fmt.Errorf("saving config: %w", err) } - _, _ = fmt.Fprintf(os.Stderr, "Stored credentials for %s\n", domain) + if tokenCmd != "" { + _, _ = fmt.Fprintf(os.Stderr, "Stored token command for %s\n", domain) + } else { + _, _ = fmt.Fprintf(os.Stderr, "Stored credentials for %s\n", domain) + } return nil }, } @@ -87,8 +91,9 @@ func authLoginCmd() *cobra.Command { } // 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) { +// Pressing Ctrl+E as the first key switches to command mode. +// Exactly one of token or tokenCmd is non-empty on success. +func readTokenInteractive(domain string) (token, tokenCmd string, err error) { const ctrlE = 0x05 fd := int(os.Stdin.Fd()) @@ -96,24 +101,26 @@ func readTokenInteractive(domain string) (string, error) { oldState, err := term.MakeRaw(fd) if err != nil { - return "", fmt.Errorf("setting raw mode: %w", err) + 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 + return "", "", err } if ch == ctrlE { _ = term.Restore(fd, oldState) _, _ = fmt.Fprintln(os.Stderr) - return readCommandInteractive(domain) + cmd, err := readCommandInteractive(domain) + return "", cmd, err } r := io.MultiReader(bytes.NewReader([]byte{ch}), os.Stdin) - return readRawToken(fd, oldState, r) + tok, err := readRawToken(fd, oldState, r) + return tok, "", err } func readOneByte(r io.Reader) (byte, error) { @@ -176,7 +183,6 @@ func readRawToken(fd int, oldState *term.State, r io.Reader) (string, error) { // 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, err := bufio.NewReader(os.Stdin).ReadString('\n') @@ -187,7 +193,7 @@ func readCommandInteractive(domain string) (string, error) { if cmd == "" { return "", fmt.Errorf("command cannot be empty") } - return "!" + cmd, nil + return cmd, nil } func authStatusCmd() *cobra.Command { diff --git a/internal/cli/auth_test.go b/internal/cli/auth_test.go index 0412a9a..3790129 100644 --- a/internal/cli/auth_test.go +++ b/internal/cli/auth_test.go @@ -138,7 +138,7 @@ func TestAuthLoginTokenCmd(t *testing.T) { t.Fatalf("reading config: %v", err) } content := string(data) - if !strings.Contains(content, "token = !rbw get github-token") { + if !strings.Contains(content, "token-cmd = rbw get github-token") { t.Errorf("expected token command in config, got:\n%s", content) } } @@ -203,7 +203,7 @@ func TestAuthStatusWithTokenCmd(t *testing.T) { _ = os.MkdirAll(cfgDir, 0700) _ = os.WriteFile(filepath.Join(cfgDir, "config"), []byte(`[gitlab.example.com] type = gitlab -token = !echo secret +token-cmd = echo secret `), 0600) var buf bytes.Buffer @@ -247,8 +247,8 @@ func TestReadCommandInteractive(t *testing.T) { 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) + if result != "rbw get github-token" { + t.Errorf("expected %q, got %q", "rbw get github-token", result) } } diff --git a/internal/config/config.go b/internal/config/config.go index e5b98fa..45c94da 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -32,7 +32,7 @@ type DefaultSection struct { type DomainSection struct { Type string // github, gitlab, gitea, forgejo, bitbucket 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) + TokenExec string // non-empty when token is retrieved via a shell command (from "token-cmd" config key) SSHHost string // alternate host for git-over-ssh; the section name remains the API host GitProtocol string // https or ssh; overrides default } @@ -208,12 +208,16 @@ func loadFile(cfg *Config, path string, allowTokens bool) error { } } if allowTokens { - if v, ok := kv["token"]; ok { - if strings.HasPrefix(v, "!") { - ds.TokenExec = v[1:] - } else { - ds.Token = v - } + _, hasToken := kv["token"] + _, hasTokenCmd := kv["token-cmd"] + if hasToken && hasTokenCmd { + return fmt.Errorf("%s: [%s] token and token-cmd are mutually exclusive", path, name) + } + if hasToken { + ds.Token = kv["token"] + } + if hasTokenCmd { + ds.TokenExec = kv["token-cmd"] } } cfg.Domains[name] = ds @@ -303,7 +307,7 @@ func findProjectConfig(dir string) string { // SetDomain updates or adds a domain section in the user config file. // Creates the config directory if needed. Sets file permissions to 0600 // since the file may contain tokens. -func SetDomain(domain, token, forgeType string) error { +func SetDomain(domain, token, tokenCmd, forgeType string) error { path := UserConfigPath() if path == "" { return fmt.Errorf("cannot determine config path") @@ -328,6 +332,11 @@ func SetDomain(domain, token, forgeType string) error { } if token != "" { sections[domain]["token"] = token + delete(sections[domain], "token-cmd") + } + if tokenCmd != "" { + sections[domain]["token-cmd"] = tokenCmd + delete(sections[domain], "token") } if forgeType != "" { sections[domain]["type"] = forgeType diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 1ace421..d3f72ef 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -350,7 +350,7 @@ func TestSetDomain(t *testing.T) { dir := t.TempDir() t.Setenv("XDG_CONFIG_HOME", dir) - err := SetDomain("gitea.example.com", "tok123", "gitea") + err := SetDomain("gitea.example.com", "tok123", "", "gitea") if err != nil { t.Fatalf("SetDomain: %v", err) } @@ -406,7 +406,7 @@ func TestSetDomainTightensExistingPermissions(t *testing.T) { t.Fatal(err) } - if err := SetDomain("github.com", "ghp_secret", ""); err != nil { + if err := SetDomain("github.com", "ghp_secret", "", ""); err != nil { t.Fatalf("SetDomain: %v", err) } @@ -434,7 +434,7 @@ type = gitlab `), 0600) // Add a new domain; existing entries should survive. - err := SetDomain("codeberg.org", "tok_new", "gitea") + err := SetDomain("codeberg.org", "tok_new", "", "gitea") if err != nil { t.Fatalf("SetDomain: %v", err) } @@ -468,7 +468,7 @@ token = old_token `), 0600) // Update - err := SetDomain("github.com", "new_token", "") + err := SetDomain("github.com", "new_token", "", "") if err != nil { t.Fatalf("SetDomain: %v", err) } @@ -487,7 +487,7 @@ func TestLoadFileTokenCommand(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config") _ = os.WriteFile(path, []byte(`[github.com] -token = !echo mytoken +token-cmd = echo mytoken `), 0600) cfg := &Config{Domains: make(map[string]DomainSection)} @@ -512,11 +512,29 @@ token = !echo mytoken } } +func TestLoadFileTokenAndTokenCmdMutuallyExclusive(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config") + _ = os.WriteFile(path, []byte(`[github.com] +token = ghp_abc +token-cmd = rbw get github-token +`), 0600) + + cfg := &Config{Domains: make(map[string]DomainSection)} + err := loadFile(cfg, path, true) + if err == nil { + t.Fatal("expected error when both token and token-cmd are set, got nil") + } + if !strings.Contains(err.Error(), "token") || !strings.Contains(err.Error(), "token-cmd") { + t.Errorf("expected error to mention both keys, got: %v", err) + } +} + func TestLoadFileTokenCommandForgeDomain(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config") _ = os.WriteFile(path, []byte(`[gitlab.example.com] -token = !echo $FORGE_DOMAIN +token-cmd = echo $FORGE_DOMAIN `), 0600) cfg := &Config{Domains: make(map[string]DomainSection)} @@ -537,7 +555,7 @@ func TestLoadFileTokenCommandFails(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config") _ = os.WriteFile(path, []byte(`[github.com] -token = !false +token-cmd = false `), 0600) cfg := &Config{Domains: make(map[string]DomainSection)} @@ -555,7 +573,7 @@ func TestLoadFileTokenCommandMissingBinary(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config") _ = os.WriteFile(path, []byte(`[github.com] -token = !no-such-binary-xyz +token-cmd = no-such-binary-xyz `), 0600) cfg := &Config{Domains: make(map[string]DomainSection)} @@ -573,7 +591,7 @@ func TestLoadFileTokenCommandNotExecutedInProjectConfig(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, ".forge") _ = os.WriteFile(path, []byte(`[github.com] -token = !echo secret +token-cmd = echo secret `), 0644) cfg := &Config{Domains: make(map[string]DomainSection)}