Skip to content
Merged
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: 8 additions & 3 deletions cmd/entire/cli/activity_cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,15 @@ func TestNormalizeAgentString(t *testing.T) {

func TestGroupCommitsByDay_SortsNewestFirst(t *testing.T) {
t.Parallel()

localDate := func(year int, month time.Month, day int) *string {
return strPtr(time.Date(year, month, day, 12, 0, 0, 0, time.Local).Format(time.RFC3339))
}

commits := []userCommit{
{CommitSHA: "aaa", CommitDate: strPtr("2026-01-10T12:00:00Z")},
{CommitSHA: "bbb", CommitDate: strPtr("2026-01-12T08:00:00Z")},
{CommitSHA: "ccc", CommitDate: strPtr("2026-01-11T15:00:00Z")},
{CommitSHA: "aaa", CommitDate: localDate(2026, time.January, 10)},
{CommitSHA: "bbb", CommitDate: localDate(2026, time.January, 12)},
{CommitSHA: "ccc", CommitDate: localDate(2026, time.January, 11)},
}
days := groupCommitsByDay(commits)

Expand Down
76 changes: 55 additions & 21 deletions cmd/entire/cli/auth/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,42 @@ import (

const keyringService = "entire-cli"

// Store manages CLI authentication tokens in the OS keyring.
// Store manages CLI authentication tokens via a pluggable backend. The
// production binary always resolves to the OS keyring. A file-backed
// backend is available only in builds tagged `authfilestore` (used by
// integration tests to avoid the OS keychain).
type Store struct {
service string
backend tokenBackend
}

// NewStore returns a Store backed by the system keyring.
// tokenBackend abstracts token persistence. Implementations must treat
// "missing key" as a non-error: get returns ("", nil) and delete is a
// no-op so callers don't have to plumb backend-specific sentinels.
type tokenBackend interface {
save(service, key, value string) error
get(service, key string) (string, error)
delete(service, key string) error
}

// chooseBackend returns the backend used by NewStore and
// NewStoreWithService. The default returns the keyring backend; the
// `authfilestore` build adds an init() that may swap in a file-backed
// backend when the test env var is set.
var chooseBackend = func() tokenBackend { return keyringBackend{} }

// NewStore returns a Store backed by the system keyring (or, in
// `authfilestore` builds, optionally a file-backed test store).
func NewStore() *Store {
return &Store{service: keyringService}
return &Store{service: keyringService, backend: chooseBackend()}
}

// NewStoreWithService returns a Store with a custom keyring service name (for testing).
// Honors the same backend selection as NewStore so tests that opt into the
// file-backed test store via env var see consistent behavior across both
// constructors.
func NewStoreWithService(service string) *Store {
return &Store{service: service}
return &Store{service: service, backend: chooseBackend()}
}

// SaveToken persists an access token for the given base URL.
Expand All @@ -32,43 +55,54 @@ func (s *Store) SaveToken(baseURL, token string) error {
if token == "" {
return errors.New("refusing to save empty token")
}
return s.backend.save(s.service, baseURL, token)
}

// GetToken retrieves a stored token for the given base URL.
// Returns an empty string (and no error) if no token is stored.
func (s *Store) GetToken(baseURL string) (string, error) {
return s.backend.get(s.service, baseURL)
}

// DeleteToken removes a stored token for the given base URL.
// Returns no error if the token does not exist.
func (s *Store) DeleteToken(baseURL string) error {
return s.backend.delete(s.service, baseURL)
}

// LookupCurrentToken retrieves the token for the current base URL.
func LookupCurrentToken() (string, error) {
store := NewStore()
return store.GetToken(api.BaseURL())
}

type keyringBackend struct{}

if err := keyring.Set(s.service, baseURL, token); err != nil {
func (keyringBackend) save(service, key, value string) error {
if err := keyring.Set(service, key, value); err != nil {
return fmt.Errorf("save token to keyring: %w", err)
}

return nil
}

// GetToken retrieves a stored token for the given base URL.
// Returns an empty string (and no error) if no token is stored.
func (s *Store) GetToken(baseURL string) (string, error) {
token, err := keyring.Get(s.service, baseURL)
func (keyringBackend) get(service, key string) (string, error) {
token, err := keyring.Get(service, key)
if errors.Is(err, keyring.ErrNotFound) {
return "", nil
}
if err != nil {
return "", fmt.Errorf("get token from keyring: %w", err)
}

return token, nil
}

// DeleteToken removes a stored token for the given base URL.
func (s *Store) DeleteToken(baseURL string) error {
err := keyring.Delete(s.service, baseURL)
func (keyringBackend) delete(service, key string) error {
err := keyring.Delete(service, key)
if errors.Is(err, keyring.ErrNotFound) {
return nil
}
if err != nil {
return fmt.Errorf("delete token from keyring: %w", err)
}

return nil
}

// LookupCurrentToken retrieves the token for the current base URL.
func LookupCurrentToken() (string, error) {
store := NewStore()
return store.GetToken(api.BaseURL())
}
116 changes: 116 additions & 0 deletions cmd/entire/cli/auth/store_filebackend.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
//go:build authfilestore

// File-backed auth store backend. Compiled only under the `authfilestore`
// build tag, which is enabled by:
// - the integration test subprocess build (cmd/entire/cli/integration_test/setup_test.go)
// - test:integration / test:ci tasks (mise.toml) for running this package's
// tagged tests
//
// Production builds (no tag) do not include this file, so the env var below
// has no effect outside test environments.

package auth

import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
)

// testAuthStoreFileEnv, when set, redirects token storage to a JSON file at
// the given path instead of the OS keyring. Only honored in `authfilestore`
// builds.
const testAuthStoreFileEnv = "ENTIRE_TEST_AUTH_STORE_FILE"

func init() {
chooseBackend = func() tokenBackend {
if path := os.Getenv(testAuthStoreFileEnv); path != "" {
return &fileBackend{path: path}
}
return keyringBackend{}
}
}

type fileBackend struct {
path string
}

func (b *fileBackend) save(service, key, value string) error {
tokens, err := b.read()
if err != nil {
return err
}
svc := tokens[service]
if svc == nil {
svc = make(map[string]string)
tokens[service] = svc
}
svc[key] = value
return b.write(tokens)
}

func (b *fileBackend) get(service, key string) (string, error) {
tokens, err := b.read()
if err != nil {
return "", err
}
return tokens[service][key], nil
}

func (b *fileBackend) delete(service, key string) error {
tokens, err := b.read()
if err != nil {
return err
}
if svc := tokens[service]; svc != nil {
delete(svc, key)
if len(svc) == 0 {
delete(tokens, service)
}
}
return b.write(tokens)
}

func (b *fileBackend) read() (map[string]map[string]string, error) {
data, err := os.ReadFile(b.path)
if errors.Is(err, os.ErrNotExist) {
return make(map[string]map[string]string), nil
}
if err != nil {
return nil, fmt.Errorf("read test auth store: %w", err)
}
if len(data) == 0 {
return make(map[string]map[string]string), nil
}

var tokens map[string]map[string]string
if err := json.Unmarshal(data, &tokens); err != nil {
return nil, fmt.Errorf("parse test auth store: %w", err)
}
if tokens == nil {
tokens = make(map[string]map[string]string)
}
return tokens, nil
}

func (b *fileBackend) write(tokens map[string]map[string]string) error {
if err := os.MkdirAll(filepath.Dir(b.path), 0o700); err != nil {
return fmt.Errorf("create test auth store directory: %w", err)
}
data, err := json.Marshal(tokens)
if err != nil {
return fmt.Errorf("marshal test auth store: %w", err)
}
if err := os.WriteFile(b.path, data, 0o600); err != nil {
return fmt.Errorf("write test auth store: %w", err)
}
// os.WriteFile preserves an existing file's permission bits, so an
// already-broad file (e.g. 0o644 from an earlier test setup) would
// keep those bits. Force 0o600 to make the post-condition unconditional.
if err := os.Chmod(b.path, 0o600); err != nil {
return fmt.Errorf("restrict test auth store permissions: %w", err)
}
return nil
}
69 changes: 69 additions & 0 deletions cmd/entire/cli/auth/store_filebackend_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
//go:build authfilestore

package auth

import (
"os"
"path/filepath"
"testing"
)

func TestNewStore_UsesTestStoreFile(t *testing.T) {
storeFile := filepath.Join(t.TempDir(), "auth-store.json")
t.Setenv(testAuthStoreFileEnv, storeFile)

store := NewStore()
if err := store.SaveToken("http://localhost:8787", " file-token "); err != nil {
t.Fatalf("SaveToken() error = %v", err)
}

otherProcessStore := NewStore()
got, err := otherProcessStore.GetToken("http://localhost:8787")
if err != nil {
t.Fatalf("GetToken() error = %v", err)
}
if got != "file-token" {
t.Fatalf("GetToken() = %q, want %q", got, "file-token")
}

info, err := os.Stat(storeFile)
if err != nil {
t.Fatalf("stat store file: %v", err)
}
if got := info.Mode().Perm(); got != 0o600 {
t.Fatalf("store file mode = %v, want 0600", got)
}
}

func TestNewStoreWithService_UsesTestStoreFileAndRestrictsExistingFile(t *testing.T) {
storeFile := filepath.Join(t.TempDir(), "auth-store.json")
t.Setenv(testAuthStoreFileEnv, storeFile)
if err := os.WriteFile(storeFile, []byte(`{}`), 0o644); err != nil {
t.Fatalf("precreate store file: %v", err)
}
if err := os.Chmod(storeFile, 0o644); err != nil {
t.Fatalf("set broad store file permissions: %v", err)
}

store := NewStoreWithService("custom-service")
if err := store.SaveToken("http://localhost:8787", "service-token"); err != nil {
t.Fatalf("SaveToken() error = %v", err)
}

otherProcessStore := NewStoreWithService("custom-service")
got, err := otherProcessStore.GetToken("http://localhost:8787")
if err != nil {
t.Fatalf("GetToken() error = %v", err)
}
if got != "service-token" {
t.Fatalf("GetToken() = %q, want %q", got, "service-token")
}

info, err := os.Stat(storeFile)
if err != nil {
t.Fatalf("stat store file: %v", err)
}
if got := info.Mode().Perm(); got != 0o600 {
t.Fatalf("store file mode = %v, want 0600", got)
}
}
Loading
Loading