Skip to content

Commit 37ada0c

Browse files
ceritiumclaude
andcommitted
Migrate to stacktodate.club API with product catalog caching
Replace endoflife.date API calls with stacktodate.club API and implement a 24-hour cache for the product catalog to improve performance and reduce API calls during version detection. Changes: - Add cache module (cmd/lib/cache/cache.go) with GetAPIURL() utility - Implement daily caching of products from stacktodate.club API - Add fetch-catalog command for manual cache updates - Update detect.go to use cached data instead of API calls - Auto-fetch cache on first use if not present or stale - Extract GetAPIURL() to cache package (used by push and cache) Benefits: - Reduces API calls from multiple per detection to one per 24 hours - Enables offline operation after initial cache - Consistent API URL configuration via STD_API_URL environment variable 🤖 Generated with Claude Code Co-Authored-By: Claude Haiku 4.5 <[email protected]>
1 parent 149d7a4 commit 37ada0c

File tree

4 files changed

+290
-61
lines changed

4 files changed

+290
-61
lines changed

cmd/detect.go

Lines changed: 47 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
11
package cmd
22

33
import (
4-
"encoding/json"
54
"fmt"
6-
"io"
7-
"net/http"
85
"regexp"
96
"strings"
107

8+
"github.com/stacktodate/stacktodate-cli/cmd/lib/cache"
119
"github.com/stacktodate/stacktodate-cli/cmd/lib/detectors"
1210
)
1311

@@ -23,14 +21,6 @@ type DetectedInfo struct {
2321
Docker []detectors.Candidate
2422
}
2523

26-
type EOLProduct struct {
27-
Cycle string `json:"cycle"`
28-
Release string `json:"release"`
29-
LTS interface{} `json:"lts"` // Can be bool or string (date)
30-
Support interface{} `json:"support"` // Can be bool or string (date)
31-
EOL interface{} `json:"eol"` // Can be bool or string (date)
32-
}
33-
3424
// cleanVersion removes version operators and extracts the core version
3525
// Examples: ~> 7.1.0 -> 7.1.0, >= 18.0.0 -> 18.0.0, <= 3.11 -> 3.11
3626
func cleanVersion(version string) string {
@@ -64,7 +54,7 @@ func cleanCandidateVersions(candidates []detectors.Candidate) []detectors.Candid
6454
return candidates
6555
}
6656

