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
69 changes: 43 additions & 26 deletions app/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"github.com/cashapp/hermit"
"github.com/cashapp/hermit/cache"
"github.com/cashapp/hermit/github"
"github.com/cashapp/hermit/github/auth"
"github.com/cashapp/hermit/sources"
"github.com/cashapp/hermit/state"
"github.com/cashapp/hermit/ui"
Expand Down Expand Up @@ -170,14 +171,6 @@ func Main(config Config) {
cli = &unactivated{cliBase: common}
}

githubToken := os.Getenv("HERMIT_GITHUB_TOKEN")
if githubToken == "" {
githubToken = os.Getenv("GITHUB_TOKEN")
p.Tracef("GitHub token set from GITHUB_TOKEN")
} else {
p.Tracef("GitHub token set from HERMIT_GITHUB_TOKEN")
}

kongOptions := []kong.Option{
kong.Groups{
"env": "Environment:\nCommands for creating and managing environments.",
Expand Down Expand Up @@ -212,6 +205,23 @@ func Main(config Config) {
log.Fatalf("failed to initialise CLI: %s", err)
}

ctx, err := parser.Parse(os.Args[1:])
parser.FatalIfErrorf(err)
configureLogging(cli, ctx, p)

userConfig := NewUserConfigWithDefaults()
userConfigPath := cli.getUserConfigFile()

if IsUserConfigExists(userConfigPath) {
p.Tracef("Loading user config from: %s", userConfigPath)
userConfig, err = LoadUserConfig(userConfigPath)
if err != nil {
log.Printf("%s: %s", userConfigPath, err)
}
} else {
p.Tracef("No user config found at: %s", userConfigPath)
}

var envInfo *hermit.EnvInfo
if isActivated {
envInfo, err = hermit.LoadEnvInfo(envPath)
Expand All @@ -220,12 +230,36 @@ func Main(config Config) {
}
}

// Initialize GitHub auth provider if needed
var (
githubAuthProvider auth.Provider
githubToken string
)
if envInfo != nil && len(envInfo.Config.GitHubTokenAuth.Match) > 0 {
providerType := auth.ProviderTypeEnv
if userConfig.GHCliAuth {
providerType = auth.ProviderTypeGHCli
}
provider, err := auth.NewProvider(providerType, p)
if err != nil {
p.Fatalf("Failed to create GitHub auth provider: %v", err)
}
githubAuthProvider = provider
if token, tokenErr := provider.GetToken(); tokenErr != nil {
p.Tracef("GitHub auth provider %s did not return token: %v", providerType, tokenErr)
} else {
githubToken = token
}
}

getSource := config.PackageSourceSelector
if config.PackageSourceSelector == nil {
getSource = cache.GetSource
}
defaultHTTPClient := config.defaultHTTPClient(p)
ghClient := github.New(defaultHTTPClient, githubToken)

// Use the auth provider for GitHub client
ghClient := github.New(p, defaultHTTPClient, githubAuthProvider)

var matcher github.RepoMatcher
if envInfo != nil {
Expand All @@ -249,23 +283,6 @@ func Main(config Config) {
log.Fatalf("failed to open cache: %s", err)
}

ctx, err := parser.Parse(os.Args[1:])
parser.FatalIfErrorf(err)
configureLogging(cli, ctx, p)

userConfig := NewUserConfigWithDefaults()
userConfigPath := cli.getUserConfigFile()

if IsUserConfigExists(userConfigPath) {
p.Tracef("Loading user config from: %s", userConfigPath)
userConfig, err = LoadUserConfig(userConfigPath)
if err != nil {
log.Printf("%s: %s", userConfigPath, err)
}
} else {
p.Tracef("No user config found at: %s", userConfigPath)
}

config.State.LockTimeout = cli.getLockTimeout()
sta, err = state.Open(hermit.UserStateDir, config.State, cache)
if err != nil {
Expand Down
1 change: 1 addition & 0 deletions app/user_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ type UserConfig struct {
NoGit bool `hcl:"no-git,optional" help:"If true Hermit will never add/remove files from Git automatically."`
Idea bool `hcl:"idea,optional" help:"If true Hermit will try to add the IntelliJ IDEA plugin automatically."`
Defaults hermit.Config `hcl:"defaults,block,optional" help:"Default configuration values for new Hermit environments."`
GHCliAuth bool `hcl:"gh-cli-auth,optional" help:"If true, use GitHub CLI (gh) for token authentication instead of environment variables."`
}

func NewUserConfigWithDefaults() UserConfig {
Expand Down
3 changes: 2 additions & 1 deletion docs/docs/usage/user-config-schema.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ short-prompt = boolean # (optional)
no-git = boolean # (optional)
# If true Hermit will try to add the IntelliJ IDEA plugin automatically.
idea = boolean # (optional)

# If true, use GitHub CLI (gh) for token authentication instead of environment variables.
gh-cli-auth = boolean # (optional)
# Default configuration values for new Hermit environments.
defaults {
# Extra environment variables.
Expand Down
8 changes: 5 additions & 3 deletions github/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import (
"sync"

"github.com/cashapp/hermit/errors"
"github.com/cashapp/hermit/github/auth"
"github.com/cashapp/hermit/ui"
)

const (
Expand Down Expand Up @@ -49,14 +51,14 @@ type Client struct {
}

// New creates a new GitHub API client.
func New(client *http.Client, token string) *Client {
func New(ui *ui.UI, client *http.Client, provider auth.Provider) *Client {
if client == nil {
client = http.DefaultClient
}
if token == "" {
if provider == nil {
client = http.DefaultClient
} else {
client = &http.Client{Transport: TokenAuthenticatedTransport(client.Transport, token)}
client = &http.Client{Transport: AuthenticatedTransport(ui, client.Transport, provider)}
}
return &Client{client: client}
}
Expand Down
93 changes: 93 additions & 0 deletions github/auth/provider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package auth

import (
"os"
"os/exec"
"strings"
"sync"

"github.com/cashapp/hermit/errors"
"github.com/cashapp/hermit/ui"
)

const (
ProviderTypeEnv = "env"
ProviderTypeGHCli = "gh-cli"
)

// Provider is an interface for GitHub token providers
Copy link
Copy Markdown
Contributor

@nickajacks1 nickajacks1 Jul 2, 2025

Choose a reason for hiding this comment

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

Not necessarily in scope for this PR, but it might be good to make this more generalized beyond GitHub.
I'd like to add netrc support at some point, and that wouldn't be limited to GitHub tokens.

type Provider interface {
// GetToken returns a GitHub token or an error if one cannot be obtained
GetToken() (string, error)
}

// EnvProvider implements Provider using environment variables
type EnvProvider struct {
ui *ui.UI
}

// GetToken returns a token from environment variables
func (p *EnvProvider) GetToken() (string, error) {
p.ui.Debugf("Getting GitHub token from environment variables")
if token := os.Getenv("HERMIT_GITHUB_TOKEN"); token != "" {
p.ui.Tracef("Using HERMIT_GITHUB_TOKEN for GitHub authentication")
return token, nil
}
if token := os.Getenv("GITHUB_TOKEN"); token != "" {
p.ui.Tracef("Using GITHUB_TOKEN for GitHub authentication")
return token, nil
}
p.ui.Tracef("No GitHub token found in environment")
return "", errors.New("no GitHub token found in environment")
}

// GHCliProvider implements Provider using the gh CLI tool
type GHCliProvider struct {
// cache the token and only refresh when needed
token string
tokenLock sync.Mutex
ui *ui.UI
}

// GetToken returns a token from gh CLI
func (p *GHCliProvider) GetToken() (string, error) {
p.ui.Debugf("Getting GitHub token from gh")
p.tokenLock.Lock()
defer p.tokenLock.Unlock()

// Return cached token if available
if p.token != "" {
return p.token, nil
}

// Check if gh is installed
ghPath, err := exec.LookPath("gh")
if err != nil {
return "", errors.New("gh CLI not found in PATH")
}

p.ui.Tracef("gh found: %s", ghPath)

// Run gh auth token
cmd := exec.Command("gh", "auth", "token")
output, err := cmd.CombinedOutput()
if err != nil {
p.ui.Warnf("gh auth failed: %s", strings.TrimSpace(string(output)))
return "", errors.Wrap(err, "gh auth failed")
}

p.token = strings.TrimSpace(string(output))
return p.token, nil
}

// NewProvider creates a new token provider based on the specified type
func NewProvider(providerType string, ui *ui.UI) (Provider, error) {
switch providerType {
case ProviderTypeEnv, "":
return &EnvProvider{ui: ui}, nil
case ProviderTypeGHCli:
return &GHCliProvider{ui: ui}, nil
default:
return nil, errors.Errorf("unknown GitHub token provider: %s", providerType)
}
}
125 changes: 125 additions & 0 deletions github/auth/provider_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package auth

import (
"fmt"
"os"
"os/exec"
"testing"

"github.com/alecthomas/assert/v2"
"github.com/cashapp/hermit/ui"
)

func TestEnvProvider(t *testing.T) {
ui, _ := ui.NewForTesting()
provider := &EnvProvider{ui: ui}

t.Run("no tokens set", func(t *testing.T) {
os.Unsetenv("HERMIT_GITHUB_TOKEN")
os.Unsetenv("GITHUB_TOKEN")
token, err := provider.GetToken()
assert.Error(t, err)
assert.Equal(t, "", token)
})

t.Run("HERMIT_GITHUB_TOKEN set", func(t *testing.T) {
t.Setenv("HERMIT_GITHUB_TOKEN", "hermit-token")
t.Setenv("GITHUB_TOKEN", "")
token, err := provider.GetToken()
assert.NoError(t, err)
assert.Equal(t, "hermit-token", token)
})

t.Run("GITHUB_TOKEN set", func(t *testing.T) {
t.Setenv("HERMIT_GITHUB_TOKEN", "")
t.Setenv("GITHUB_TOKEN", "github-token")
token, err := provider.GetToken()
assert.NoError(t, err)
assert.Equal(t, "github-token", token)
})

t.Run("both tokens set, HERMIT_GITHUB_TOKEN takes precedence", func(t *testing.T) {
t.Setenv("HERMIT_GITHUB_TOKEN", "hermit-token")
t.Setenv("GITHUB_TOKEN", "github-token")
token, err := provider.GetToken()
assert.NoError(t, err)
assert.Equal(t, "hermit-token", token)
})
}

func TestGHCliProvider(t *testing.T) {
ui, _ := ui.NewForTesting()
provider := &GHCliProvider{ui: ui}

t.Run("gh not installed", func(t *testing.T) {
t.Setenv("PATH", "")

token, err := provider.GetToken()
assert.Error(t, err)
assert.Equal(t, "", token)
assert.Contains(t, err.Error(), "gh CLI not found")
})

t.Run("token caching", func(t *testing.T) {
// Skip if gh not installed
if _, err := exec.LookPath("gh"); err != nil {
t.Skip("gh CLI not installed")
}

// First call should get a real token
token1, err := provider.GetToken()
if err != nil {
t.Skip("gh auth token failed, probably not authenticated")
}
assert.NotEqual(t, "", token1)

// Second call should return cached token
token2, err := provider.GetToken()
assert.NoError(t, err)
assert.Equal(t, token1, token2)
})
}

func TestNewProvider(t *testing.T) {
tests := []struct {
name string
provider string
wantType Provider
wantErrText string
}{
{
name: "env provider",
provider: "env",
wantType: &EnvProvider{},
},
{
name: "empty string defaults to env",
provider: "",
wantType: &EnvProvider{},
},
{
name: "gh-cli provider",
provider: "gh-cli",
wantType: &GHCliProvider{},
},
{
name: "unknown provider",
provider: "unknown",
wantErrText: "unknown GitHub token provider: unknown",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ui, _ := ui.NewForTesting()
got, err := NewProvider(tt.provider, ui)
if tt.wantErrText != "" {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErrText)
return
}
assert.NoError(t, err)
assert.Equal(t, fmt.Sprintf("%T", tt.wantType), fmt.Sprintf("%T", got))
})
}
}
Loading