|
| 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 | +} |
0 commit comments