-
Notifications
You must be signed in to change notification settings - Fork 13
Expand file tree
/
Copy pathupdates.go
More file actions
343 lines (317 loc) · 10 KB
/
Copy pathupdates.go
File metadata and controls
343 lines (317 loc) · 10 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
// SPDX-License-Identifier: AGPL-3.0-or-later
package main
import (
"encoding/xml"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/pilot-protocol/common/driver"
"github.com/pilot-protocol/skillinject"
"github.com/pilot-protocol/updater"
)
// changelogFeedURL is the canonical RSS 2.0 feed for the public Pilot
// Protocol changelog. Hosted on GitHub Pages from the pilot-changelog
// repo (per `pilot-changelog/README.md`). RSS chosen over feed.json so
// we can stay on the standard library only — no JSON-feed dep needed.
//
// Declared as a var (not const) so tests can point at httptest.Server.
var changelogFeedURL = "https://teoslayer.github.io/pilot-changelog/feed.xml"
// rssDoc is the minimal RSS 2.0 shape we care about. Only fields needed
// for the human-readable + JSON output are decoded; unknown elements are
// ignored by encoding/xml.
type rssDoc struct {
XMLName xml.Name `xml:"rss"`
Channel rssChan `xml:"channel"`
}
type rssChan struct {
Title string `xml:"title"`
Items []rssItem `xml:"item"`
}
type rssItem struct {
Title string `xml:"title"`
Link string `xml:"link"`
GUID string `xml:"guid"`
PubDate string `xml:"pubDate"`
Description string `xml:"description"`
Categories []string `xml:"category"`
}
// cmdUpdates fetches the changelog feed, parses it, and prints recent
// entries. Cached at ~/.pilot/updates-cache.xml for 5 minutes so a tight
// loop of `pilotctl updates` doesn't hammer the GH-Pages origin.
//
// Flags:
//
// --count N : how many entries to show (default 10)
// --scope <name> : filter by RSS <category> (e.g. protocol, networks, ops)
// --refresh : force a fresh fetch, bypass cache
// (global) --json : emit machine-readable JSON instead of human text
func cmdUpdates(args []string) {
flags, _ := parseFlags(args)
count := flagInt(flags, "count", 10)
scope := strings.ToLower(flagString(flags, "scope", ""))
refresh := flagBool(flags, "refresh")
body, fromCache, err := fetchChangelogFeed(refresh)
if err != nil {
fatalCode("connection_failed", "fetch %s: %v", changelogFeedURL, err)
}
var doc rssDoc
if err := xml.Unmarshal(body, &doc); err != nil {
fatalCode("internal", "parse RSS: %v", err)
}
items := filterAndTruncate(doc.Channel.Items, scope, count)
if jsonOutput {
out := make([]map[string]interface{}, 0, len(items))
for _, it := range items {
out = append(out, map[string]interface{}{
"title": strings.TrimSpace(it.Title),
"link": strings.TrimSpace(it.Link),
"guid": strings.TrimSpace(it.GUID),
"pub_date": strings.TrimSpace(it.PubDate),
"description": strings.TrimSpace(it.Description),
"categories": it.Categories,
})
}
output(map[string]interface{}{
"source": changelogFeedURL,
"from_cache": fromCache,
"channel": doc.Channel.Title,
"count": len(out),
"updates": out,
})
return
}
if len(items) == 0 {
if scope != "" {
fmt.Printf("no updates matching scope %q (try omitting --scope)\n", scope)
} else {
fmt.Println("no updates available")
}
return
}
if fromCache {
fmt.Fprintf(os.Stderr, "(cached; --refresh to force re-fetch)\n")
}
fmt.Printf("%s — %s\n\n", doc.Channel.Title, changelogFeedURL)
for _, it := range items {
date := strings.TrimSpace(it.PubDate)
// Trim RSS pubDate to YYYY-MM-DD for compactness when we can.
if t, err := time.Parse(time.RFC1123Z, date); err == nil {
date = t.Format("2006-01-02")
} else if t, err := time.Parse(time.RFC1123, date); err == nil {
date = t.Format("2006-01-02")
}
title := strings.TrimSpace(it.Title)
fmt.Printf("• %s %s\n", sAccent(date), title)
if len(it.Categories) > 0 {
fmt.Printf(" %s\n", sDim("["+strings.Join(it.Categories, ", ")+"]"))
}
if d := strings.TrimSpace(it.Description); d != "" {
fmt.Printf(" %s\n", wrapText(collapseWhitespace(d), 100, 4))
}
if l := strings.TrimSpace(it.Link); l != "" {
fmt.Printf(" %s\n", sDim(l))
}
fmt.Println()
}
}
// wrapText word-wraps s at word boundaries so no line exceeds width
// columns (counting the indent), with a hanging indent: continuation
// lines are prefixed with `indent` spaces to line up under a first line
// the caller has already indented by the same amount. Words longer than
// a full line are emitted unbroken — never split mid-word.
func wrapText(s string, width, indent int) string {
words := strings.Fields(s)
if len(words) == 0 {
return ""
}
pad := strings.Repeat(" ", indent)
var b strings.Builder
lineLen := indent // caller prints the first line's indent
for i, w := range words {
wl := len([]rune(w))
switch {
case i == 0:
b.WriteString(w)
lineLen += wl
case lineLen+1+wl > width:
b.WriteString("\n")
b.WriteString(pad)
b.WriteString(w)
lineLen = indent + wl
default:
b.WriteString(" ")
b.WriteString(w)
lineLen += 1 + wl
}
}
return b.String()
}
// filterAndTruncate applies the --scope category filter (case-insensitive,
// match-any-category) and the --count cap to a list of feed items. Both
// arguments are inert when zero/empty, so callers can pass scope="" or
// count=0 to skip either step.
func filterAndTruncate(items []rssItem, scope string, count int) []rssItem {
if scope != "" {
filtered := make([]rssItem, 0, len(items))
for _, it := range items {
for _, c := range it.Categories {
if strings.EqualFold(c, scope) {
filtered = append(filtered, it)
break
}
}
}
items = filtered
}
if count > 0 && len(items) > count {
items = items[:count]
}
return items
}
func collapseWhitespace(s string) string {
// Cheap inline whitespace collapse — RSS descriptions often carry HTML
// linebreaks that print badly in a terminal. We keep one space between
// runs of any whitespace.
var b strings.Builder
prevSpace := false
for _, r := range s {
if r == ' ' || r == '\n' || r == '\r' || r == '\t' {
if !prevSpace {
b.WriteByte(' ')
prevSpace = true
}
continue
}
b.WriteRune(r)
prevSpace = false
}
return strings.TrimSpace(b.String())
}
// cmdUpdate runs the updater once — checking for new releases, downloading and
// installing if available. When the daemon is not running (manual mode), it
// also re-runs skill install so newly installed binaries have matching skills.
//
// Flags:
//
// --repo <name> : GitHub owner/repo for releases (default: pilot-protocol/pilotprotocol)
// --pin <tag> : pin to a specific release tag (e.g. v1.10.5)
// (global) --json : emit machine-readable JSON
func cmdUpdate(args []string) {
flags, _ := parseFlags(args)
repo := flagString(flags, "repo", "pilot-protocol/pilotprotocol")
pin := flagString(flags, "pin", "")
// Determine install directory: where the updater binary lives.
updaterBin, err := findCompanionBinary("pilot-updater", "PILOT_UPDATER_BIN")
if err != nil {
fatalCode("internal", "cannot locate pilot-updater binary: %v", err)
}
installDir := filepath.Dir(updaterBin)
u := updater.New(updater.Config{
CheckInterval: 0, // unused for RunOnce
Repo: repo,
InstallDir: installDir,
Version: version,
PinnedVersion: pin,
})
u.RunOnce()
if jsonOutput {
outputOK(map[string]interface{}{
"install_dir": installDir,
"repo": repo,
"pinned": pin != "",
})
return
}
fmt.Printf("Update check complete. Install dir: %s\n", installDir)
// In manual mode (no daemon running), re-run skill install so skills
// match the (possibly updated) binaries.
if !daemonRunning() {
report, err := runTick()
if err != nil {
fmt.Fprintf(os.Stderr, "warning: skill install failed: %v\n", err)
} else {
c := report.Counts()
fmt.Printf("Skills: %d files checked (%d up-to-date, %d installed, %d errors)\n",
len(report.Outcomes),
c[skillinject.ActionNoop],
c[skillinject.ActionCreate]+c[skillinject.ActionRewrite],
c[skillinject.ActionError])
}
}
}
// daemonRunning checks whether the daemon socket is reachable.
func daemonRunning() bool {
sock := getSocket()
d, err := driver.Connect(sock)
if err != nil {
return false
}
d.Close()
return true
}
// fetchChangelogFeed returns the cached feed body if it's fresh (< 5 min)
// and `refresh` is false; otherwise hits the network. Returns
// (body, fromCache, err). Cache lives at ~/.pilot/updates-cache.xml so
// repeat invocations within the cache window are zero-network.
func fetchChangelogFeed(refresh bool) ([]byte, bool, error) {
cachePath := updatesCachePath()
if !refresh && cachePath != "" {
if info, err := os.Stat(cachePath); err == nil {
if time.Since(info.ModTime()) < 5*time.Minute {
if data, err := os.ReadFile(cachePath); err == nil && len(data) > 0 {
return data, true, nil
}
}
}
}
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest(http.MethodGet, changelogFeedURL, nil)
if err != nil {
return nil, false, err
}
req.Header.Set("User-Agent", "pilotctl/"+version)
req.Header.Set("Accept", "application/rss+xml, application/xml, text/xml")
resp, err := client.Do(req)
if err != nil {
// Fall back to stale cache rather than hard-failing the user — an
// offline laptop should still be able to see what we knew last.
if cachePath != "" {
if data, ferr := os.ReadFile(cachePath); ferr == nil && len(data) > 0 {
return data, true, nil
}
}
return nil, false, err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return nil, false, fmt.Errorf("HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 4*1024*1024))
if err != nil {
return nil, false, err
}
if cachePath != "" {
// Best-effort cache write; swallow errors so a read-only home
// doesn't break the command.
tmp := cachePath + ".tmp"
if werr := os.WriteFile(tmp, body, 0644); werr == nil {
_ = os.Rename(tmp, cachePath)
}
}
return body, false, nil
}
func updatesCachePath() string {
home, err := os.UserHomeDir()
if err != nil || home == "" {
return ""
}
dir := filepath.Join(home, ".pilot")
if err := os.MkdirAll(dir, 0700); err != nil {
return ""
}
return filepath.Join(dir, "updates-cache.xml")
}