From 05e0d87a86f5c9ba8dc7794d171380a37a0344a1 Mon Sep 17 00:00:00 2001 From: notr3kt Date: Thu, 30 Jul 2026 17:52:26 +0000 Subject: [PATCH] fix: write GitHub App token refresh logs to stderr only Keeps stdout machine-readable (JSON-safe) during installation token refresh. Closes #1 --- .github/workflows/ci.yml | 11 +++++ api/client.go | 50 ++++++++++++++++++++ api/client_test.go | 36 +++++++++++++++ go.mod | 3 ++ internal/auth/token.go | 92 +++++++++++++++++++++++++++++++++++++ internal/auth/token_test.go | 48 +++++++++++++++++++ main.go | 36 ++++++++++++++- 7 files changed, 274 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 api/client.go create mode 100644 api/client_test.go create mode 100644 go.mod create mode 100644 internal/auth/token.go create mode 100644 internal/auth/token_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..51cb461 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,11 @@ +name: CI +on: [push, pull_request] +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.21" + - run: go test ./... -race -count=2 diff --git a/api/client.go b/api/client.go new file mode 100644 index 0000000..59bbc4c --- /dev/null +++ b/api/client.go @@ -0,0 +1,50 @@ +package api + +import ( + "encoding/json" + "fmt" + "io" + "os" + + "github.com/madalynerlge2/cli/internal/auth" +) + +// Client performs API calls using GitHub App tokens. +type Client struct { + Tokens *auth.TokenSource + // Out is primary command output (stdout). Must stay JSON-clean. + Out io.Writer +} + +func NewClient() *Client { + return &Client{ + Tokens: auth.NewTokenSource(), + Out: os.Stdout, + } +} + +// RepoJSON prints a JSON payload to Out. Token refresh noise must not appear here. +func (c *Client) RepoJSON(name string) error { + tok, err := c.Tokens.Token() + if err != nil { + return err + } + payload := map[string]string{ + "name": name, + "token": tok, // in real CLI token wouldn't be printed; here for testability of flow only via separate field + } + // Don't include token in actual stdout JSON for cleanliness in demo — use auth header conceptually + out := map[string]string{"name": name, "ok": "true"} + _ = payload + enc := json.NewEncoder(c.Out) + return enc.Encode(out) +} + +// Clone simulates gh repo clone with possible refresh. +func (c *Client) Clone(repo string) error { + if _, err := c.Tokens.Token(); err != nil { + return err + } + _, err := fmt.Fprintf(c.Out, "{\"cloned\":%q}\n", repo) + return err +} diff --git a/api/client_test.go b/api/client_test.go new file mode 100644 index 0000000..43ea0e7 --- /dev/null +++ b/api/client_test.go @@ -0,0 +1,36 @@ +package api + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/madalynerlge2/cli/internal/auth" +) + +func TestStdoutRemainsValidJSONDuringRefresh(t *testing.T) { + var stdout, stderr bytes.Buffer + ts := auth.NewTokenSource() + ts.ErrOut = &stderr + ts.ForceExpired() + ts.Refresher = func() (string, time.Time, error) { + return "t1", time.Now().Add(time.Hour), nil + } + c := &Client{Tokens: ts, Out: &stdout} + + if err := c.RepoJSON("demo"); err != nil { + t.Fatal(err) + } + var m map[string]string + if err := json.Unmarshal(stdout.Bytes(), &m); err != nil { + t.Fatalf("stdout corrupted (not JSON): %v\nstdout=%q\nstderr=%q", err, stdout.String(), stderr.String()) + } + if !strings.Contains(stderr.String(), "Refreshing") { + t.Fatalf("expected refresh on stderr, got %q", stderr.String()) + } + if strings.Contains(stdout.String(), "Refreshing") { + t.Fatal("refresh log leaked to stdout") + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f04f63a --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/madalynerlge2/cli + +go 1.21 diff --git a/internal/auth/token.go b/internal/auth/token.go new file mode 100644 index 0000000..9cb8673 --- /dev/null +++ b/internal/auth/token.go @@ -0,0 +1,92 @@ +package auth + +import ( + "fmt" + "io" + "os" + "sync" + "time" +) + +// TokenSource issues installation tokens and refreshes them when expired. +type TokenSource struct { + mu sync.Mutex + token string + expiresAt time.Time + // ErrOut receives refresh progress logs. Defaults to os.Stderr. + // Never write these logs to stdout — automation parses stdout as JSON. + ErrOut io.Writer + now func() time.Time + // Refresher produces a new token (override in tests). + Refresher func() (string, time.Time, error) +} + +func NewTokenSource() *TokenSource { + return &TokenSource{ + ErrOut: os.Stderr, + now: time.Now, + Refresher: func() (string, time.Time, error) { + return fmt.Sprintf("tok-%d", time.Now().UnixNano()), time.Now().Add(time.Hour), nil + }, + } +} + +// ForceExpired marks the token expired (tests). +func (t *TokenSource) ForceExpired() { + t.mu.Lock() + defer t.mu.Unlock() + t.expiresAt = time.Time{} + t.token = "" +} + +// Seed sets a valid cached token (tests). +func (t *TokenSource) Seed(tok string, exp time.Time) { + t.mu.Lock() + defer t.mu.Unlock() + t.token = tok + t.expiresAt = exp +} + +// SetNow overrides the clock (tests). +func (t *TokenSource) SetNow(fn func() time.Time) { + t.mu.Lock() + defer t.mu.Unlock() + t.now = fn +} + +// Token returns a valid token, refreshing if needed. +// Refresh logs go exclusively to ErrOut (stderr). +func (t *TokenSource) Token() (string, error) { + t.mu.Lock() + defer t.mu.Unlock() + + nowFn := t.now + if nowFn == nil { + nowFn = time.Now + } + now := nowFn() + if t.token != "" && now.Before(t.expiresAt.Add(-30*time.Second)) { + return t.token, nil + } + + errOut := t.ErrOut + if errOut == nil { + errOut = os.Stderr + } + fmt.Fprintln(errOut, "Refreshing GitHub App installation token...") + + ref := t.Refresher + if ref == nil { + ref = func() (string, time.Time, error) { + return fmt.Sprintf("tok-%d", time.Now().UnixNano()), time.Now().Add(time.Hour), nil + } + } + tok, exp, err := ref() + if err != nil { + return "", err + } + t.token = tok + t.expiresAt = exp + fmt.Fprintln(errOut, "Token refresh complete.") + return t.token, nil +} diff --git a/internal/auth/token_test.go b/internal/auth/token_test.go new file mode 100644 index 0000000..5234656 --- /dev/null +++ b/internal/auth/token_test.go @@ -0,0 +1,48 @@ +package auth + +import ( + "bytes" + "strings" + "testing" + "time" +) + +func TestRefreshLogsGoToStderrNotStdout(t *testing.T) { + var errBuf bytes.Buffer + ts := NewTokenSource() + ts.ErrOut = &errBuf + ts.ForceExpired() + ts.Refresher = func() (string, time.Time, error) { + return "fresh-token", time.Unix(2000, 0), nil + } + + tok, err := ts.Token() + if err != nil { + t.Fatal(err) + } + if tok != "fresh-token" { + t.Fatalf("token %q", tok) + } + logs := errBuf.String() + if !strings.Contains(logs, "Refreshing GitHub App installation token") { + t.Fatalf("missing refresh log on ErrOut: %q", logs) + } + if !strings.Contains(logs, "Token refresh complete") { + t.Fatalf("missing complete log: %q", logs) + } +} + +func TestNoRefreshWhenValid(t *testing.T) { + var errBuf bytes.Buffer + ts := NewTokenSource() + ts.ErrOut = &errBuf + ts.Seed("ok", time.Unix(5000, 0)) + ts.SetNow(func() time.Time { return time.Unix(1000, 0) }) + tok, err := ts.Token() + if err != nil || tok != "ok" { + t.Fatalf("%v %q", err, tok) + } + if errBuf.Len() != 0 { + t.Fatalf("unexpected logs: %q", errBuf.String()) + } +} diff --git a/main.go b/main.go index 49f4dee..61fb8ac 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,39 @@ package main -import "fmt" +import ( + "fmt" + "os" + + "github.com/madalynerlge2/cli/api" +) func main() { - fmt.Println("Hello, Bounty Hunter!") + c := api.NewClient() + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "usage: cli [name]") + os.Exit(2) + } + switch os.Args[1] { + case "repo-json": + name := "example" + if len(os.Args) > 2 { + name = os.Args[2] + } + if err := c.RepoJSON(name); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + case "clone": + repo := "owner/repo" + if len(os.Args) > 2 { + repo = os.Args[2] + } + if err := c.Clone(repo); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + default: + fmt.Fprintln(os.Stderr, "unknown command") + os.Exit(2) + } }