From 936e968ad07869b7c434e6752e469b4df8add9bb Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 28 Jan 2026 15:19:03 +0000 Subject: [PATCH] feat(core): add workspace-local cache for GitHub API responses - Add pkg/cache for file-based caching with TTL - Cache lives in workspace .core/cache/ (not home dir) - Search command now caches repo lists for 1 hour - Shows "Cache: host-uk (5s ago)" on cache hit - Use --refresh to bypass cache - Add .core/ to core-devops .gitignore Structure: .core/cache/github//repos.json Co-Authored-By: Claude Opus 4.5 --- cmd/core/cmd/search.go | 103 +++++++++++++++++--------- go.work | 1 + pkg/cache/cache.go | 161 +++++++++++++++++++++++++++++++++++++++++ pkg/cache/go.mod | 3 + 4 files changed, 233 insertions(+), 35 deletions(-) create mode 100644 pkg/cache/cache.go create mode 100644 pkg/cache/go.mod diff --git a/cmd/core/cmd/search.go b/cmd/core/cmd/search.go index fa8a5b79..879ea95a 100644 --- a/cmd/core/cmd/search.go +++ b/cmd/core/cmd/search.go @@ -5,9 +5,13 @@ import ( "fmt" "os" "os/exec" + "path/filepath" "sort" "strings" + "time" + "github.com/Snider/Core/pkg/cache" + "github.com/Snider/Core/pkg/repos" "github.com/leaanthony/clir" ) @@ -17,19 +21,21 @@ func AddSearchCommand(parent *clir.Cli) { var pattern string var repoType string var limit int + var refresh bool searchCmd := parent.NewSubCommand("search", "Search GitHub for repos by pattern") searchCmd.LongDescription("Searches GitHub for repositories matching a pattern.\n" + - "Uses gh CLI for authenticated search.\n\n" + + "Uses gh CLI for authenticated search. Results are cached for 1 hour.\n\n" + "Examples:\n" + " core search --org host-uk --pattern 'core-*'\n" + " core search --org mycompany --pattern '*-mod-*'\n" + - " core search --org letheanvpn --pattern '*'") + " core search --org letheanvpn --refresh") searchCmd.StringFlag("org", "GitHub organization to search (required)", &org) searchCmd.StringFlag("pattern", "Repo name pattern (* for wildcard)", &pattern) searchCmd.StringFlag("type", "Filter by type in name (mod, services, plug, website)", &repoType) searchCmd.IntFlag("limit", "Max results (default 50)", &limit) + searchCmd.BoolFlag("refresh", "Bypass cache and fetch fresh data", &refresh) searchCmd.Action(func() error { if org == "" { @@ -41,7 +47,7 @@ func AddSearchCommand(parent *clir.Cli) { if limit == 0 { limit = 50 } - return runSearch(org, pattern, repoType, limit) + return runSearch(org, pattern, repoType, limit, refresh) }) } @@ -54,44 +60,71 @@ type ghRepo struct { Language string `json:"language"` } -func runSearch(org, pattern, repoType string, limit int) error { - if !ghAuthenticated() { - return fmt.Errorf("gh CLI not authenticated. Run: gh auth login") +func runSearch(org, pattern, repoType string, limit int, refresh bool) error { + // Initialize cache in workspace .core/ directory + var cacheDir string + if regPath, err := repos.FindRegistry(); err == nil { + cacheDir = filepath.Join(filepath.Dir(regPath), ".core", "cache") } - // Check for bad GH_TOKEN which can override keyring auth - if os.Getenv("GH_TOKEN") != "" { - fmt.Printf("%s GH_TOKEN env var is set - this may cause auth issues\n", dimStyle.Render("Note:")) - fmt.Printf("%s Unset it with: unset GH_TOKEN\n\n", dimStyle.Render("")) - } - - fmt.Printf("%s %s", dimStyle.Render("Searching:"), org) - if pattern != "" && pattern != "*" { - fmt.Printf(" (filter: %s)", pattern) - } - if repoType != "" { - fmt.Printf(" (type: %s)", repoType) - } - fmt.Println() - fmt.Println() - - // Always use gh repo list (more reliable than gh search repos) - cmd := exec.Command("gh", "repo", "list", org, - "--json", "name,description,visibility,updatedAt,primaryLanguage", - "--limit", fmt.Sprintf("%d", limit)) - output, err := cmd.CombinedOutput() - + c, err := cache.New(cacheDir, 0) if err != nil { - errStr := strings.TrimSpace(string(output)) - if strings.Contains(errStr, "401") || strings.Contains(errStr, "Bad credentials") { - return fmt.Errorf("authentication failed - try: unset GH_TOKEN && gh auth login") - } - return fmt.Errorf("search failed: %s", errStr) + // Cache init failed, continue without cache + c = nil } + cacheKey := cache.GitHubReposKey(org) var repos []ghRepo - if err := json.Unmarshal(output, &repos); err != nil { - return fmt.Errorf("failed to parse results: %w", err) + var fromCache bool + + // Try cache first (unless refresh requested) + if c != nil && !refresh { + if found, err := c.Get(cacheKey, &repos); found && err == nil { + fromCache = true + age := c.Age(cacheKey) + fmt.Printf("%s %s %s\n", dimStyle.Render("Cache:"), org, dimStyle.Render(fmt.Sprintf("(%s ago)", age.Round(time.Second)))) + } + } + + // Fetch from GitHub if not cached + if !fromCache { + if !ghAuthenticated() { + return fmt.Errorf("gh CLI not authenticated. Run: gh auth login") + } + + // Check for bad GH_TOKEN which can override keyring auth + if os.Getenv("GH_TOKEN") != "" { + fmt.Printf("%s GH_TOKEN env var is set - this may cause auth issues\n", dimStyle.Render("Note:")) + fmt.Printf("%s Unset it with: unset GH_TOKEN\n\n", dimStyle.Render("")) + } + + fmt.Printf("%s %s... ", dimStyle.Render("Fetching:"), org) + + // Always use gh repo list (more reliable than gh search repos) + cmd := exec.Command("gh", "repo", "list", org, + "--json", "name,description,visibility,updatedAt,primaryLanguage", + "--limit", fmt.Sprintf("%d", limit)) + output, err := cmd.CombinedOutput() + + if err != nil { + fmt.Println() + errStr := strings.TrimSpace(string(output)) + if strings.Contains(errStr, "401") || strings.Contains(errStr, "Bad credentials") { + return fmt.Errorf("authentication failed - try: unset GH_TOKEN && gh auth login") + } + return fmt.Errorf("search failed: %s", errStr) + } + + if err := json.Unmarshal(output, &repos); err != nil { + return fmt.Errorf("failed to parse results: %w", err) + } + + // Cache the results + if c != nil { + _ = c.Set(cacheKey, repos) + } + + fmt.Printf("%s\n", successStyle.Render("✓")) } // Filter by glob pattern and type diff --git a/go.work b/go.work index 1797f659..641400e3 100644 --- a/go.work +++ b/go.work @@ -7,6 +7,7 @@ use ( ./cmd/core-mcp ./cmd/examples/core-static-di ./cmd/lthn-desktop + ./pkg/cache ./pkg/config ./pkg/core ./pkg/display diff --git a/pkg/cache/cache.go b/pkg/cache/cache.go new file mode 100644 index 00000000..6081fc37 --- /dev/null +++ b/pkg/cache/cache.go @@ -0,0 +1,161 @@ +// Package cache provides a file-based cache for GitHub API responses. +package cache + +import ( + "encoding/json" + "os" + "path/filepath" + "time" +) + +// DefaultTTL is the default cache expiry time. +const DefaultTTL = 1 * time.Hour + +// Cache represents a file-based cache. +type Cache struct { + baseDir string + ttl time.Duration +} + +// Entry represents a cached item with metadata. +type Entry struct { + Data json.RawMessage `json:"data"` + CachedAt time.Time `json:"cached_at"` + ExpiresAt time.Time `json:"expires_at"` +} + +// New creates a new cache instance. +// If baseDir is empty, uses .core/cache in current directory +func New(baseDir string, ttl time.Duration) (*Cache, error) { + if baseDir == "" { + // Use .core/cache in current working directory + cwd, err := os.Getwd() + if err != nil { + return nil, err + } + baseDir = filepath.Join(cwd, ".core", "cache") + } + + if ttl == 0 { + ttl = DefaultTTL + } + + // Ensure cache directory exists + if err := os.MkdirAll(baseDir, 0755); err != nil { + return nil, err + } + + return &Cache{ + baseDir: baseDir, + ttl: ttl, + }, nil +} + +// Path returns the full path for a cache key. +func (c *Cache) Path(key string) string { + return filepath.Join(c.baseDir, key+".json") +} + +// Get retrieves a cached item if it exists and hasn't expired. +func (c *Cache) Get(key string, dest interface{}) (bool, error) { + path := c.Path(key) + + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + + var entry Entry + if err := json.Unmarshal(data, &entry); err != nil { + // Invalid cache file, treat as miss + return false, nil + } + + // Check expiry + if time.Now().After(entry.ExpiresAt) { + return false, nil + } + + // Unmarshal the actual data + if err := json.Unmarshal(entry.Data, dest); err != nil { + return false, err + } + + return true, nil +} + +// Set stores an item in the cache. +func (c *Cache) Set(key string, data interface{}) error { + path := c.Path(key) + + // Ensure parent directory exists + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + + // Marshal the data + dataBytes, err := json.Marshal(data) + if err != nil { + return err + } + + entry := Entry{ + Data: dataBytes, + CachedAt: time.Now(), + ExpiresAt: time.Now().Add(c.ttl), + } + + entryBytes, err := json.MarshalIndent(entry, "", " ") + if err != nil { + return err + } + + return os.WriteFile(path, entryBytes, 0644) +} + +// Delete removes an item from the cache. +func (c *Cache) Delete(key string) error { + path := c.Path(key) + err := os.Remove(path) + if os.IsNotExist(err) { + return nil + } + return err +} + +// Clear removes all cached items. +func (c *Cache) Clear() error { + return os.RemoveAll(c.baseDir) +} + +// Age returns how old a cached item is, or -1 if not cached. +func (c *Cache) Age(key string) time.Duration { + path := c.Path(key) + + data, err := os.ReadFile(path) + if err != nil { + return -1 + } + + var entry Entry + if err := json.Unmarshal(data, &entry); err != nil { + return -1 + } + + return time.Since(entry.CachedAt) +} + +// GitHub-specific cache keys + +// GitHubReposKey returns the cache key for an org's repo list. +func GitHubReposKey(org string) string { + return filepath.Join("github", org, "repos") +} + +// GitHubRepoKey returns the cache key for a specific repo's metadata. +func GitHubRepoKey(org, repo string) string { + return filepath.Join("github", org, repo, "meta") +} diff --git a/pkg/cache/go.mod b/pkg/cache/go.mod new file mode 100644 index 00000000..5cb6084d --- /dev/null +++ b/pkg/cache/go.mod @@ -0,0 +1,3 @@ +module github.com/Snider/Core/pkg/cache + +go 1.24