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
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
50 changes: 50 additions & 0 deletions api/client.go
Original file line number Diff line number Diff line change
@@ -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
}
36 changes: 36 additions & 0 deletions api/client_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module github.com/madalynerlge2/cli

go 1.21
92 changes: 92 additions & 0 deletions internal/auth/token.go
Original file line number Diff line number Diff line change
@@ -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
}
48 changes: 48 additions & 0 deletions internal/auth/token_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
36 changes: 34 additions & 2 deletions main.go
Original file line number Diff line number Diff line change
@@ -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 <repo-json|clone> [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)
}
}