diff --git a/cmd/pilotctl/appstore.go b/cmd/pilotctl/appstore.go index 686a19c2..296d4edc 100644 --- a/cmd/pilotctl/appstore.go +++ b/cmd/pilotctl/appstore.go @@ -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": @@ -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]) } } @@ -119,6 +123,8 @@ Usage: pilotctl appstore install --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 | --all re-install the catalogue's current version (verified; supervisor restarts) pilotctl appstore gen-key generate a fresh ed25519 publisher keypair; prints the public side pilotctl appstore sign --key sign (or re-sign) a manifest's store.signature so the supervisor accepts it diff --git a/cmd/pilotctl/appstore_update.go b/cmd/pilotctl/appstore_update.go new file mode 100644 index 00000000..f1280615 --- /dev/null +++ b/cmd/pilotctl/appstore_update.go @@ -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")) + 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 (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 | --all") + return + default: + id = a + } + } + if !all && id == "" { + fatalHint("invalid_argument", "usage: pilotctl appstore upgrade | --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 +} diff --git a/cmd/pilotctl/appstore_update_test.go b/cmd/pilotctl/appstore_update_test.go new file mode 100644 index 00000000..2f081cc0 --- /dev/null +++ b/cmd/pilotctl/appstore_update_test.go @@ -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"