Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand All @@ -66,6 +75,36 @@ 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'
```

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.

`.forge` in the repo root is for per-project settings, committed to the repo, no tokens:

```ini
Expand Down
108 changes: 101 additions & 7 deletions internal/cli/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package cli

import (
"bufio"
"bytes"
"fmt"
"io"
"os"
"strings"

Expand Down Expand Up @@ -53,13 +55,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")
}
Expand All @@ -80,6 +80,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",
Expand Down Expand Up @@ -111,7 +201,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"
Expand All @@ -121,9 +215,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)
}
}

Expand Down
90 changes: 88 additions & 2 deletions internal/cli/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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)
}
}
32 changes: 30 additions & 2 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
Expand All @@ -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
}
Expand Down Expand Up @@ -94,6 +96,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.
func execValue(cmd, domain string) (string, error) {
cmd = strings.TrimSpace(cmd)
if cmd == "" {
return "", fmt.Errorf("empty command")
}
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)
}
return strings.TrimSpace(string(out)), nil
}

// ResetCache clears the cached config. Only useful in tests.
func ResetCache() {
once = sync.Once{}
Expand Down Expand Up @@ -176,7 +195,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:], 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
}
}
}
cfg.Domains[name] = ds
Expand Down
Loading