Skip to content

Commit 0a79cd0

Browse files
authored
pilotctl: appstore outdated + upgrade (client-side app updates) (#339)
1 parent b203d86 commit 0a79cd0

3 files changed

Lines changed: 278 additions & 1 deletion

File tree

cmd/pilotctl/appstore.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,10 @@ func cmdAppStore(args []string) {
6969
cmdAppStoreVerify(args[1:])
7070
case "install":
7171
cmdAppStoreInstall(args[1:])
72+
case "outdated":
73+
cmdAppStoreOutdated(args[1:])
74+
case "upgrade":
75+
cmdAppStoreUpgrade(args[1:])
7276
case "gen-key":
7377
cmdAppStoreGenKey(args[1:])
7478
case "sign":
@@ -87,7 +91,7 @@ func cmdAppStore(args []string) {
8791
appStoreHelp()
8892
default:
8993
fatalHint("invalid_argument",
90-
"available: list, status, view, audit, uninstall, verify, install, gen-key, sign, sign-catalogue, catalogue, restart, caps, actions, call",
94+
"available: list, status, view, audit, uninstall, verify, install, outdated, upgrade, gen-key, sign, sign-catalogue, catalogue, restart, caps, actions, call",
9195
"unknown appstore subcommand: %s", args[0])
9296
}
9397
}
@@ -119,6 +123,8 @@ Usage:
119123
pilotctl appstore install <bundle-dir> --local [--force]
120124
sideload a local bundle (sandbox: fs.read/fs.write
121125
under $APP, audit.log; no net, no key.sign, no hooks)
126+
pilotctl appstore outdated list installed apps with a newer version in the catalogue
127+
pilotctl appstore upgrade <id> | --all re-install the catalogue's current version (verified; supervisor restarts)
122128
pilotctl appstore gen-key <key-file> generate a fresh ed25519 publisher keypair; prints the public side
123129
pilotctl appstore sign --key <key-file> <manifest>
124130
sign (or re-sign) a manifest's store.signature so the supervisor accepts it

cmd/pilotctl/appstore_update.go

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
"sort"
9+
"strings"
10+
11+
"github.com/pilot-protocol/app-store/pkg/manifest"
12+
)
13+
14+
// installedApp is one app present in the install root, identified by the
15+
// authoritative on-disk record: its manifest's id + app_version.
16+
type installedApp struct {
17+
ID string
18+
AppVersion string
19+
}
20+
21+
// scanInstalledApps reads the install root and returns each installed app's id
22+
// and version straight from its manifest.json (the version of record). Apps with
23+
// an unreadable/invalid manifest are skipped — they surface as broken in `list`.
24+
func scanInstalledApps() ([]installedApp, error) {
25+
root := appStoreRoot()
26+
entries, err := os.ReadDir(root)
27+
if err != nil {
28+
return nil, err
29+
}
30+
var apps []installedApp
31+
for _, e := range entries {
32+
if !e.IsDir() {
33+
continue
34+
}
35+
raw, err := os.ReadFile(filepath.Join(root, e.Name(), "manifest.json"))
36+
if err != nil {
37+
continue
38+
}
39+
m, err := manifest.Parse(raw)
40+
if err != nil {
41+
continue
42+
}
43+
apps = append(apps, installedApp{ID: m.ID, AppVersion: m.AppVersion})
44+
}
45+
sort.Slice(apps, func(i, j int) bool { return apps[i].ID < apps[j].ID })
46+
return apps, nil
47+
}
48+
49+
// outdatedApp pairs an installed app with the newer version the catalogue offers.
50+
type outdatedApp struct {
51+
ID string `json:"id"`
52+
Installed string `json:"installed"`
53+
Available string `json:"available"`
54+
}
55+
56+
// findOutdated cross-references installed apps against the signed catalogue and
57+
// returns those whose catalogue version is strictly newer than what's installed.
58+
// This is the missing client link: install records a version, the catalogue
59+
// advertises one, but nothing compared them until now.
60+
func findOutdated() ([]outdatedApp, error) {
61+
installed, err := scanInstalledApps()
62+
if err != nil {
63+
return nil, err
64+
}
65+
cat, err := loadCatalogue()
66+
if err != nil {
67+
return nil, err
68+
}
69+
latest := make(map[string]string, len(cat.Apps))
70+
for _, e := range cat.Apps {
71+
latest[e.ID] = e.Version
72+
}
73+
var out []outdatedApp
74+
for _, a := range installed {
75+
if v, ok := latest[a.ID]; ok && semverCompare(v, a.AppVersion) > 0 {
76+
out = append(out, outdatedApp{ID: a.ID, Installed: a.AppVersion, Available: v})
77+
}
78+
}
79+
return out, nil
80+
}
81+
82+
// cmdAppStoreOutdated lists installed apps that have a newer version in the
83+
// catalogue. Exit status is 0 even when some are outdated (it's a report); the
84+
// JSON form is stable for scripting an auto-upgrade.
85+
func cmdAppStoreOutdated(_ []string) {
86+
out, err := findOutdated()
87+
if err != nil {
88+
fatalHint("io_error", "is the install root present and the catalogue reachable?", "outdated: %v", err)
89+
}
90+
if jsonOutput {
91+
_ = json.NewEncoder(os.Stdout).Encode(out)
92+
return
93+
}
94+
if len(out) == 0 {
95+
fmt.Println("all installed apps are up to date")
96+
return
97+
}
98+
fmt.Printf("%-32s %-12s %-12s\n", "APP", "INSTALLED", "AVAILABLE")
99+
for _, o := range out {
100+
fmt.Printf("%-32s %-12s %-12s\n", o.ID, o.Installed, o.Available)
101+
}
102+
fmt.Printf("\nupgrade with: pilotctl appstore upgrade <id> (or --all)\n")
103+
}
104+
105+
// cmdAppStoreUpgrade upgrades one app (or --all outdated apps) to the catalogue's
106+
// current version by re-running the same verified install with --force. The
107+
// supervisor detects the on-disk version bump on its next rescan, refuses any
108+
// downgrade, and restarts the app at the new version. Reusing install means the
109+
// upgrade goes through the exact catalogue-sha + manifest-sha + trust-anchor
110+
// checks a fresh install does — no second, weaker code path.
111+
func cmdAppStoreUpgrade(args []string) {
112+
all := false
113+
var id string
114+
for _, a := range args {
115+
switch a {
116+
case "--all":
117+
all = true
118+
case "-h", "--help":
119+
fmt.Fprintln(os.Stderr, "usage: pilotctl appstore upgrade <id> | --all")
120+
return
121+
default:
122+
id = a
123+
}
124+
}
125+
if !all && id == "" {
126+
fatalHint("invalid_argument", "usage: pilotctl appstore upgrade <id> | --all", "missing app id (or --all)")
127+
}
128+
129+
outdated, err := findOutdated()
130+
if err != nil {
131+
fatalHint("io_error", "is the install root present and the catalogue reachable?", "upgrade: %v", err)
132+
}
133+
byID := make(map[string]outdatedApp, len(outdated))
134+
for _, o := range outdated {
135+
byID[o.ID] = o
136+
}
137+
138+
var targets []outdatedApp
139+
if all {
140+
targets = outdated
141+
if len(targets) == 0 {
142+
fmt.Println("all installed apps are up to date")
143+
return
144+
}
145+
} else {
146+
o, ok := byID[id]
147+
if !ok {
148+
// Either not installed, not in the catalogue, or already current.
149+
fmt.Printf("%s is already up to date (or not a catalogue app)\n", id)
150+
return
151+
}
152+
targets = []outdatedApp{o}
153+
}
154+
155+
for _, o := range targets {
156+
fmt.Printf("==> upgrading %s %s → %s\n", o.ID, o.Installed, o.Available)
157+
// --force: install over the existing app dir; the supervisor applies the
158+
// version bump (and refuses a downgrade) on its next rescan.
159+
cmdAppStoreInstall([]string{o.ID, "--force"})
160+
}
161+
fmt.Printf("\nupgraded %s\n", strings.TrimSpace(pluralApps(len(targets))))
162+
}
163+
164+
func pluralApps(n int) string {
165+
if n == 1 {
166+
return "1 app"
167+
}
168+
return fmt.Sprintf("%d apps", n)
169+
}
170+
171+
// semverCompare compares two MAJOR.MINOR.PATCH versions, ignoring any
172+
// prerelease/build suffix beyond the numeric core. Returns -1, 0, or 1. A
173+
// missing component counts as 0 (so "1.2" == "1.2.0").
174+
func semverCompare(a, b string) int {
175+
ap := semverParts(a)
176+
bp := semverParts(b)
177+
for i := 0; i < 3; i++ {
178+
if ap[i] != bp[i] {
179+
if ap[i] < bp[i] {
180+
return -1
181+
}
182+
return 1
183+
}
184+
}
185+
return 0
186+
}
187+
188+
func semverParts(v string) [3]int {
189+
core := v
190+
if i := strings.IndexAny(core, "-+"); i >= 0 {
191+
core = core[:i]
192+
}
193+
var out [3]int
194+
for i, s := range strings.SplitN(core, ".", 3) {
195+
if i > 2 {
196+
break
197+
}
198+
n := 0
199+
for _, c := range s {
200+
if c < '0' || c > '9' {
201+
break
202+
}
203+
n = n*10 + int(c-'0')
204+
}
205+
out[i] = n
206+
}
207+
return out
208+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package main
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
)
8+
9+
func TestSemverCompare(t *testing.T) {
10+
cases := []struct {
11+
a, b string
12+
want int
13+
}{
14+
{"1.0.0", "1.0.0", 0},
15+
{"1.0.1", "1.0.0", 1},
16+
{"1.0.0", "1.0.1", -1},
17+
{"1.2.0", "1.1.9", 1},
18+
{"2.0.0", "1.9.9", 1},
19+
{"1.2", "1.2.0", 0}, // missing component == 0
20+
{"1.2.3-rc.1", "1.2.3", 0}, // prerelease ignored in the core compare
21+
{"0.10.0", "0.9.0", 1}, // numeric, not lexical
22+
}
23+
for _, c := range cases {
24+
if got := semverCompare(c.a, c.b); got != c.want {
25+
t.Errorf("semverCompare(%q,%q) = %d, want %d", c.a, c.b, got, c.want)
26+
}
27+
}
28+
}
29+
30+
func TestScanInstalledApps(t *testing.T) {
31+
root := t.TempDir()
32+
t.Setenv("PILOT_APPSTORE_ROOT", root)
33+
34+
writeApp := func(id, ver string) {
35+
d := filepath.Join(root, id)
36+
if err := os.MkdirAll(d, 0o755); err != nil {
37+
t.Fatal(err)
38+
}
39+
mf := `{"id":"` + id + `","app_version":"` + ver + `","manifest_version":1,` +
40+
`"binary":{"path":"bin/x","sha256":"` + hex64 + `"},"exposes":["x.help"],` +
41+
`"protection":"shareable","store":{"publisher":"ed25519:AAA"}}`
42+
if err := os.WriteFile(filepath.Join(d, "manifest.json"), []byte(mf), 0o644); err != nil {
43+
t.Fatal(err)
44+
}
45+
}
46+
writeApp("io.pilot.alpha", "1.0.0")
47+
writeApp("io.pilot.beta", "0.3.1")
48+
// a junk dir without a manifest is skipped
49+
_ = os.MkdirAll(filepath.Join(root, "junk"), 0o755)
50+
51+
apps, err := scanInstalledApps()
52+
if err != nil {
53+
t.Fatal(err)
54+
}
55+
if len(apps) != 2 {
56+
t.Fatalf("got %d apps, want 2: %+v", len(apps), apps)
57+
}
58+
if apps[0].ID != "io.pilot.alpha" || apps[0].AppVersion != "1.0.0" {
59+
t.Errorf("unexpected first app: %+v", apps[0])
60+
}
61+
}
62+
63+
const hex64 = "0000000000000000000000000000000000000000000000000000000000000000"

0 commit comments

Comments
 (0)