Skip to content

Commit fc7d034

Browse files
committed
pilotctl updates: read pilot-changelog RSS feed
New command. Fetches https://teoslayer.github.io/pilot-changelog/feed.xml, parses RSS 2.0 with encoding/xml, prints recent entries. Cached at ~/.pilot/updates-cache.xml for 5 min; falls back to stale cache when offline so a laptop without network can still see what we knew last. Flags: --count N, --scope <category>, --refresh, --json. No external deps (RSS via stdlib). 11 tests covering filter+truncate, cold fetch, cache hit, refresh bypass, stale-cache refetch, offline fallback, and no-fallback-without-cache.
1 parent cdf06ff commit fc7d034

2 files changed

Lines changed: 591 additions & 0 deletions

File tree

cmd/pilotctl/updates.go

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+
package main
4+
5+
import (
6+
"encoding/xml"
7+
"fmt"
8+
"io"
9+
"net/http"
10+
"os"
11+
"path/filepath"
12+
"strings"
13+
"time"
14+
)
15+
16+
// changelogFeedURL is the canonical RSS 2.0 feed for the public Pilot
17+
// Protocol changelog. Hosted on GitHub Pages from the pilot-changelog
18+
// repo (per `pilot-changelog/README.md`). RSS chosen over feed.json so
19+
// we can stay on the standard library only — no JSON-feed dep needed.
20+
//
21+
// Declared as a var (not const) so tests can point at httptest.Server.
22+
var changelogFeedURL = "https://teoslayer.github.io/pilot-changelog/feed.xml"
23+
24+
// rssDoc is the minimal RSS 2.0 shape we care about. Only fields needed
25+
// for the human-readable + JSON output are decoded; unknown elements are
26+
// ignored by encoding/xml.
27+
type rssDoc struct {
28+
XMLName xml.Name `xml:"rss"`
29+
Channel rssChan `xml:"channel"`
30+
}
31+
32+
type rssChan struct {
33+
Title string `xml:"title"`
34+
Items []rssItem `xml:"item"`
35+
}
36+
37+
type rssItem struct {
38+
Title string `xml:"title"`
39+
Link string `xml:"link"`
40+
GUID string `xml:"guid"`
41+
PubDate string `xml:"pubDate"`
42+
Description string `xml:"description"`
43+
Categories []string `xml:"category"`
44+
}
45+
46+
// cmdUpdates fetches the changelog feed, parses it, and prints recent
47+
// entries. Cached at ~/.pilot/updates-cache.xml for 5 minutes so a tight
48+
// loop of `pilotctl updates` doesn't hammer the GH-Pages origin.
49+
//
50+
// Flags:
51+
//
52+
// --count N : how many entries to show (default 10)
53+
// --scope <name> : filter by RSS <category> (e.g. protocol, networks, ops)
54+
// --refresh : force a fresh fetch, bypass cache
55+
// (global) --json : emit machine-readable JSON instead of human text
56+
func cmdUpdates(args []string) {
57+
flags, _ := parseFlags(args)
58+
count := flagInt(flags, "count", 10)
59+
scope := strings.ToLower(flagString(flags, "scope", ""))
60+
refresh := flagBool(flags, "refresh")
61+
62+
body, fromCache, err := fetchChangelogFeed(refresh)
63+
if err != nil {
64+
fatalCode("connection_failed", "fetch %s: %v", changelogFeedURL, err)
65+
}
66+
67+
var doc rssDoc
68+
if err := xml.Unmarshal(body, &doc); err != nil {
69+
fatalCode("internal", "parse RSS: %v", err)
70+
}
71+
72+
items := filterAndTruncate(doc.Channel.Items, scope, count)
73+
74+
if jsonOutput {
75+
out := make([]map[string]interface{}, 0, len(items))
76+
for _, it := range items {
77+
out = append(out, map[string]interface{}{
78+
"title": strings.TrimSpace(it.Title),
79+
"link": strings.TrimSpace(it.Link),
80+
"guid": strings.TrimSpace(it.GUID),
81+
"pub_date": strings.TrimSpace(it.PubDate),
82+
"description": strings.TrimSpace(it.Description),
83+
"categories": it.Categories,
84+
})
85+
}
86+
output(map[string]interface{}{
87+
"source": changelogFeedURL,
88+
"from_cache": fromCache,
89+
"channel": doc.Channel.Title,
90+
"count": len(out),
91+
"updates": out,
92+
})
93+
return
94+
}
95+
96+
if len(items) == 0 {
97+
if scope != "" {
98+
fmt.Printf("no updates matching scope %q (try omitting --scope)\n", scope)
99+
} else {
100+
fmt.Println("no updates available")
101+
}
102+
return
103+
}
104+
if fromCache {
105+
fmt.Fprintf(os.Stderr, "(cached; --refresh to force re-fetch)\n")
106+
}
107+
fmt.Printf("%s — %s\n\n", doc.Channel.Title, changelogFeedURL)
108+
for _, it := range items {
109+
date := strings.TrimSpace(it.PubDate)
110+
// Trim RSS pubDate to YYYY-MM-DD for compactness when we can.
111+
if t, err := time.Parse(time.RFC1123Z, date); err == nil {
112+
date = t.Format("2006-01-02")
113+
} else if t, err := time.Parse(time.RFC1123, date); err == nil {
114+
date = t.Format("2006-01-02")
115+
}
116+
title := strings.TrimSpace(it.Title)
117+
fmt.Printf("• %s %s\n", date, title)
118+
if len(it.Categories) > 0 {
119+
fmt.Printf(" [%s]\n", strings.Join(it.Categories, ", "))
120+
}
121+
if d := strings.TrimSpace(it.Description); d != "" {
122+
d = collapseWhitespace(d)
123+
if len(d) > 200 {
124+
d = d[:197] + "..."
125+
}
126+
fmt.Printf(" %s\n", d)
127+
}
128+
if l := strings.TrimSpace(it.Link); l != "" {
129+
fmt.Printf(" %s\n", l)
130+
}
131+
fmt.Println()
132+
}
133+
}
134+
135+
// filterAndTruncate applies the --scope category filter (case-insensitive,
136+
// match-any-category) and the --count cap to a list of feed items. Both
137+
// arguments are inert when zero/empty, so callers can pass scope="" or
138+
// count=0 to skip either step.
139+
func filterAndTruncate(items []rssItem, scope string, count int) []rssItem {
140+
if scope != "" {
141+
filtered := make([]rssItem, 0, len(items))
142+
for _, it := range items {
143+
for _, c := range it.Categories {
144+
if strings.EqualFold(c, scope) {
145+
filtered = append(filtered, it)
146+
break
147+
}
148+
}
149+
}
150+
items = filtered
151+
}
152+
if count > 0 && len(items) > count {
153+
items = items[:count]
154+
}
155+
return items
156+
}
157+
158+
func collapseWhitespace(s string) string {
159+
// Cheap inline whitespace collapse — RSS descriptions often carry HTML
160+
// linebreaks that print badly in a terminal. We keep one space between
161+
// runs of any whitespace.
162+
var b strings.Builder
163+
prevSpace := false
164+
for _, r := range s {
165+
if r == ' ' || r == '\n' || r == '\r' || r == '\t' {
166+
if !prevSpace {
167+
b.WriteByte(' ')
168+
prevSpace = true
169+
}
170+
continue
171+
}
172+
b.WriteRune(r)
173+
prevSpace = false
174+
}
175+
return strings.TrimSpace(b.String())
176+
}
177+
178+
// fetchChangelogFeed returns the cached feed body if it's fresh (< 5 min)
179+
// and `refresh` is false; otherwise hits the network. Returns
180+
// (body, fromCache, err). Cache lives at ~/.pilot/updates-cache.xml so
181+
// repeat invocations within the cache window are zero-network.
182+
func fetchChangelogFeed(refresh bool) ([]byte, bool, error) {
183+
cachePath := updatesCachePath()
184+
if !refresh && cachePath != "" {
185+
if info, err := os.Stat(cachePath); err == nil {
186+
if time.Since(info.ModTime()) < 5*time.Minute {
187+
if data, err := os.ReadFile(cachePath); err == nil && len(data) > 0 {
188+
return data, true, nil
189+
}
190+
}
191+
}
192+
}
193+
194+
client := &http.Client{Timeout: 10 * time.Second}
195+
req, err := http.NewRequest(http.MethodGet, changelogFeedURL, nil)
196+
if err != nil {
197+
return nil, false, err
198+
}
199+
req.Header.Set("User-Agent", "pilotctl/"+version)
200+
req.Header.Set("Accept", "application/rss+xml, application/xml, text/xml")
201+
resp, err := client.Do(req)
202+
if err != nil {
203+
// Fall back to stale cache rather than hard-failing the user — an
204+
// offline laptop should still be able to see what we knew last.
205+
if cachePath != "" {
206+
if data, ferr := os.ReadFile(cachePath); ferr == nil && len(data) > 0 {
207+
return data, true, nil
208+
}
209+
}
210+
return nil, false, err
211+
}
212+
defer resp.Body.Close()
213+
if resp.StatusCode/100 != 2 {
214+
return nil, false, fmt.Errorf("HTTP %d", resp.StatusCode)
215+
}
216+
body, err := io.ReadAll(io.LimitReader(resp.Body, 4*1024*1024))
217+
if err != nil {
218+
return nil, false, err
219+
}
220+
if cachePath != "" {
221+
// Best-effort cache write; swallow errors so a read-only home
222+
// doesn't break the command.
223+
tmp := cachePath + ".tmp"
224+
if werr := os.WriteFile(tmp, body, 0644); werr == nil {
225+
_ = os.Rename(tmp, cachePath)
226+
}
227+
}
228+
return body, false, nil
229+
}
230+
231+
func updatesCachePath() string {
232+
home, err := os.UserHomeDir()
233+
if err != nil || home == "" {
234+
return ""
235+
}
236+
dir := filepath.Join(home, ".pilot")
237+
if err := os.MkdirAll(dir, 0700); err != nil {
238+
return ""
239+
}
240+
return filepath.Join(dir, "updates-cache.xml")
241+
}

0 commit comments

Comments
 (0)