Skip to content

Commit 3cb0c64

Browse files
author
Lachlan Donald
committed
feat: add GitHub CLI authentication support
Add support for using GitHub CLI (`gh`) as an authentication provider for Hermit via a user config (`gh-cli-auth`), enabling seamless integration with `gh auth token`. This simplifies authentication for private packages and repositories by eliminating the need to manually set HERMIT_GITHUB_TOKEN.
1 parent 55c98fd commit 3cb0c64

File tree

18 files changed

+767
-32
lines changed

18 files changed

+767
-32
lines changed

app/main.go

Lines changed: 42 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"github.com/cashapp/hermit"
1919
"github.com/cashapp/hermit/cache"
2020
"github.com/cashapp/hermit/github"
21+
"github.com/cashapp/hermit/github/auth"
2122
"github.com/cashapp/hermit/state"
2223
"github.com/cashapp/hermit/ui"
2324
"github.com/cashapp/hermit/util/debug"
@@ -167,14 +168,6 @@ func Main(config Config) {
167168
cli = &unactivated{cliBase: common}
168169
}
169170

170-
githubToken := os.Getenv("HERMIT_GITHUB_TOKEN")
171-
if githubToken == "" {
172-
githubToken = os.Getenv("GITHUB_TOKEN")
173-
p.Tracef("GitHub token set from GITHUB_TOKEN")
174-
} else {
175-
p.Tracef("GitHub token set from HERMIT_GITHUB_TOKEN")
176-
}
177-
178171
kongOptions := []kong.Option{
179172
kong.Groups{
180173
"env": "Environment:\nCommands for creating and managing environments.",
@@ -209,6 +202,23 @@ func Main(config Config) {
209202
log.Fatalf("failed to initialise CLI: %s", err)
210203
}
211204

205+
ctx, err := parser.Parse(os.Args[1:])
206+
parser.FatalIfErrorf(err)
207+
configureLogging(cli, ctx.Command(), p)
208+
209+
var userConfig UserConfig
210+
userConfigPath := cli.getUserConfigFile()
211+
212+
if IsUserConfigExists(userConfigPath) {
213+
p.Tracef("Loading user config from: %s", userConfigPath)
214+
userConfig, err = LoadUserConfig(userConfigPath)
215+
if err != nil {
216+
log.Printf("%s: %s", userConfigPath, err)
217+
}
218+
} else {
219+
p.Tracef("No user config found at: %s", userConfigPath)
220+
}
221+
212222
var envInfo *hermit.EnvInfo
213223
if isActivated {
214224
envInfo, err = hermit.LoadEnvInfo(envPath)
@@ -217,13 +227,36 @@ func Main(config Config) {
217227
}
218228
}
219229

230+
// Initialize GitHub token
231+
var githubToken string
232+
if envInfo != nil && len(envInfo.Config.GitHubTokenAuth.Match) > 0 {
233+
// Determine provider based on user config
234+
providerType := "env"
235+
if userConfig.GHCliAuth {
236+
providerType = "gh-cli"
237+
}
238+
239+
provider, err := auth.NewProvider(providerType, p)
240+
if err != nil {
241+
p.Tracef("Failed to create GitHub token provider: %v", err)
242+
} else {
243+
token, err := provider.GetToken()
244+
if err != nil {
245+
p.Tracef("Failed to get GitHub token from provider %s: %v", providerType, err)
246+
} else {
247+
githubToken = token
248+
p.Tracef("GitHub token set from provider: %s", providerType)
249+
}
250+
}
251+
}
252+
220253
getSource := config.PackageSourceSelector
221254
if config.PackageSourceSelector == nil {
222255
getSource = cache.GetSource
223256
}
224257
defaultHTTPClient := config.defaultHTTPClient(p)
225258

226-
ghClient := github.New(defaultHTTPClient, githubToken)
259+
ghClient := github.New(p, defaultHTTPClient, githubToken)
227260
if envInfo != nil {
228261
// If the environment has been configured to use GitHub token
229262
// authentication for any patterns, wrap the
@@ -245,23 +278,6 @@ func Main(config Config) {
245278
log.Fatalf("failed to open cache: %s", err)
246279
}
247280

248-
ctx, err := parser.Parse(os.Args[1:])
249-
parser.FatalIfErrorf(err)
250-
configureLogging(cli, ctx.Command(), p)
251-
252-
var userConfig UserConfig
253-
userConfigPath := cli.getUserConfigFile()
254-
255-
if IsUserConfigExists(userConfigPath) {
256-
p.Tracef("Loading user config from: %s", userConfigPath)
257-
userConfig, err = LoadUserConfig(userConfigPath)
258-
if err != nil {
259-
log.Printf("%s: %s", userConfigPath, err)
260-
}
261-
} else {
262-
p.Tracef("No user config found at: %s", userConfigPath)
263-
}
264-
265281
config.State.LockTimeout = cli.getLockTimeout()
266282
sta, err = state.Open(hermit.UserStateDir, config.State, cache)
267283
if err != nil {

app/user_config.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ type UserConfig struct {
2929
NoGit bool `hcl:"no-git,optional" help:"If true Hermit will never add/remove files from Git automatically."`
3030
Idea bool `hcl:"idea,optional" help:"If true Hermit will try to add the IntelliJ IDEA plugin automatically."`
3131
Defaults hermit.Config `hcl:"defaults,block,optional" help:"Default configuration values for new Hermit environments."`
32+
GHCliAuth bool `hcl:"gh-cli-auth,optional" help:"If true, use GitHub CLI (gh) for token authentication instead of environment variables."`
3233
}
3334

3435
// IsUserConfigExists checks if the user config file exists at the given path.

docs/docs/usage/user-config-schema.hcl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ short-prompt = boolean # (optional)
88
no-git = boolean # (optional)
99
# If true Hermit will try to add the IntelliJ IDEA plugin automatically.
1010
idea = boolean # (optional)
11+
# If true, use GitHub CLI (gh) for token authentication instead of environment variables.
12+
gh-cli-auth = boolean # (optional)
1113
# Default configuration values for new Hermit environments.
1214
defaults = {
1315
# Package manifest sources.

github/api.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"sync"
1414

1515
"github.com/cashapp/hermit/errors"
16+
"github.com/cashapp/hermit/ui"
1617
)
1718

1819
// Repo information.
@@ -44,14 +45,14 @@ type Client struct {
4445
}
4546

4647
// New creates a new GitHub API client.
47-
func New(client *http.Client, token string) *Client {
48+
func New(ui *ui.UI, client *http.Client, token string) *Client {
4849
if client == nil {
4950
client = http.DefaultClient
5051
}
5152
if token == "" {
5253
client = http.DefaultClient
5354
} else {
54-
client = &http.Client{Transport: TokenAuthenticatedTransport(client.Transport, token)}
55+
client = &http.Client{Transport: TokenAuthenticatedTransport(ui, client.Transport, token)}
5556
}
5657
return &Client{client: client}
5758
}

github/auth/provider.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package auth
2+
3+
import (
4+
"os"
5+
"os/exec"
6+
"strings"
7+
"sync"
8+
9+
"github.com/cashapp/hermit/errors"
10+
"github.com/cashapp/hermit/ui"
11+
)
12+
13+
// Provider is an interface for GitHub token providers
14+
type Provider interface {
15+
// GetToken returns a GitHub token or an error if one cannot be obtained
16+
GetToken() (string, error)
17+
}
18+
19+
// EnvProvider implements Provider using environment variables
20+
type EnvProvider struct {
21+
ui *ui.UI
22+
}
23+
24+
// GetToken returns a token from environment variables
25+
func (p *EnvProvider) GetToken() (string, error) {
26+
p.ui.Debugf("Getting GitHub token from environment variables")
27+
if token := os.Getenv("HERMIT_GITHUB_TOKEN"); token != "" {
28+
p.ui.Tracef("Using HERMIT_GITHUB_TOKEN for GitHub authentication")
29+
return token, nil
30+
}
31+
if token := os.Getenv("GITHUB_TOKEN"); token != "" {
32+
p.ui.Tracef("Using GITHUB_TOKEN for GitHub authentication")
33+
return token, nil
34+
}
35+
p.ui.Tracef("No GitHub token found in environment")
36+
return "", errors.New("no GitHub token found in environment")
37+
}
38+
39+
// GHCliProvider implements Provider using the gh CLI tool
40+
type GHCliProvider struct {
41+
// cache the token and only refresh when needed
42+
token string
43+
tokenLock sync.Mutex
44+
ui *ui.UI
45+
}
46+
47+
// GetToken returns a token from gh CLI
48+
func (p *GHCliProvider) GetToken() (string, error) {
49+
p.ui.Debugf("Getting GitHub token from gh CLI")
50+
p.tokenLock.Lock()
51+
defer p.tokenLock.Unlock()
52+
53+
// Return cached token if available
54+
if p.token != "" {
55+
return p.token, nil
56+
}
57+
58+
// Check if gh is installed
59+
ghPath, err := exec.LookPath("gh")
60+
if err != nil {
61+
return "", errors.New("gh CLI not found in PATH")
62+
}
63+
64+
p.ui.Tracef("gh CLI found in PATH: %s", ghPath)
65+
66+
// Run gh auth token
67+
cmd := exec.Command("gh", "auth", "token")
68+
output, err := cmd.Output()
69+
if err != nil {
70+
return "", errors.Wrap(err, "failed to get token from gh CLI")
71+
}
72+
73+
p.ui.Tracef("gh auth token output: %s", string(output))
74+
p.token = strings.TrimSpace(string(output))
75+
return p.token, nil
76+
}
77+
78+
// NewProvider creates a new token provider based on the specified type
79+
func NewProvider(providerType string, ui *ui.UI) (Provider, error) {
80+
switch providerType {
81+
case "env", "":
82+
return &EnvProvider{ui: ui}, nil
83+
case "gh-cli":
84+
return &GHCliProvider{ui: ui}, nil
85+
default:
86+
return nil, errors.Errorf("unknown GitHub token provider: %s", providerType)
87+
}
88+
}

github/auth/provider_test.go

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
package auth
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"os/exec"
7+
"testing"
8+
9+
"github.com/alecthomas/assert/v2"
10+
"github.com/cashapp/hermit/ui"
11+
)
12+
13+
func TestEnvProvider(t *testing.T) {
14+
ui, _ := ui.NewForTesting()
15+
provider := &EnvProvider{ui: ui}
16+
17+
t.Run("no tokens set", func(t *testing.T) {
18+
os.Unsetenv("HERMIT_GITHUB_TOKEN")
19+
os.Unsetenv("GITHUB_TOKEN")
20+
token, err := provider.GetToken()
21+
assert.Error(t, err)
22+
assert.Equal(t, "", token)
23+
})
24+
25+
t.Run("HERMIT_GITHUB_TOKEN set", func(t *testing.T) {
26+
t.Setenv("HERMIT_GITHUB_TOKEN", "hermit-token")
27+
t.Setenv("GITHUB_TOKEN", "")
28+
token, err := provider.GetToken()
29+
assert.NoError(t, err)
30+
assert.Equal(t, "hermit-token", token)
31+
})
32+
33+
t.Run("GITHUB_TOKEN set", func(t *testing.T) {
34+
t.Setenv("HERMIT_GITHUB_TOKEN", "")
35+
t.Setenv("GITHUB_TOKEN", "github-token")
36+
token, err := provider.GetToken()
37+
assert.NoError(t, err)
38+
assert.Equal(t, "github-token", token)
39+
})
40+
41+
t.Run("both tokens set, HERMIT_GITHUB_TOKEN takes precedence", func(t *testing.T) {
42+
t.Setenv("HERMIT_GITHUB_TOKEN", "hermit-token")
43+
t.Setenv("GITHUB_TOKEN", "github-token")
44+
token, err := provider.GetToken()
45+
assert.NoError(t, err)
46+
assert.Equal(t, "hermit-token", token)
47+
})
48+
}
49+
50+
func TestGHCliProvider(t *testing.T) {
51+
ui, _ := ui.NewForTesting()
52+
provider := &GHCliProvider{ui: ui}
53+
54+
t.Run("gh not installed", func(t *testing.T) {
55+
t.Setenv("PATH", "")
56+
57+
token, err := provider.GetToken()
58+
assert.Error(t, err)
59+
assert.Equal(t, "", token)
60+
assert.Contains(t, err.Error(), "gh CLI not found")
61+
})
62+
63+
t.Run("token caching", func(t *testing.T) {
64+
// Skip if gh not installed
65+
if _, err := exec.LookPath("gh"); err != nil {
66+
t.Skip("gh CLI not installed")
67+
}
68+
69+
// First call should get a real token
70+
token1, err := provider.GetToken()
71+
if err != nil {
72+
t.Skip("gh auth token failed, probably not authenticated")
73+
}
74+
assert.NotEqual(t, "", token1)
75+
76+
// Second call should return cached token
77+
token2, err := provider.GetToken()
78+
assert.NoError(t, err)
79+
assert.Equal(t, token1, token2)
80+
})
81+
}
82+
83+
func TestNewProvider(t *testing.T) {
84+
tests := []struct {
85+
name string
86+
provider string
87+
wantType Provider
88+
wantErrText string
89+
}{
90+
{
91+
name: "env provider",
92+
provider: "env",
93+
wantType: &EnvProvider{},
94+
},
95+
{
96+
name: "empty string defaults to env",
97+
provider: "",
98+
wantType: &EnvProvider{},
99+
},
100+
{
101+
name: "gh-cli provider",
102+
provider: "gh-cli",
103+
wantType: &GHCliProvider{},
104+
},
105+
{
106+
name: "unknown provider",
107+
provider: "unknown",
108+
wantErrText: "unknown GitHub token provider: unknown",
109+
},
110+
}
111+
112+
for _, tt := range tests {
113+
t.Run(tt.name, func(t *testing.T) {
114+
ui, _ := ui.NewForTesting()
115+
got, err := NewProvider(tt.provider, ui)
116+
if tt.wantErrText != "" {
117+
assert.Error(t, err)
118+
assert.Contains(t, err.Error(), tt.wantErrText)
119+
return
120+
}
121+
assert.NoError(t, err)
122+
assert.Equal(t, fmt.Sprintf("%T", tt.wantType), fmt.Sprintf("%T", got))
123+
})
124+
}
125+
}

github/http.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
1+
//go:build !localgithub
2+
13
package github
24

35
import (
46
"net/http"
7+
8+
"github.com/cashapp/hermit/ui"
59
)
610

711
// TokenAuthenticatedTransport returns a HTTP transport that will inject a
812
// GitHub authentication token into any requests to github.com.
913
//
1014
// Conceptually similar to
1115
// https://github.com/google/go-github/blob/d23570d44313ca73dbcaadec71fc43eca4d29f8b/github/github.go#L841-L875
12-
func TokenAuthenticatedTransport(transport http.RoundTripper, token string) http.RoundTripper {
16+
func TokenAuthenticatedTransport(_ *ui.UI, transport http.RoundTripper, token string) http.RoundTripper {
1317
if transport == nil {
1418
transport = http.DefaultTransport
1519
}

0 commit comments

Comments
 (0)