Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion cmd/pilotctl/appstore.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ func cmdAppStore(args []string) {
cmdAppStoreVerify(args[1:])
case "install":
cmdAppStoreInstall(args[1:])
case "outdated":
cmdAppStoreOutdated(args[1:])
case "upgrade":
cmdAppStoreUpgrade(args[1:])
case "gen-key":
cmdAppStoreGenKey(args[1:])
case "sign":
Expand All @@ -87,7 +91,7 @@ func cmdAppStore(args []string) {
appStoreHelp()
default:
fatalHint("invalid_argument",
"available: list, status, view, audit, uninstall, verify, install, gen-key, sign, sign-catalogue, catalogue, restart, caps, actions, call",
"available: list, status, view, audit, uninstall, verify, install, outdated, upgrade, gen-key, sign, sign-catalogue, catalogue, restart, caps, actions, call",
"unknown appstore subcommand: %s", args[0])
}
}
Expand Down Expand Up @@ -119,6 +123,8 @@ Usage:
pilotctl appstore install <bundle-dir> --local [--force]
sideload a local bundle (sandbox: fs.read/fs.write
under $APP, audit.log; no net, no key.sign, no hooks)
pilotctl appstore outdated list installed apps with a newer version in the catalogue
pilotctl appstore upgrade <id> | --all re-install the catalogue's current version (verified; supervisor restarts)
pilotctl appstore gen-key <key-file> generate a fresh ed25519 publisher keypair; prints the public side
pilotctl appstore sign --key <key-file> <manifest>
sign (or re-sign) a manifest's store.signature so the supervisor accepts it
Expand Down
208 changes: 208 additions & 0 deletions cmd/pilotctl/appstore_update.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
package main

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"

"github.com/pilot-protocol/app-store/pkg/manifest"
)

// installedApp is one app present in the install root, identified by the
// authoritative on-disk record: its manifest's id + app_version.
type installedApp struct {
ID string
AppVersion string
}

// scanInstalledApps reads the install root and returns each installed app's id
// and version straight from its manifest.json (the version of record). Apps with
// an unreadable/invalid manifest are skipped — they surface as broken in `list`.
func scanInstalledApps() ([]installedApp, error) {
root := appStoreRoot()
entries, err := os.ReadDir(root)
if err != nil {
return nil, err
}
var apps []installedApp
for _, e := range entries {
if !e.IsDir() {
continue
}
raw, err := os.ReadFile(filepath.Join(root, e.Name(), "manifest.json"))

Check failure

Code scanning / gosec

Potential file inclusion via variable Error

Potential file inclusion via variable
Comment thread
Alexgodoroja marked this conversation as resolved.
Dismissed
if err != nil {
continue
}
m, err := manifest.Parse(raw)
if err != nil {
continue
}
apps = append(apps, installedApp{ID: m.ID, AppVersion: m.AppVersion})
}
sort.Slice(apps, func(i, j int) bool { return apps[i].ID < apps[j].ID })
return apps, nil
}

// outdatedApp pairs an installed app with the newer version the catalogue offers.
type outdatedApp struct {
ID string `json:"id"`
Installed string `json:"installed"`
Available string `json:"available"`
}

// findOutdated cross-references installed apps against the signed catalogue and
// returns those whose catalogue version is strictly newer than what's installed.
// This is the missing client link: install records a version, the catalogue
// advertises one, but nothing compared them until now.
func findOutdated() ([]outdatedApp, error) {
installed, err := scanInstalledApps()
if err != nil {
return nil, err
}
cat, err := loadCatalogue()
if err != nil {
return nil, err
}
latest := make(map[string]string, len(cat.Apps))
for _, e := range cat.Apps {
latest[e.ID] = e.Version
}
var out []outdatedApp
for _, a := range installed {
if v, ok := latest[a.ID]; ok && semverCompare(v, a.AppVersion) > 0 {
out = append(out, outdatedApp{ID: a.ID, Installed: a.AppVersion, Available: v})
}
}
return out, nil
}

// cmdAppStoreOutdated lists installed apps that have a newer version in the
// catalogue. Exit status is 0 even when some are outdated (it's a report); the
// JSON form is stable for scripting an auto-upgrade.
func cmdAppStoreOutdated(_ []string) {
out, err := findOutdated()
if err != nil {
fatalHint("io_error", "is the install root present and the catalogue reachable?", "outdated: %v", err)
}
if jsonOutput {
_ = json.NewEncoder(os.Stdout).Encode(out)
return
}
if len(out) == 0 {
fmt.Println("all installed apps are up to date")
return
}
fmt.Printf("%-32s %-12s %-12s\n", "APP", "INSTALLED", "AVAILABLE")
for _, o := range out {
fmt.Printf("%-32s %-12s %-12s\n", o.ID, o.Installed, o.Available)
}
fmt.Printf("\nupgrade with: pilotctl appstore upgrade <id> (or --all)\n")
}

