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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ require (
github.com/atotto/clipboard v0.1.4
github.com/charmbracelet/huh v0.8.0
github.com/charmbracelet/lipgloss v1.1.0
github.com/open-cli-collective/cli-common v0.0.0-20260516182733-b753d5c62d14
github.com/open-cli-collective/cli-common v0.0.0-20260519134256-e67b2fc81f9d
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
github.com/spf13/cobra v1.8.0
github.com/yuin/goldmark v1.8.2
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,8 @@ github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIf
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/open-cli-collective/cli-common v0.0.0-20260516182733-b753d5c62d14 h1:78EW5uCbAzbAO32+oY4HDaFOqS2sPYnc4AT+G5UjdL0=
github.com/open-cli-collective/cli-common v0.0.0-20260516182733-b753d5c62d14/go.mod h1:5i4MkFToMVPLBW29O01lsHS9d1m9pC0BxSOYjFDz7ds=
github.com/open-cli-collective/cli-common v0.0.0-20260519134256-e67b2fc81f9d h1:fiyfxc/Wvpuen/u6Mk3bCUG0DLyOylF0K+eol4y9C64=
github.com/open-cli-collective/cli-common v0.0.0-20260519134256-e67b2fc81f9d/go.mod h1:5i4MkFToMVPLBW29O01lsHS9d1m9pC0BxSOYjFDz7ds=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
Expand Down
2 changes: 1 addition & 1 deletion internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ func CheckScopesMigration(grantedScopes []string) string {
// OAuth client JSON referenced by config.yml's oauth_client_path (§1.2 — not
// a secret; lives on disk, never the keyring), with all scopes.
func GetOAuthConfig() (*oauth2.Config, error) {
cfg, err := config.LoadConfig()
cfg, err := config.LoadConfigForRuntime()
if err != nil {
return nil, err
}
Expand Down
227 changes: 103 additions & 124 deletions internal/cache/cache.go
Original file line number Diff line number Diff line change
@@ -1,196 +1,175 @@
// Package cache provides TTL-based caching for API metadata.
// Package cache wraps cli-common/cache for gro's Drive metadata cache.
//
// Per cli-common/docs/working-with-state.md §4, gro's cache is disposable
// state at os.UserCacheDir()/google-readonly (via statedir.Cache). Writes are
// atomic via cli-common/cache's temp+rename envelope; TTL is hard-coded per
// resource (no user-configurable cache_ttl_hours — §4.4); reads classify a
// version/identity mismatch as a miss so schema bumps self-heal. The pre-B2b
// "<configdir>/cache/" relocation is retained for installs that pre-date the
// B2b cache move.
package cache

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

clicache "github.com/open-cli-collective/cli-common/cache"

"github.com/open-cli-collective/google-readonly/internal/config"
)

const (
// DefaultTTLHours is the default cache TTL if not configured
DefaultTTLHours = 24
// DrivesFile is the cache file for shared drives
// DrivesFile is the cache file for shared drives. Retained as the legacy
// pre-B2b file name so migrateLegacyCacheDir can find and relocate it.
DrivesFile = "drives.json"
// drivesResource is the cli-common cache resource name. The on-disk file
// becomes <cachedir>/<instanceKey>/drives.json.
drivesResource = "drives"
// instanceKey is gro's single-instance discriminator (Locator requires
// one). Per cli-common docs: single-instance CLIs use "default".
instanceKey = "default"
// drivesTTL is the §4.4 hard-coded per-resource TTL for shared drives —
// same 24-hour default the user-configurable knob previously defaulted to.
drivesTTL = "24h"
)

// CachedDrive represents a cached shared drive entry
// CachedDrive represents a cached shared drive entry. Public so callers
// (drives.go) can populate it directly.
type CachedDrive struct {
ID string `json:"id"`
Name string `json:"name"`
}

// DriveCache represents the cached shared drives data
type DriveCache struct {
CachedAt time.Time `json:"cached_at"`
TTLHours int `json:"ttl_hours"`
Drives []*CachedDrive `json:"drives"`
}

// Cache provides TTL-based caching for API metadata
// Cache is gro's wrapper around the cli-common envelope cache.
Comment thread
monit-reviewer marked this conversation as resolved.
type Cache struct {
dir string
ttlHours int
loc clicache.Locator
}

// New creates a new Cache instance rooted at the OS cache dir (B2b). It also
// runs a transparent, best-effort one-time relocation of a pre-B2b cache that
// lived inside the config dir; relocation never fails New (the cache is
// disposable — it simply repopulates).
func New(ttlHours int) (*Cache, error) {
// New creates a new Cache instance rooted at the OS cache dir (B2b via
// cli-common/statedir). It also runs a transparent, best-effort one-time
// relocation of a pre-B2b cache that lived inside the config dir; relocation
// never fails New (the cache is disposable — it simply repopulates).
func New() (*Cache, error) {
cacheDir, err := config.GetCacheDir()
if err != nil {
return nil, err
}

migrateLegacyCacheDir(cacheDir)

if ttlHours <= 0 {
ttlHours = DefaultTTLHours
}

return &Cache{
dir: cacheDir,
ttlHours: ttlHours,
loc: clicache.Locator{Root: cacheDir, InstanceKey: instanceKey},
}, nil
}

// migrateLegacyCacheDir relocates a pre-B2b cache (a "cache" subdir of the
// config dir) into newDir, then removes the legacy dir. Strictly silent and
Comment thread
monit-reviewer marked this conversation as resolved.
// best-effort: any failure is abandoned without touching New's result. If the
// warm-cache carry fails the legacy dir is left intact (don't delete data we
// could not carry); a stuck legacy dir is force-cleaned by `config clear
// --all`. Idempotent: once the legacy dir is gone this is a single stat.
// warm-cache carry fails the legacy dir is left intact; a stuck legacy dir is
// force-cleaned by `config clear --all`. The carry is a byte copy and does
// NOT parse the envelope; a stale shape arriving in newDir is harmless
// because cli-common/cache's identity/version check + this package's
// parse-error-mapped-to-miss demote it on the next read.
func migrateLegacyCacheDir(newDir string) {
legacy, err := config.LegacyCacheDir()
if err != nil {
return
// Two possible legacy locations on macOS/Windows: (a) the pre-B2b
// "cache" subdir under the NEW (statedir-resolved) config dir — what a
// user reached if they already ran post-port code once — and (b) the
// same subdir under the OLD hand-rolled config dir, which is where a
// pure pre-MON-5371 install lived. Both are byte-carried best-effort;
// any failure abandons the migration without touching New's result. On
// Linux the two paths collapse, so the second probe is a stat-only no-op.
probes := []func() (string, error){
config.LegacyCacheDir,
config.OldHandRolledLegacyCacheDir,
}
tried := map[string]bool{}
for _, p := range probes {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Low (harness-engineering:harness-architecture-reviewer): GetDrives catches json.SyntaxError and json.UnmarshalTypeError via errors.As as cache misses, but not json.InvalidUnmarshalError or io.ErrUnexpectedEOF (which cli-common/cache.ReadResource may wrap around a truncated read). A partially-written cache file producing one of these unwrapped error types would propagate to the caller instead of being demoted to a miss, breaking the 'corrupt → miss' contract. Consider widening the catch or adding a comment that names the specific cli-common wrapping guarantees this relies on.

Reply to this thread when addressed.

legacy, err := p()
if err != nil || tried[legacy] {
continue
}
tried[legacy] = true
tryCarryLegacyCache(legacy, newDir)
}
}
Comment thread
monit-reviewer marked this conversation as resolved.

func tryCarryLegacyCache(legacy, newDir string) {
if _, err := os.Stat(legacy); err != nil {
return // absent (steady state) or unreadable — nothing safe to do
return
}

legacyDrives := filepath.Join(legacy, DrivesFile)
newDrives := filepath.Join(newDir, DrivesFile)
newDrives := filepath.Join(newDir, instanceKey, DrivesFile)
switch _, serr := os.Stat(newDrives); {
case serr == nil:
// New cache already present: nothing to carry, safe to drop legacy.
case os.IsNotExist(serr):
// New cache absent: try to carry the warm legacy file.
data, rerr := os.ReadFile(legacyDrives) //nolint:gosec // G304: path from config dir, not user input
data, rerr := os.ReadFile(legacyDrives) //nolint:gosec // path from legacy config dir
switch {
case rerr == nil:
if werr := os.WriteFile(newDrives, data, config.TokenPerm); werr != nil { //nolint:gosec // G703: config-derived cache path, user's own disposable data
return // carry failed: keep legacy intact, retry next run
if mkErr := os.MkdirAll(filepath.Dir(newDrives), 0o700); mkErr != nil {
return
}
if werr := os.WriteFile(newDrives, data, config.TokenPerm); werr != nil { //nolint:gosec // disposable cache
return
}
case os.IsNotExist(rerr):
// No warm file to carry — fine, fall through to cleanup.
default:
return // legacy drives file exists but unreadable: do NOT delete it
return
}
default:
return // ambiguous stat on the new path: do not risk deleting un-carried legacy
return
}
_ = os.RemoveAll(legacy) // best-effort; cosmetic if it lingers
_ = os.RemoveAll(legacy)
}

// GetDrives returns cached shared drives, or nil if cache is stale or missing
// GetDrives returns cached shared drives, or nil if cache is stale, missing,
// or corrupt. Corrupt-as-miss preserves the pre-MON-5371 behavior: caches
// are disposable, so a JSON parse error self-heals on the next API call. I/O
// errors (read failure, permission denied) propagate.
func (c *Cache) GetDrives() ([]*CachedDrive, error) {
path := filepath.Join(c.dir, DrivesFile)

data, err := os.ReadFile(path) //nolint:gosec // Path constructed from known config directory
if err != nil {
if os.IsNotExist(err) {
return nil, nil // Cache miss, not an error
}
return nil, err
}

var cache DriveCache
if err := json.Unmarshal(data, &cache); err != nil {
// Corrupted cache, treat as miss
env, err := clicache.ReadResource[[]*CachedDrive](c.loc, drivesResource)
switch {
case errors.Is(err, clicache.ErrCacheMiss):
return nil, nil
case err != nil:
var syn *json.SyntaxError
var ute *json.UnmarshalTypeError
if errors.As(err, &syn) || errors.As(err, &ute) {
return nil, nil // corrupt → miss (self-heals on next write)
}
return nil, fmt.Errorf("reading drives cache: %w", err)
}

// Check if cache is stale
ttl := time.Duration(cache.TTLHours) * time.Hour
if time.Since(cache.CachedAt) > ttl {
return nil, nil // Cache expired
if clicache.Classify(env.FetchedAt, env.TTL, nowFn()) == clicache.StatusStale {
return nil, nil // stale → miss
}

return cache.Drives, nil
return env.Data, nil
}

// SetDrives updates the cached shared drives
// SetDrives atomically writes the drives cache with the §4.4 hard-coded TTL.
func (c *Cache) SetDrives(drives []*CachedDrive) error {
cache := DriveCache{
CachedAt: time.Now(),
TTLHours: c.ttlHours,
Drives: drives,
}

data, err := json.MarshalIndent(cache, "", " ")
if err != nil {
return err
if err := clicache.WriteResource(c.loc, drivesResource, drivesTTL, drives); err != nil {
return fmt.Errorf("writing drives cache: %w", err)
}

path := filepath.Join(c.dir, DrivesFile)
return os.WriteFile(path, data, config.TokenPerm)
return nil
}

// Clear removes all cached data
// Clear removes all cached data for this instance. Scoped to
// <Root>/<InstanceKey> rather than the tool-level Root so a future move to a
// multi-instance Locator can't have one instance's Clear() silently evict
// every other instance's cache.
func (c *Cache) Clear() error {
return os.RemoveAll(c.dir)
}

// Status returns information about the cache state
type Status struct {
Dir string `json:"dir"`
TTLHours int `json:"ttl_hours"`
DrivesCache *FileInfo `json:"drives_cache,omitempty"`
}

// FileInfo contains information about a cache file
type FileInfo struct {
Path string `json:"path"`
CachedAt time.Time `json:"cached_at"`
ExpiresAt time.Time `json:"expires_at"`
IsStale bool `json:"is_stale"`
Count int `json:"count"`
}

// GetStatus returns the current cache status
func (c *Cache) GetStatus() (*Status, error) {
status := &Status{
Dir: c.dir,
TTLHours: c.ttlHours,
}

// Check drives cache
drivesPath := filepath.Join(c.dir, DrivesFile)
data, err := os.ReadFile(drivesPath) //nolint:gosec // Path constructed from known config directory
if err == nil {
var cache DriveCache
if json.Unmarshal(data, &cache) == nil {
ttl := time.Duration(cache.TTLHours) * time.Hour
expiresAt := cache.CachedAt.Add(ttl)
status.DrivesCache = &FileInfo{
Path: drivesPath,
CachedAt: cache.CachedAt,
ExpiresAt: expiresAt,
IsStale: time.Now().After(expiresAt),
Count: len(cache.Drives),
}
}
}

return status, nil
return os.RemoveAll(filepath.Join(c.loc.Root, c.loc.InstanceKey))
}

// GetDir returns the cache directory path
// GetDir returns the cache directory path.
func (c *Cache) GetDir() string {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Low (harness-engineering:harness-knowledge-reviewer): GetDir() returns c.loc.Root (the tool-level cache root: /google-readonly), not the per-instance directory where files are actually written (/). TestCache_Clear confirms real data lives one level deeper than GetDir() reports. A future caller using GetDir() to construct data paths will look in the wrong place. Either return filepath.Join(c.loc.Root, c.loc.InstanceKey) or rename to GetRootDir and add a doc comment clarifying this is a tool-root accessor.

Reply to this thread when addressed.

return c.dir
return c.loc.Root
}

Comment thread
monit-reviewer marked this conversation as resolved.
// nowFn is a testing seam for cache classification.
var nowFn = func() time.Time { return time.Now() }
Loading
Loading