diff --git a/codejob_auth.go b/codejob_auth.go index ae9ec21..5b2c4b6 100644 --- a/codejob_auth.go +++ b/codejob_auth.go @@ -8,6 +8,7 @@ import ( "golang.org/x/term" ) +// Deprecated: use SecretJulesAPIKey with SecretStore const julesAPIKeyKey = "jules_api_key" const julesAPIKeyURL = "https://jules.google.com/settings/api" @@ -19,19 +20,15 @@ func termLink(text, url string) string { // JulesAuth manages the Jules API key via the system keyring. // On first use it prompts the user to enter the key and stores it securely. type JulesAuth struct { - kr *Keyring - log func(...any) + store *SecretStore + log func(...any) } // NewJulesAuth creates a JulesAuth with an initialized keyring. func NewJulesAuth() (*JulesAuth, error) { - kr, err := NewKeyring() - if err != nil { - return nil, err - } return &JulesAuth{ - kr: kr, - log: func(...any) {}, + store: NewSecretStore(), + log: func(...any) {}, }, nil } @@ -39,23 +36,28 @@ func NewJulesAuth() (*JulesAuth, error) { func (a *JulesAuth) SetLog(fn func(...any)) { if fn != nil { a.log = fn + a.store.SetLog(fn) } } // HasKey returns true if the Jules API key is already stored in the keyring. func (a *JulesAuth) HasKey() bool { - key, err := a.kr.Get(julesAPIKeyKey) - return err == nil && key != "" + _, _, err := a.store.Get(SecretJulesAPIKey) + return err == nil } // EnsureAPIKey returns the Jules API key from the keyring. // If absent, prompts the user for it once and persists it. func (a *JulesAuth) EnsureAPIKey() (string, error) { - key, err := a.kr.Get(julesAPIKeyKey) + key, _, err := a.store.Get(SecretJulesAPIKey) if err == nil && key != "" { return key, nil } + if !IsInteractive() { + return "", fmt.Errorf("Jules API key not found; set JULES_API_KEY") + } + fmt.Fprintf(os.Stderr, "Jules API Key not found. Get yours at %s\nEnter it now: ", termLink(julesAPIKeyURL, julesAPIKeyURL)) raw, err := term.ReadPassword(int(os.Stdin.Fd())) @@ -69,7 +71,7 @@ func (a *JulesAuth) EnsureAPIKey() (string, error) { return "", fmt.Errorf("API key cannot be empty") } - if err := a.kr.Set(julesAPIKeyKey, key); err != nil { + if err := a.store.Set(SecretJulesAPIKey, key); err != nil { a.log(fmt.Sprintf("warning: could not save API key to keyring: %v", err)) } diff --git a/docs/CODEJOB.md b/docs/CODEJOB.md index c483b8b..0d3fc2b 100644 --- a/docs/CODEJOB.md +++ b/docs/CODEJOB.md @@ -34,6 +34,7 @@ Dispatching a task automatically runs the setup wizard if the Jules API key is m Jules API key is managed via the system keyring (`github.com/zalando/go-keyring`): - **After first run**: reads silently from keyring — no env vars required in local use +- **CI/CD / Headless**: use `JULES_API_KEY` and `GH_TOKEN` environment variables. GitHub repo and branch are auto-detected when not provided: - `SourceID` — via `gh repo view --json owner,name` diff --git a/docs/GORELEASE.md b/docs/GORELEASE.md index a5df65c..ad93577 100644 --- a/docs/GORELEASE.md +++ b/docs/GORELEASE.md @@ -75,6 +75,20 @@ gorelease gorelease v1.2.3 ``` +## CI/CD Usage + +`gorelease` can be run in CI/CD environments (headless) by providing a GitHub token via environment variables. + +```yaml +# GitHub Actions example +- name: Run gorelease + run: go run github.com/tinywasm/devflow/cmd/gorelease@latest + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Or a PAT with 'repo' scope +``` + +`gorelease` prioritizes `GH_TOKEN`, then `GITHUB_TOKEN`, and falls back to the system keyring only if neither is present. + ## Integration with `codejob` The `-release` flag in `codejob` automatically triggers a release after `gopush`: diff --git a/docs/github/diagrams/GITHUB_AUTH_FLOW.md b/docs/github/diagrams/GITHUB_AUTH_FLOW.md index e196123..212ce07 100644 --- a/docs/github/diagrams/GITHUB_AUTH_FLOW.md +++ b/docs/github/diagrams/GITHUB_AUTH_FLOW.md @@ -5,7 +5,9 @@ This diagram illustrates how the `devflow` authentication system affects Git ope ```mermaid flowchart TD %% Initialization - A[User executes push / gopush] --> B["devflow/github_auth.go
(Requests Device Code)"] + A[User executes push / gopush] --> SS{"SecretStore.Get('github_token')"} + SS -->|Env GH_TOKEN/GITHUB_TOKEN| H + SS -->|Not in env| B["devflow/github_auth.go
(Requests Device Code)"] B --> C["Client ID: Ov23lij...
(GitHub API)"] C --> D["Returns User Code
(User opens browser)"] diff --git a/github_auth.go b/github_auth.go index 9186a13..277167f 100644 --- a/github_auth.go +++ b/github_auth.go @@ -25,17 +25,20 @@ import ( const DevflowOAuthClientID = "Ov23lijHU2vxBCpShn1Q" // GitHub token key for keyring storage +// Deprecated: use SecretGitHubToken with SecretStore const githubTokenKey = "github_token" // GitHubAuth handles GitHub authentication and token management type GitHubAuth struct { - log func(...any) + log func(...any) + store *SecretStore } // NewGitHubAuth creates a new GitHub authentication handler func NewGitHubAuth() *GitHubAuth { return &GitHubAuth{ - log: func(...any) {}, + log: func(...any) {}, + store: NewSecretStore(), } } @@ -48,6 +51,7 @@ func (a *GitHubAuth) Name() string { func (a *GitHubAuth) SetLog(fn func(...any)) { if fn != nil { a.log = fn + a.store.SetLog(fn) } } @@ -71,15 +75,7 @@ type tokenResponse struct { // EnsureGitHubAuth checks if GitHub is authenticated via keyring, and if not, initiates Device Flow func (a *GitHubAuth) EnsureGitHubAuth() error { - // Initialize keyring (auto-installs if needed) - kr, err := NewKeyring() - if err != nil { - return err - } - kr.SetLog(a.log) - - // Try to load saved token from keyring - token, err := kr.Get(githubTokenKey) + token, src, err := a.store.Get(SecretGitHubToken) if err == nil && token != "" { // Verify the token works by configuring gh if a.configureGhWithToken(token) == nil { @@ -87,12 +83,23 @@ func (a *GitHubAuth) EnsureGitHubAuth() error { return nil } } - // Token is invalid, remove it - kr.Delete(githubTokenKey) + + // Token is invalid + if src == SourceEnv { + return fmt.Errorf("invalid GitHub token in environment variable (GH_TOKEN or GITHUB_TOKEN)") + } + + // If it was from keyring, delete it and try to re-authenticate + a.store.Delete(SecretGitHubToken) + } + + // Not authenticated - check if interactive + if !IsInteractive() { + return fmt.Errorf("no GitHub token found; set GH_TOKEN or GITHUB_TOKEN, or run locally to authenticate") } - // Not authenticated - initiate Device Flow - token, err = a.DeviceFlowAuth(kr) + // Initiate Device Flow + token, err = a.DeviceFlowAuth() if err != nil { return err } @@ -102,7 +109,7 @@ func (a *GitHubAuth) EnsureGitHubAuth() error { } // DeviceFlowAuth initiates GitHub OAuth Device Flow and returns an access token -func (a *GitHubAuth) DeviceFlowAuth(kr *Keyring) (string, error) { +func (a *GitHubAuth) DeviceFlowAuth() (string, error) { // Step 1: Request device and user codes codeResp, err := a.requestDeviceCode() if err != nil { @@ -129,7 +136,7 @@ func (a *GitHubAuth) DeviceFlowAuth(kr *Keyring) (string, error) { } // Step 4: Save token to keyring - if err := kr.Set(githubTokenKey, token); err != nil { + if err := a.store.Set(SecretGitHubToken, token); err != nil { a.log(fmt.Sprintf("Warning: could not save token: %v", err)) } diff --git a/secret_store.go b/secret_store.go new file mode 100644 index 0000000..086725c --- /dev/null +++ b/secret_store.go @@ -0,0 +1,149 @@ +package devflow + +import ( + "errors" + "fmt" + "os" + "strings" + "sync" + + "golang.org/x/term" +) + +// SecretSource indicates where a secret was resolved from. +type SecretSource int + +const ( + SourceNone SecretSource = iota // not found + SourceEnv // environment variable + SourceKeyring // system keyring +) + +// secretSpec describes how to resolve a logical secret. +type secretSpec struct { + keyringKey string // key in the keyring (compatibility with existing stored values) + envKeys []string // environment variables to try, in priority order +} + +// Logical names of managed secrets. +const ( + SecretGitHubToken = "github_token" + SecretJulesAPIKey = "jules_api_key" +) + +// secretRegistry maps each logical secret to its spec. It is the SINGLE source of truth +// for env var names and keyring keys. Do not duplicate these literals in other files. +var secretRegistry = map[string]secretSpec{ + SecretGitHubToken: {keyringKey: "github_token", envKeys: []string{"GH_TOKEN", "GITHUB_TOKEN"}}, + SecretJulesAPIKey: {keyringKey: "jules_api_key", envKeys: []string{"JULES_API_KEY"}}, +} + +// ErrSecretNotFound is returned when a known secret is not found in any backend. +var ErrSecretNotFound = errors.New("secret not found in environment or keyring") + +// SecretStore resolves credentials with environment → keyring precedence. +// The keyring is initialized lazily: it is NEVER touched if the credential +// is available via environment variable (key for CI/CD). +type SecretStore struct { + log func(...any) + kr *Keyring + mu sync.Mutex + once sync.Once +} + +// NewSecretStore creates the handler. It does NOT initialize the keyring (no cost or side effects). +func NewSecretStore() *SecretStore { + return &SecretStore{ + log: func(...any) {}, + } +} + +// SetLog assigns the logger (propagates to Keyring when initialized). +func (s *SecretStore) SetLog(fn func(...any)) { + s.mu.Lock() + defer s.mu.Unlock() + s.log = fn + if s.kr != nil { + s.kr.SetLog(fn) + } +} + +func (s *SecretStore) keyring() (*Keyring, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.kr == nil { + kr, err := NewKeyring() + if err != nil { + return nil, err + } + s.kr = kr + s.kr.SetLog(s.log) + } + return s.kr, nil +} + +// Get resolves the value of secret `name`. Returns value, its source and error. +// - If name is not in secretRegistry → error (programming). +// - If found in env → (value, SourceEnv, nil) WITHOUT touching keyring. +// - If not in env but in keyring → (value, SourceKeyring, nil). +// - If not in any backend → ("", SourceNone, ErrSecretNotFound). +func (s *SecretStore) Get(name string) (string, SecretSource, error) { + spec, ok := secretRegistry[name] + if !ok { + return "", SourceNone, fmt.Errorf("unknown secret %q", name) + } + + for _, e := range spec.envKeys { + if v := strings.TrimSpace(os.Getenv(e)); v != "" { + return v, SourceEnv, nil + } + } + + kr, err := s.keyring() + if err != nil { + return "", SourceNone, err + } + + v, err := kr.Get(spec.keyringKey) + if err == nil && v != "" { + return v, SourceKeyring, nil + } + + return "", SourceNone, ErrSecretNotFound +} + +// Set persists the value in the keyring (only writable backend). +// Used by interactive acquisition after obtaining a new secret. +func (s *SecretStore) Set(name, value string) error { + spec, ok := secretRegistry[name] + if !ok { + return fmt.Errorf("unknown secret %q", name) + } + + kr, err := s.keyring() + if err != nil { + return err + } + + return kr.Set(spec.keyringKey, value) +} + +// Delete removes the value from the keyring (e.g. invalid token). +func (s *SecretStore) Delete(name string) error { + spec, ok := secretRegistry[name] + if !ok { + return fmt.Errorf("unknown secret %q", name) + } + + kr, err := s.keyring() + if err != nil { + return err + } + + return kr.Delete(spec.keyringKey) +} + +// IsInteractive indicates if there is a TTY where credentials can be requested from the user. +func IsInteractive() bool { + return term.IsTerminal(int(os.Stdin.Fd())) +} diff --git a/test/secret_store_test.go b/test/secret_store_test.go new file mode 100644 index 0000000..7c550fc --- /dev/null +++ b/test/secret_store_test.go @@ -0,0 +1,75 @@ +package devflow_test + +import ( + "os" + "testing" + + "github.com/tinywasm/devflow" +) + +func TestSecretStore_Precedence(t *testing.T) { + // Clean env + os.Unsetenv("GH_TOKEN") + os.Unsetenv("GITHUB_TOKEN") + defer os.Unsetenv("GH_TOKEN") + defer os.Unsetenv("GITHUB_TOKEN") + + store := devflow.NewSecretStore() + + // 1. None + _, _, err := store.Get(devflow.SecretGitHubToken) + if err == nil { + t.Error("expected error when no secret is present") + } + + // 2. GITHUB_TOKEN + os.Setenv("GITHUB_TOKEN", "token2") + val, src, err := store.Get(devflow.SecretGitHubToken) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if val != "token2" { + t.Errorf("expected token2, got %s", val) + } + if src != devflow.SourceEnv { + t.Error("expected SourceEnv") + } + + // 3. GH_TOKEN (precedence over GITHUB_TOKEN) + os.Setenv("GH_TOKEN", "token1") + val, src, err = store.Get(devflow.SecretGitHubToken) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if val != "token1" { + t.Errorf("expected token1, got %s", val) + } +} + +func TestSecretStore_Trim(t *testing.T) { + os.Setenv("JULES_API_KEY", " key-with-spaces \n") + defer os.Unsetenv("JULES_API_KEY") + + store := devflow.NewSecretStore() + val, _, err := store.Get(devflow.SecretJulesAPIKey) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if val != "key-with-spaces" { + t.Errorf("expected trimmed key, got %q", val) + } + + os.Setenv("JULES_API_KEY", " ") + _, _, err = store.Get(devflow.SecretJulesAPIKey) + if err == nil { + t.Error("expected error for empty-space env var") + } +} + +func TestSecretStore_UnknownSecret(t *testing.T) { + store := devflow.NewSecretStore() + _, _, err := store.Get("non-existent") + if err == nil { + t.Error("expected error for unknown secret") + } +}