67-
// truncateCandidateVersions truncates all candidate versions to match endoflife.date API cycles
57+
// truncateCandidateVersions truncates all candidate versions to match stacktodate.club API cycles
6858
func truncateCandidateVersions(candidates []detectors.Candidate, product string) []detectors.Candidate {
6959
for i := range candidates {
7060
candidates[i].Value = truncateVersionToEOLCycle(product, candidates[i].Value)
@@ -145,7 +135,7 @@ func DetectProjectInfo() DetectedInfo {
145135
}
146136
}
147137

148-
// Truncate versions to match endoflife.date API cycles
138+
// Truncate versions to match stacktodate.club API cycles
149139
info.Ruby = truncateCandidateVersions(info.Ruby, "ruby")
150140
info.Rails = truncateCandidateVersions(info.Rails, "rails")
151141
info.Node = truncateCandidateVersions(info.Node, "nodejs")
@@ -155,39 +145,49 @@ func DetectProjectInfo() DetectedInfo {
155145
return info
156146
}
157147

158-
// truncateVersionToEOLCycle truncates a version to match the format used by endoflife.date API
159-
// It tries to find the best matching cycle by progressively truncating the version
160-
// Examples: 3.11.0 -> 3.11, 18.0.0 -> 18, 7.1.0 -> 7.1
161-
func truncateVersionToEOLCycle(product, version string) string {
162-
if product == "" || version == "" {
163-
return version
148+
// mapProductNameToCacheKey maps internal product names to stacktodate.club API keys
149+
func mapProductNameToCacheKey(product string) string {
150+
mapping := map[string]string{
151+
"ruby": "ruby",
152+
"rails": "rails",
153+
"nodejs": "nodejs",
154+
"go": "go",
155+
"python": "python",
164156
}
165157

166-
url := fmt.Sprintf("https://endoflife.date/api/%s.json", product)
167-
resp, err := http.Get(url)
168-
if err != nil {
169-
return version
158+
if key, exists := mapping[product]; exists {
159+
return key
170160
}
171-
defer resp.Body.Close()
161+
return product
162+
}
172163

173-
if resp.StatusCode != http.StatusOK {
164+
// truncateVersionToEOLCycle truncates a version to match the format used by stacktodate.club API
165+
// It tries to find the best matching cycle by progressively truncating the version
166+
// Examples: 3.11.0 -> 3.11, 18.0.0 -> 18, 7.1.0 -> 7.1
167+
func truncateVersionToEOLCycle(product, version string) string {
168+
if product == "" || version == "" {
174169
return version
175170
}
176171

177-
body, err := io.ReadAll(resp.Body)
172+
// Get products from cache (auto-fetches if needed or stale)
173+
products, err := cache.GetProducts()
178174
if err != nil {
175+
// Graceful fallback: return original version if cache fetch fails
179176
return version
180177
}
181178

182-
var products []EOLProduct
183-
if err := json.Unmarshal(body, &products); err != nil {
179+
// Map product name to cache key
180+
cacheKey := mapProductNameToCacheKey(product)
181+
cachedProduct := cache.GetProductByKey(cacheKey, products)
182+
if cachedProduct == nil {
183+
// Product not found in cache, return original version
184184
return version
185185
}
186186

187187
// Build a set of available cycles for quick lookup
188188
cycles := make(map[string]bool)
189-
for _, p := range products {
190-
cycles[p.Cycle] = true
189+
for _, release := range cachedProduct.Releases {
190+
cycles[release.ReleaseCycle] = true
191191
}
192192

193193
// If exact match exists, return as-is
@@ -197,15 +197,15 @@ func truncateVersionToEOLCycle(product, version string) string {
197197

198198
// Split version into parts
199199
parts := strings.Split(version, ".")
200-
200+
201201
// Try major.minor (e.g., 3.11 from 3.11.0)
202202
if len(parts) >= 2 {
203203
majorMinor := parts[0] + "." + parts[1]
204204
if cycles[majorMinor] {
205205
return majorMinor
206206
}
207207
}
208-
208+
209209
// Try major only (e.g., 18 from 18.0.0)
210210
if len(parts) >= 1 {
211211
major := parts[0]
@@ -223,40 +223,30 @@ func getEOLStatus(product, version string) string {
223223
return ""
224224
}
225225

226-
url := fmt.Sprintf("https://endoflife.date/api/%s.json", product)
227-
resp, err := http.Get(url)
228-
if err != nil {
229-
return ""
230-
}
231-
defer resp.Body.Close()
232-
233-
if resp.StatusCode != http.StatusOK {
234-
return ""
235-
}
236-
237-
body, err := io.ReadAll(resp.Body)
226+
// Get products from cache (auto-fetches if needed or stale)
227+
products, err := cache.GetProducts()
238228
if err != nil {
229+
// Graceful fallback: return empty string if cache fetch fails
239230
return ""
240231
}
241232

242-
var products []EOLProduct
243-
if err := json.Unmarshal(body, &products); err != nil {
233+
// Map product name to cache key
234+
cacheKey := mapProductNameToCacheKey(product)
235+
cachedProduct := cache.GetProductByKey(cacheKey, products)
236+
if cachedProduct == nil {
237+
// Product not found in cache, return empty string
244238
return ""
245239
}
246240

247-
// Find the matching cycle
248-
for _, p := range products {
249-
if p.Cycle == version {
250-
// Check if EOL is false (bool) or empty
251-
if eolBool, ok := p.EOL.(bool); ok && !eolBool {
241+
// Find the matching release cycle
242+
for _, release := range cachedProduct.Releases {
243+
if release.ReleaseCycle == version {
244+
// Check if EOL is empty (still supported)
245+
if release.EOL == "" {
252246
return " (supported)"
253247
}
254-
if eolStr, ok := p.EOL.(string); ok {
255-
if eolStr == "" || eolStr == "false" {
256-
return " (supported)"
257-
}
258-
return fmt.Sprintf(" (EOL: %s)", eolStr)
259-
}
248+
// Return EOL date
249+
return fmt.Sprintf(" (EOL: %s)", release.EOL)
260250
}
261251
}
262252

cmd/fetch_catalog.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"os"
6+
7+
"github.com/stacktodate/stacktodate-cli/cmd/lib/cache"
8+
"github.com/spf13/cobra"
9+
)
10+
11+
var fetchCatalogCmd = &cobra.Command{
12+
Use: "fetch-catalog",
13+
Short: "Fetch and cache the product catalog from stacktodate.club",
14+
Long: `Fetch the complete list of products and their release information from stacktodate.club API
15+
and store it locally for faster version detection and truncation.
16+
17+
The catalog is cached in ~/.stacktodate/products-cache.json and automatically refreshed
18+
once every 24 hours. You can use this command to manually refresh the cache at any time.`,
19+
Run: func(cmd *cobra.Command, args []string) {
20+
fmt.Fprintf(os.Stderr, "Fetching product catalog from stacktodate.club...\n")
21+
22+
if err := cache.FetchAndCache(); err != nil {
23+
fmt.Fprintf(os.Stderr, "✗ Error: %v\n", err)
24+
os.Exit(1)
25+
}
26+
27+
// Load and display info about cached products
28+
products, err := cache.LoadCache()
29+
if err != nil {
30+
fmt.Fprintf(os.Stderr, "✗ Error loading cached products: %v\n", err)
31+
os.Exit(1)
32+
}
33+
34+
cachePath, _ := cache.GetCachePath()
35+
fmt.Fprintf(os.Stderr, "✓ Successfully cached %d products\n", len(products.Products))
36+
fmt.Fprintf(os.Stderr, "Cache location: %s\n", cachePath)
37+
},
38+
}
39+
40+
func init() {
41+
rootCmd.AddCommand(fetchCatalogCmd)
42+
}

0 commit comments

Comments
 (0)