// cmdAppStoreUpgrade upgrades one app (or --all outdated apps) to the catalogue's
// current version by re-running the same verified install with --force. The
// supervisor detects the on-disk version bump on its next rescan, refuses any
// downgrade, and restarts the app at the new version. Reusing install means the
// upgrade goes through the exact catalogue-sha + manifest-sha + trust-anchor
// checks a fresh install does — no second, weaker code path.
func cmdAppStoreUpgrade(args []string) {
all := false
var id string
for _, a := range args {
switch a {
case "--all":
all = true
case "-h", "--help":
fmt.Fprintln(os.Stderr, "usage: pilotctl appstore upgrade <id> | --all")
return
default:
id = a
}
}
if !all && id == "" {
fatalHint("invalid_argument", "usage: pilotctl appstore upgrade <id> | --all", "missing app id (or --all)")
}

outdated, err := findOutdated()
if err != nil {
fatalHint("io_error", "is the install root present and the catalogue reachable?", "upgrade: %v", err)
}
byID := make(map[string]outdatedApp, len(outdated))
for _, o := range outdated {
byID[o.ID] = o
}

var targets []outdatedApp
if all {
targets = outdated
if len(targets) == 0 {
fmt.Println("all installed apps are up to date")
return
}
} else {
o, ok := byID[id]
if !ok {
// Either not installed, not in the catalogue, or already current.
fmt.Printf("%s is already up to date (or not a catalogue app)\n", id)
return
}
targets = []outdatedApp{o}
}

for _, o := range targets {
fmt.Printf("==> upgrading %s %s → %s\n", o.ID, o.Installed, o.Available)
// --force: install over the existing app dir; the supervisor applies the
// version bump (and refuses a downgrade) on its next rescan.
cmdAppStoreInstall([]string{o.ID, "--force"})
}
fmt.Printf("\nupgraded %s\n", strings.TrimSpace(pluralApps(len(targets))))
}

func pluralApps(n int) string {
if n == 1 {
return "1 app"
}
return fmt.Sprintf("%d apps", n)
}

// semverCompare compares two MAJOR.MINOR.PATCH versions, ignoring any
// prerelease/build suffix beyond the numeric core. Returns -1, 0, or 1. A
// missing component counts as 0 (so "1.2" == "1.2.0").
func semverCompare(a, b string) int {
ap := semverParts(a)
bp := semverParts(b)
for i := 0; i < 3; i++ {
if ap[i] != bp[i] {
if ap[i] < bp[i] {
return -1
}
return 1
}
}
return 0
}

func semverParts(v string) [3]int {
core := v
if i := strings.IndexAny(core, "-+"); i >= 0 {
core = core[:i]
}
var out [3]int
for i, s := range strings.SplitN(core, ".", 3) {
if i > 2 {
break
}
n := 0
for _, c := range s {
if c < '0' || c > '9' {
break
}
n = n*10 + int(c-'0')
}
out[i] = n
}
return out
}
63 changes: 63 additions & 0 deletions cmd/pilotctl/appstore_update_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package main

import (
"os"
"path/filepath"
"testing"
)

func TestSemverCompare(t *testing.T) {
cases := []struct {
a, b string
want int
}{
{"1.0.0", "1.0.0", 0},
{"1.0.1", "1.0.0", 1},
{"1.0.0", "1.0.1", -1},
{"1.2.0", "1.1.9", 1},
{"2.0.0", "1.9.9", 1},
{"1.2", "1.2.0", 0}, // missing component == 0
{"1.2.3-rc.1", "1.2.3", 0}, // prerelease ignored in the core compare
{"0.10.0", "0.9.0", 1}, // numeric, not lexical
}
for _, c := range cases {
if got := semverCompare(c.a, c.b); got != c.want {
t.Errorf("semverCompare(%q,%q) = %d, want %d", c.a, c.b, got, c.want)
}
}
}

func TestScanInstalledApps(t *testing.T) {
root := t.TempDir()
t.Setenv("PILOT_APPSTORE_ROOT", root)

writeApp := func(id, ver string) {
d := filepath.Join(root, id)
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
mf := `{"id":"` + id + `","app_version":"` + ver + `","manifest_version":1,` +
`"binary":{"path":"bin/x","sha256":"` + hex64 + `"},"exposes":["x.help"],` +
`"protection":"shareable","store":{"publisher":"ed25519:AAA"}}`
if err := os.WriteFile(filepath.Join(d, "manifest.json"), []byte(mf), 0o644); err != nil {
t.Fatal(err)
}
}
writeApp("io.pilot.alpha", "1.0.0")
writeApp("io.pilot.beta", "0.3.1")
// a junk dir without a manifest is skipped
_ = os.MkdirAll(filepath.Join(root, "junk"), 0o755)

apps, err := scanInstalledApps()
if err != nil {
t.Fatal(err)
}
if len(apps) != 2 {
t.Fatalf("got %d apps, want 2: %+v", len(apps), apps)
}
if apps[0].ID != "io.pilot.alpha" || apps[0].AppVersion != "1.0.0" {
t.Errorf("unexpected first app: %+v", apps[0])
}
}

const hex64 = "0000000000000000000000000000000000000000000000000000000000000000"
Loading