From 94d07d65491518007a796b55100f764a539d1f3c Mon Sep 17 00:00:00 2001 From: Pierre Gambarotto Date: Tue, 16 Jun 2026 08:49:02 +0200 Subject: [PATCH 1/4] 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/4] 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/4] 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/4] 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")