diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d1f88f5..45453949 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,27 @@ project uses [Semantic Versioning](https://semver.org/). Detailed per-release notes are on the [GitHub Releases page](https://github.com/TeoSlayer/pilotprotocol/releases). +## [1.12.0] - 2026-06-15 + +### Added +- **`pilotctl appstore view ` — a detail page for store apps.** Shows a + human-app-store-style listing: structured description, vendor, latest + changelog (`--all-changelog` for full history), download/installed size, + source-code URL, license, methods, and — when the app is installed — its + verified integrity state and granted permissions. Works whether or not the + app is installed, and whether or not it is in the catalogue (a sideloaded app + renders from local manifest facts). `--json` emits the merged report. The + catalogue listing now also shows vendor, categories, license, and size, plus + a `view:` hint. (app store) +- **Catalogue schema v2 + per-app detail docs.** The catalogue index gains + optional teaser fields (`display_name`, `vendor`, `categories`, + `bundle_size`, `source_url`, `license`) and a `metadata_url` + + `metadata_sha256` pin to a per-app `catalogue/apps//metadata.json` detail + document, fetched lazily by `view` and sha-verified the same way bundles are. + v1 catalogues still load unchanged, and an older `pilotctl` ignores the new + fields — the bump is backward and forward compatible. The `reviews` slot in + the detail schema is reserved for a future signed reviews service. (app store) + ## [1.11.0] - 2026-06-09 ### Added diff --git a/catalogue/README.md b/catalogue/README.md index 619f42e9..534c7309 100644 --- a/catalogue/README.md +++ b/catalogue/README.md @@ -1,30 +1,107 @@ # Pilot app store catalogue -This directory holds `catalogue.json` — the canonical list of apps installable via -`pilotctl appstore install `. +This directory holds the app store catalogue: -## Schema +- `catalogue.json` — the **index**: the list of apps installable via + `pilotctl appstore install `. Kept lightweight; read on every + `catalogue` and `install`. +- `apps//metadata.json` — the per-app **detail document** ("store + listing"): structured description, changelog, vendor, size, source URL, + screenshots. Fetched lazily, only by `pilotctl appstore view `. + +The split mirrors a human app store (a cheap list vs a rich detail page) and +keeps the install hot path small. Each index entry sha-pins its detail doc the +same way it pins the bundle tarball. + +## Index schema (`catalogue.json`) + +The index is **versioned**. Version 1 is the original flat shape; version 2 +adds optional teaser fields plus a pin to the detail doc. `pilotctl` +understands both — a v1 catalogue still loads, and an older `pilotctl` +ignores the v2 fields. **Always set `"version": 2` when using any v2 field.** ```json { - "version": 1, + "version": 2, "updated_at": "", "apps": [ { "id": "", "version": "", - "description": "", + "description": "", "bundle_url": "https:///.tar.gz", - "bundle_sha256": "" + "bundle_sha256": "", + + "display_name": "", + "vendor": "", + "categories": ["", ""], + "bundle_size": 0, + "source_url": "https://github.com//", + "license": "", + + "metadata_url": "https:///apps//metadata.json", + "metadata_sha256": "" } ] } ``` -The schema is intentionally flat — `pilotctl` decodes it directly into -`catalogueEntry` in `cmd/pilotctl/appstore_catalogue.go`. Any field added +Everything from `display_name` down is optional (`omitempty`). The five v1 +fields stay required. `pilotctl` decodes the index directly into +`catalogueEntry` in `cmd/pilotctl/appstore_catalogue.go` — any field added here must also land there. +## Detail schema (`apps//metadata.json`) + +Fetched only by `pilotctl appstore view `, verified against the index's +`metadata_sha256`. Every field is optional so a partial document still +renders. Decoded into `appMetadata` in `cmd/pilotctl/appstore_metadata.go`. + +```json +{ + "schema_version": 1, + "id": "", + "display_name": "", + "tagline": "", + "description_md": "", + "vendor": { "name": "", "url": "", "contact": "", "publisher_pubkey": "ed25519:..." }, + "homepage": "https://...", + "source_url": "https://github.com//", + "license": "", + "categories": ["..."], + "keywords": ["..."], + "icon_url": "https://...", + "screenshots": [{ "url": "https://...", "caption": "" }], + "size": { "bundle_bytes": 0, "installed_bytes": 0 }, + "compat": { "min_pilot_version": "1.0.0", "runtimes": ["go"] }, + "methods": [{ "name": "app.method", "summary": "" }], + "changelog": [{ "version": "X.Y.Z", "date": "YYYY-MM-DD", "notes": ["..."] }], + "links": [{ "label": "Docs", "url": "https://..." }], + "reviews": null, + "published_at": "", + "updated_at": "" +} +``` + +`reviews` is **reserved** — pilotctl parses it but never writes it. Community +reviews are a separate, signed, dynamic service (not static git data); the +slot exists so the `view` output and JSON shape stay stable when it lands. + +## What `view` shows + +`pilotctl appstore view ` merges three bands and labels their provenance: + +- **catalogue** — the index teaser (publisher-attested, sha-anchored), +- **metadata** — the detail doc above (publisher copy), +- **local-manifest** — verified install facts when the app is installed + (integrity, real on-disk size, granted permissions, methods). + +It works whether or not the app is installed, and whether or not it is in the +catalogue (a sideloaded app renders from local facts alone). Publisher copy and +verified install facts are kept visually distinct so they are never conflated. +Add `--all-changelog` for the full version history; `--json` for the merged +`appViewReport`. + ## Where pilotctl loads it from By default, `pilotctl appstore catalogue` and `pilotctl appstore install @@ -58,10 +135,21 @@ from a remote — operators relying on a catalogue do so over `https` only). 4. Upload the tarball as a release artifact (`gh release upload` or equivalent — GitHub releases, Cloudflare R2, anywhere reachable over HTTPS). -5. Update this `catalogue.json` with the new `version`, `bundle_url`, and - `bundle_sha256`. Commit. The change goes live the moment the commit - lands on `main` and the raw URL serves the new bytes — no daemon - restart, no pilotctl release. +5. Write or update the detail doc at `apps//metadata.json` (bump its + `changelog`, `version`, `size`, `updated_at`). Then recompute its sha: + ```bash + shasum -a 256 apps//metadata.json + ``` +6. Update this `catalogue.json` with the new `version`, `bundle_url`, + `bundle_sha256`, the teaser fields, **and** the new `metadata_sha256` + from step 5. Commit everything together — the index pin and the detail + doc must change in the same commit or `view` will reject a stale pin. + The change goes live the moment the commit lands on `main` and the raw + URLs serve the new bytes — no daemon restart, no pilotctl release. + +> **Pin discipline:** `metadata_sha256` must be the sha256 of the exact +> committed `metadata.json` bytes. Edit the doc, then recompute — never the +> other way round. A mismatch makes `view` fall back to the teaser and warn. ## Trust model @@ -70,7 +158,10 @@ from a remote — operators relying on a catalogue do so over `https` only). | User trusts pilotctl | Project release pipeline (signed pilotctl binary) | The catalogue URL is correct | | pilotctl trusts the catalogue | Future: signed against `EmbeddedCatalogPubkey`; today: the raw URL itself | App IDs map to specific bundle URLs + SHAs | | pilotctl trusts the bundle | Embedded `bundle_sha256` matches downloaded bytes | A CDN substitute is rejected | +| pilotctl trusts the detail doc | Index `metadata_sha256` matches fetched `metadata.json` | A substituted listing is rejected (`view` falls back to the teaser) | | Daemon trusts the manifest | Embedded ed25519 publisher pubkey verifies the signature | The bundle's manifest hasn't been tampered with | -Every layer is checked at install time, and the manifest signature is -re-verified at every supervisor rescan (every 2 s). +Every layer is checked at install/view time, and the manifest signature is +re-verified at every supervisor rescan (every 2 s). The detail doc carries no +authority of its own — it is display metadata, anchored only by the index pin, +and `view` keeps it visually separate from verified install facts. diff --git a/catalogue/apps/io.pilot.cosift/metadata.json b/catalogue/apps/io.pilot.cosift/metadata.json new file mode 100644 index 00000000..953c97d5 --- /dev/null +++ b/catalogue/apps/io.pilot.cosift/metadata.json @@ -0,0 +1,53 @@ +{ + "schema_version": 1, + "id": "io.pilot.cosift", + "display_name": "Cosift", + "tagline": "Grounded web search, retrieval, and research for agents", + "description_md": "Cosift is the Pilot app-store front door for the cosift search/answer/research API. It gives an agent keyword + semantic search, document retrieval, and LLM-grounded answers and multi-step research over a crawled web corpus — returned as clean structured JSON.\n\nEvery method is discoverable at runtime via cosift.help, which reports each method's parameters and a latency class (fast / med / slow) so a caller can pick the cheapest method that fits.", + "vendor": { + "name": "Pilot Protocol", + "url": "https://pilotprotocol.network", + "contact": "apps@pilotprotocol.network", + "publisher_pubkey": "ed25519:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + }, + "homepage": "https://pilotprotocol.network", + "source_url": "https://github.com/pilot-protocol/cosift", + "license": "MIT", + "categories": ["search", "research", "retrieval"], + "keywords": ["search", "rag", "answer", "research", "web", "retrieval"], + "size": { + "bundle_bytes": 4774876, + "installed_bytes": 8641298 + }, + "compat": { + "min_pilot_version": "1.0.0", + "runtimes": ["go"] + }, + "methods": [ + { "name": "cosift.search", "summary": "Keyword + semantic search over the crawled corpus" }, + { "name": "cosift.find_similar", "summary": "Find documents similar to a given result" }, + { "name": "cosift.contents", "summary": "Fetch the full contents of a document" }, + { "name": "cosift.answer", "summary": "Single-shot grounded answer with citations" }, + { "name": "cosift.research", "summary": "Multi-step research report over the corpus" }, + { "name": "cosift.stats", "summary": "Corpus and index statistics" }, + { "name": "cosift.health", "summary": "Service health check" }, + { "name": "cosift.help", "summary": "Discovery: methods, params, and latency classes" } + ], + "changelog": [ + { + "version": "0.1.2", + "date": "2026-06-09", + "notes": [ + "cosift search/answer/research adapter with cosift.help discovery", + "Installable via the Pilot app store catalogue" + ] + } + ], + "links": [ + { "label": "Source", "url": "https://github.com/pilot-protocol/cosift" }, + { "label": "Website", "url": "https://pilotprotocol.network" } + ], + "reviews": null, + "published_at": "2026-06-09T23:01:53Z", + "updated_at": "2026-06-09T23:30:00Z" +} diff --git a/catalogue/apps/io.pilot.wallet/metadata.json b/catalogue/apps/io.pilot.wallet/metadata.json new file mode 100644 index 00000000..4c856834 --- /dev/null +++ b/catalogue/apps/io.pilot.wallet/metadata.json @@ -0,0 +1,65 @@ +{ + "schema_version": 1, + "id": "io.pilot.wallet", + "display_name": "Wallet", + "tagline": "On-overlay USDC payments across Base, Ethereum, and Polygon", + "description_md": "The Pilot reference wallet brings x402 + EIP-3009 USDC payments to the overlay, with spend caps the supervisor enforces. One secp256k1 address works across all three USDC mainnets (Base, Ethereum, Polygon); per-chain RPC is configurable via PILOT_EVM_RPC_.\n\nInstalling this app lets other apps and agents settle payments without leaving the network. Spend caps declared in the manifest are reviewed at install time and enforced on every signing operation — see `pilotctl appstore caps io.pilot.wallet`.", + "vendor": { + "name": "Pilot Protocol", + "url": "https://pilotprotocol.network", + "contact": "apps@pilotprotocol.network" + }, + "homepage": "https://pilotprotocol.network", + "source_url": "https://github.com/pilot-protocol/wallet", + "license": "AGPL-3.0-or-later", + "categories": ["payments", "crypto", "finance"], + "keywords": ["usdc", "x402", "eip-3009", "evm", "base", "ethereum", "polygon", "payments"], + "size": { + "bundle_bytes": 9110758 + }, + "compat": { + "min_pilot_version": "1.0.0", + "runtimes": ["go"] + }, + "changelog": [ + { + "version": "0.3.3", + "date": "2026-06-08", + "notes": [ + "Default --evm-chains expands to 8453,1,137 so supervised wallets get all three USDC mainnets out of the box", + "PILOT_EVM_CHAINS env overrides the default chain set" + ] + }, + { + "version": "0.3.2", + "date": "2026-06-08", + "notes": [ + "Multichain EVM: same secp256k1 address across Base, Ethereum, Polygon", + "New wallet.evm.chains method; all evm.* methods accept an optional chain_id", + "Per-chain RPC via PILOT_EVM_RPC_" + ] + }, + { + "version": "0.3.1", + "date": "2026-06-08", + "notes": [ + "Wired the wallet.evm.* methods into the binary (declared in v0.3.0 but unregistered)", + "Added --evm-identity, --evm-chain, --evm-rpc, --no-evm flags" + ] + }, + { + "version": "0.3.0", + "date": "2026-06-08", + "notes": [ + "Signed wallet app bundle: ed25519-signed manifest, sha256-pinned binary" + ] + } + ], + "links": [ + { "label": "Source", "url": "https://github.com/pilot-protocol/wallet" }, + { "label": "Website", "url": "https://pilotprotocol.network" } + ], + "reviews": null, + "published_at": "2026-06-08T08:00:26Z", + "updated_at": "2026-06-08T09:16:19Z" +} diff --git a/catalogue/catalogue.json b/catalogue/catalogue.json index 175dc505..6d25c695 100644 --- a/catalogue/catalogue.json +++ b/catalogue/catalogue.json @@ -1,20 +1,36 @@ { - "version": 1, - "updated_at": "2026-06-09T23:30:00Z", + "version": 2, + "updated_at": "2026-06-15T00:00:00Z", "apps": [ { "id": "io.pilot.wallet", "version": "0.3.3", - "description": "On-overlay USDC payments \u2014 multichain (Base + Ethereum + Polygon).", + "description": "On-overlay USDC payments — multichain (Base + Ethereum + Polygon).", "bundle_url": "https://github.com/TeoSlayer/pilotprotocol/releases/download/wallet-v0.3.3/io.pilot.wallet-0.3.3.tar.gz", - "bundle_sha256": "8d30b4331bc025c327dd2d8610362984cc9365843176e21b96a2637d8e18ff54" + "bundle_sha256": "8d30b4331bc025c327dd2d8610362984cc9365843176e21b96a2637d8e18ff54", + "display_name": "Wallet", + "vendor": "Pilot Protocol", + "categories": ["payments", "crypto", "finance"], + "bundle_size": 9110758, + "source_url": "https://github.com/pilot-protocol/wallet", + "license": "AGPL-3.0-or-later", + "metadata_url": "https://raw.githubusercontent.com/TeoSlayer/pilotprotocol/main/catalogue/apps/io.pilot.wallet/metadata.json", + "metadata_sha256": "b0ed7dee416144c39d8938f4dbeaac8946be3290a6c44473d898a5945eb6cadb" }, { "id": "io.pilot.cosift", "version": "0.1.2", "description": "cosift search / answer / research over the public web corpus (with cosift.help discovery).", "bundle_url": "https://github.com/pilot-protocol/catalog/releases/download/cosift-v0.1.2/io.pilot.cosift-0.1.2.tar.gz", - "bundle_sha256": "faf60deeaa9a29298b447f7f0276f1bd4523af86390cf65c677452dc9718d5a0" + "bundle_sha256": "faf60deeaa9a29298b447f7f0276f1bd4523af86390cf65c677452dc9718d5a0", + "display_name": "Cosift", + "vendor": "Pilot Protocol", + "categories": ["search", "research", "retrieval"], + "bundle_size": 4774876, + "source_url": "https://github.com/pilot-protocol/cosift", + "license": "MIT", + "metadata_url": "https://raw.githubusercontent.com/TeoSlayer/pilotprotocol/main/catalogue/apps/io.pilot.cosift/metadata.json", + "metadata_sha256": "2b13511562dc1cfe09a6f53928149509a96ef893ee783fd1e055ed379b08b640" } ] -} \ No newline at end of file +} diff --git a/cmd/pilotctl/appstore.go b/cmd/pilotctl/appstore.go index 7ebf8fe8..334d3738 100644 --- a/cmd/pilotctl/appstore.go +++ b/cmd/pilotctl/appstore.go @@ -55,6 +55,8 @@ func cmdAppStore(args []string) { cmdAppStoreCall(args[1:]) case "status": cmdAppStoreStatus(args[1:]) + case "view": + cmdAppStoreView(args[1:]) case "audit": cmdAppStoreAudit(args[1:]) case "uninstall": @@ -79,7 +81,7 @@ func cmdAppStore(args []string) { appStoreHelp() default: fatalHint("invalid_argument", - "available: list, status, audit, uninstall, verify, install, gen-key, sign, catalogue, restart, caps, actions, call", + "available: list, status, view, audit, uninstall, verify, install, gen-key, sign, catalogue, restart, caps, actions, call", "unknown appstore subcommand: %s", args[0]) } } @@ -94,6 +96,10 @@ const AppStoreHelpText = `pilotctl appstore — list installed apps and call the Usage: pilotctl appstore list list installed apps + their methods pilotctl appstore status deep-dive on one app's pinned state + pilotctl appstore view [--all-changelog] + detail page: description, vendor, changelog, + size, source, methods, permissions (works + whether or not the app is installed) pilotctl appstore audit [--tail N] [--event NAME] [--since DURATION] show the supervisor lifecycle log event names: supervise-start, supervise-stop, diff --git a/cmd/pilotctl/appstore_catalogue.go b/cmd/pilotctl/appstore_catalogue.go index aa871d39..93cd6c97 100644 --- a/cmd/pilotctl/appstore_catalogue.go +++ b/cmd/pilotctl/appstore_catalogue.go @@ -66,12 +66,34 @@ type catalogue struct { // catalogueEntry mirrors the JSON shape exactly. Adding a field here // means adding it to catalogue.json AND to catalogue/README.md. +// +// The first five fields are the v1 schema and are required. The +// remaining fields are v2 additions: all optional, all omitempty, so a +// v1 catalogue still decodes cleanly (the zero values render as +// "absent"). The teaser fields surface in `pilotctl appstore catalogue`; +// the metadata pin is consumed lazily by `pilotctl appstore view`. type catalogueEntry struct { ID string `json:"id"` Version string `json:"version"` Description string `json:"description"` BundleURL string `json:"bundle_url"` BundleSHA string `json:"bundle_sha256"` + + // --- v2 teaser fields (cheap, shown in the catalogue listing) --- + DisplayName string `json:"display_name,omitempty"` + Vendor string `json:"vendor,omitempty"` + Categories []string `json:"categories,omitempty"` + BundleSize int64 `json:"bundle_size,omitempty"` // bytes of the downloadable tarball + SourceURL string `json:"source_url,omitempty"` // OSS source, if any + License string `json:"license,omitempty"` // SPDX id + + // --- v2 detail-doc pin (consumed by `pilotctl appstore view`) --- + // MetadataURL points at the per-app metadata.json; MetadataSHA pins + // its bytes the same way BundleSHA pins the tarball. Empty MetadataURL + // means "no extended detail" — `view` falls back to teaser + local + // manifest. + MetadataURL string `json:"metadata_url,omitempty"` + MetadataSHA string `json:"metadata_sha256,omitempty"` } // catalogueURL returns the URL pilotctl should fetch the catalogue @@ -103,8 +125,12 @@ func loadCatalogue() (*catalogue, error) { if err := json.Unmarshal(data, &c); err != nil { return nil, fmt.Errorf("parse catalogue: %w", err) } - if c.Version != 1 { - return nil, fmt.Errorf("unsupported catalogue version %d (pilotctl understands version 1)", c.Version) + // v1 and v2 are both understood. v2 only adds optional fields, so a + // v2-aware pilotctl reads a v1 catalogue and an older pilotctl reads a + // v2 catalogue (ignoring the unknown fields) — the bump is backward + // AND forward compatible by construction. + if c.Version != 1 && c.Version != 2 { + return nil, fmt.Errorf("unsupported catalogue version %d (pilotctl understands versions 1 and 2)", c.Version) } return &c, nil } @@ -127,8 +153,34 @@ func cmdAppStoreCatalogue(_ []string) { fmt.Printf("Catalogue: %s (updated %s)\n\n", catalogueURL(), c.UpdatedAt) fmt.Println("Installable apps:") for _, e := range c.Apps { - fmt.Printf("\n %s v%s\n", e.ID, e.Version) + name := e.ID + if e.DisplayName != "" { + name = fmt.Sprintf("%s (%s)", e.DisplayName, e.ID) + } + fmt.Printf("\n %s v%s\n", name, e.Version) + // Teaser line: vendor · categories · license · size — only the + // parts a v2 entry actually carries. v1 entries skip it entirely. + var bits []string + if e.Vendor != "" { + bits = append(bits, e.Vendor) + } + if len(e.Categories) > 0 { + bits = append(bits, strings.Join(e.Categories, ", ")) + } + if e.License != "" { + bits = append(bits, e.License) + } + if e.BundleSize > 0 { + bits = append(bits, formatBytes(uint64(e.BundleSize))) + } + if len(bits) > 0 { + fmt.Printf(" %s\n", strings.Join(bits, " · ")) + } fmt.Printf(" %s\n", e.Description) + // Point at the new detail view when extended metadata is published. + if e.MetadataURL != "" { + fmt.Printf(" view: pilotctl appstore view %s\n", e.ID) + } fmt.Printf(" install: pilotctl appstore install %s\n", e.ID) } } diff --git a/cmd/pilotctl/appstore_metadata.go b/cmd/pilotctl/appstore_metadata.go new file mode 100644 index 00000000..40e85ce3 --- /dev/null +++ b/cmd/pilotctl/appstore_metadata.go @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// Per-app "store listing" detail document — the human-app-store surface +// (structured description, changelog, vendor, screenshots, size, source +// URL) that is deliberately kept OUT of the signed manifest. +// +// The manifest (app-store/pkg/manifest) is security-critical and +// re-verified on every supervisor rescan; bloating it with marketing +// metadata would expand the signed surface for no security benefit. +// Instead each catalogue entry points at a metadata.json via +// catalogueEntry.MetadataURL and pins its bytes with MetadataSHA — the +// exact same trust model the bundle tarball already gets. `pilotctl +// appstore view` fetches this lazily; the catalogue listing/install hot +// path never touches it. +// +// Every field is optional so a partial document still renders. Adding a +// field here means adding it to catalogue/apps//metadata.json AND to +// catalogue/README.md. + +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" +) + +// maxMetadataBytes bounds a hostile or runaway detail doc. Generous for +// text (a long description + full changelog is a few KiB) while keeping +// the fetch cheap and DoS-resistant, mirroring loadCatalogue's 1 MiB cap. +const maxMetadataBytes = 256 << 10 // 256 KiB + +// appMetadata is the parsed wire shape of metadata.json. SchemaVersion is +// independent of the catalogue's version so the detail format can evolve +// on its own cadence. +type appMetadata struct { + SchemaVersion int `json:"schema_version"` + ID string `json:"id"` + DisplayName string `json:"display_name,omitempty"` + Tagline string `json:"tagline,omitempty"` + DescriptionMD string `json:"description_md,omitempty"` + Vendor *mdVendor `json:"vendor,omitempty"` + Homepage string `json:"homepage,omitempty"` + SourceURL string `json:"source_url,omitempty"` + License string `json:"license,omitempty"` + Categories []string `json:"categories,omitempty"` + Keywords []string `json:"keywords,omitempty"` + IconURL string `json:"icon_url,omitempty"` + Screenshots []mdScreenshot `json:"screenshots,omitempty"` + Size *mdSize `json:"size,omitempty"` + Compat *mdCompat `json:"compat,omitempty"` + Methods []mdMethod `json:"methods,omitempty"` + Changelog []mdChangelog `json:"changelog,omitempty"` + Links []mdLink `json:"links,omitempty"` + + // Reviews is RESERVED for the future community-reviews service. It is + // parsed if present but pilotctl never writes it today — reviews are a + // separate, signed, dynamic service (see catalogue/README.md), not + // static git-served data. Kept here so `view` output and the JSON + // shape are stable when the service lands. + Reviews *mdReviews `json:"reviews,omitempty"` + + PublishedAt string `json:"published_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +type mdVendor struct { + Name string `json:"name,omitempty"` + URL string `json:"url,omitempty"` + Contact string `json:"contact,omitempty"` + PublisherPubkey string `json:"publisher_pubkey,omitempty"` // ties the listing to manifest.store.publisher +} + +type mdScreenshot struct { + URL string `json:"url"` + Caption string `json:"caption,omitempty"` +} + +type mdSize struct { + BundleBytes int64 `json:"bundle_bytes,omitempty"` // compressed download + InstalledBytes int64 `json:"installed_bytes,omitempty"` // approx unpacked footprint +} + +type mdCompat struct { + MinPilotVersion string `json:"min_pilot_version,omitempty"` + Runtimes []string `json:"runtimes,omitempty"` +} + +type mdMethod struct { + Name string `json:"name"` + Summary string `json:"summary,omitempty"` +} + +type mdChangelog struct { + Version string `json:"version"` + Date string `json:"date,omitempty"` + Notes []string `json:"notes,omitempty"` +} + +type mdLink struct { + Label string `json:"label"` + URL string `json:"url"` +} + +// mdReviews is the reserved aggregate shape for the future reviews +// service. Read-only snapshot; pilotctl does not compute or submit it yet. +type mdReviews struct { + Average float64 `json:"average,omitempty"` + Count int `json:"count,omitempty"` + Distribution []int `json:"distribution,omitempty"` // [1★..5★] counts +} + +// loadAppMetadata fetches the per-app detail doc named by the catalogue +// entry and, when the entry carries a pin, verifies the bytes against +// metadata_sha256 (the same CDN-substitution defence the bundle gets). +// +// An entry with no MetadataURL returns (nil, nil): "no extended detail", +// not an error — that is how a v1 entry, or a v2 entry that simply hasn't +// published a listing yet, degrades. A present-but-unpinned URL is +// fetched but logged as unverified by the caller; a present pin that +// mismatches is a hard error (someone served different bytes than the +// catalogue vouched for). +func loadAppMetadata(e catalogueEntry) (*appMetadata, error) { + if e.MetadataURL == "" { + return nil, nil + } + body, err := openURL(e.MetadataURL) + if err != nil { + return nil, fmt.Errorf("fetch metadata from %s: %w", e.MetadataURL, err) + } + defer body.Close() + data, err := io.ReadAll(io.LimitReader(body, maxMetadataBytes)) + if err != nil { + return nil, fmt.Errorf("read metadata body: %w", err) + } + if e.MetadataSHA != "" && e.MetadataSHA != "REPLACE_AT_RELEASE_TIME" { + sum := sha256.Sum256(data) + got := hex.EncodeToString(sum[:]) + if got != e.MetadataSHA { + return nil, fmt.Errorf("metadata sha256 mismatch for %s: want=%s got=%s — the host served different bytes than the catalogue pinned", e.ID, e.MetadataSHA, got) + } + } + var m appMetadata + if err := json.Unmarshal(data, &m); err != nil { + return nil, fmt.Errorf("parse metadata: %w", err) + } + // Cross-check the id so a mis-pinned or mis-uploaded doc can't masquerade + // as a different app's listing. An empty id in the doc is tolerated + // (the catalogue entry is authoritative). + if m.ID != "" && m.ID != e.ID { + return nil, fmt.Errorf("metadata id %q does not match catalogue id %q", m.ID, e.ID) + } + return &m, nil +} + +// metadataPinned reports whether the entry carries a usable sha pin, so +// callers can label the detail doc as verified vs unverified. +func metadataPinned(e catalogueEntry) bool { + return e.MetadataSHA != "" && e.MetadataSHA != "REPLACE_AT_RELEASE_TIME" +} diff --git a/cmd/pilotctl/appstore_view.go b/cmd/pilotctl/appstore_view.go new file mode 100644 index 00000000..7140b310 --- /dev/null +++ b/cmd/pilotctl/appstore_view.go @@ -0,0 +1,444 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// `pilotctl appstore view ` — the human-app-store detail page. +// +// Unlike `status` (which reads ONLY the local installed manifest) and +// `catalogue` (which reads ONLY the remote index), `view` merges three +// bands and labels their provenance: +// +// - catalogue index — teaser fields (publisher-attested, sha-anchored) +// - metadata.json — full listing: description, changelog, vendor… +// - local manifest — verified install facts: integrity, grants, methods +// +// It works whether or not the app is installed, and whether or not it is +// in the catalogue (a sideloaded app still renders from local facts). The +// two provenance bands are kept visually distinct so an agent never +// conflates marketing copy with verified integrity. + +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/pilot-protocol/app-store/pkg/manifest" +) + +// installedAppFacts is the verified, local-only band of `view` — derived +// from the pinned manifest and on-disk state, never from the catalogue. +type installedAppFacts struct { + Installed bool `json:"installed"` + AppVersion string `json:"app_version,omitempty"` + State string `json:"state,omitempty"` // ready | stopped | missing-binary | sha256-mismatch | corrupt-manifest + IntegrityOK bool `json:"integrity_ok"` + InstalledBytes int64 `json:"installed_bytes,omitempty"` + Protection string `json:"protection,omitempty"` + Methods []string `json:"methods,omitempty"` + Grants []string `json:"grants,omitempty"` + ManifestValid bool `json:"manifest_valid"` +} + +// gatherInstalledFacts reads the pinned manifest for appID and computes +// its on-disk state. Returns nil when the app is not installed — `view` +// treats that as "catalogue-only", not an error. This mirrors the +// integrity logic in cmdAppStoreStatus without disturbing it. +func gatherInstalledFacts(appID string) *installedAppFacts { + dir := filepath.Join(appStoreRoot(), appID) + raw, err := os.ReadFile(filepath.Join(dir, "manifest.json")) + if err != nil { + return nil // not installed + } + m, err := manifest.Parse(raw) + if err != nil { + return &installedAppFacts{Installed: true, State: "corrupt-manifest"} + } + f := &installedAppFacts{ + Installed: true, + AppVersion: m.AppVersion, + Protection: m.Protection, + Methods: append([]string(nil), m.Exposes...), + ManifestValid: len(m.Validate()) == 0, + } + for _, g := range m.Grants { + f.Grants = append(f.Grants, fmt.Sprintf("%s:%s", g.Cap, g.Target)) + } + binPath := filepath.Join(dir, m.Binary.Path) + state := "stopped" + if info, err := os.Stat(binPath); err != nil { + state = "missing-binary" + } else { + f.InstalledBytes = info.Size() + if sha256File(binPath) == m.Binary.SHA256 { + f.IntegrityOK = true + if _, err := os.Stat(filepath.Join(dir, "app.sock")); err == nil { + state = "ready" + } + } else { + state = "sha256-mismatch" + } + } + f.State = state + return f +} + +// appViewReport is the merged, flattened view — the `--json` shape and the +// source of truth for the human renderer. Detail (publisher) fields and +// the Install (verified) band stay separate so consumers can tell them +// apart. Sources records which bands actually contributed. +type appViewReport struct { + ID string `json:"id"` + DisplayName string `json:"display_name,omitempty"` + Version string `json:"version,omitempty"` + Tagline string `json:"tagline,omitempty"` + Description string `json:"description,omitempty"` + Vendor *mdVendor `json:"vendor,omitempty"` + Homepage string `json:"homepage,omitempty"` + SourceURL string `json:"source_url,omitempty"` + License string `json:"license,omitempty"` + Categories []string `json:"categories,omitempty"` + Keywords []string `json:"keywords,omitempty"` + Screenshots []mdScreenshot `json:"screenshots,omitempty"` + BundleBytes int64 `json:"bundle_bytes,omitempty"` + InstalledBytes int64 `json:"installed_bytes,omitempty"` + Methods []mdMethod `json:"methods,omitempty"` + Changelog []mdChangelog `json:"changelog,omitempty"` + Compat *mdCompat `json:"compat,omitempty"` + Links []mdLink `json:"links,omitempty"` + Reviews *mdReviews `json:"reviews"` // null until the reviews service lands + InCatalogue bool `json:"in_catalogue"` + DetailVerified bool `json:"detail_verified"` // metadata.json fetched AND sha-pinned + Install *installedAppFacts `json:"install,omitempty"` + PublishedAt string `json:"published_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + Sources []string `json:"sources"` +} + +func cmdAppStoreView(args []string) { + allChangelog := false + appID := "" + for _, a := range args { + switch a { + case "--all-changelog", "--all": + allChangelog = true + case "-h", "--help": + fmt.Fprintln(os.Stderr, "usage: pilotctl appstore view [--all-changelog]") + return + default: + if strings.HasPrefix(a, "-") { + fatalHint("invalid_argument", + "usage: pilotctl appstore view [--all-changelog]", + "unknown flag: %s", a) + } + if appID == "" { + appID = a + } + } + } + if appID == "" { + fatalHint("invalid_argument", + "usage: pilotctl appstore view [--all-changelog]", + "missing app id") + } + + // Local install facts first — these work offline and are the only band + // for a sideloaded app. + facts := gatherInstalledFacts(appID) + + // Catalogue entry + detail doc, best-effort. A failed catalogue lookup + // must not block `view` for an installed app. + var entry *catalogueEntry + var meta *appMetadata + if c, err := loadCatalogue(); err == nil { + for i := range c.Apps { + if c.Apps[i].ID == appID { + entry = &c.Apps[i] + break + } + } + if entry != nil { + if m, err := loadAppMetadata(*entry); err != nil { + fmt.Fprintf(os.Stderr, "warn: could not load detail metadata: %v\n", err) + } else { + meta = m + } + } + } else { + fmt.Fprintf(os.Stderr, "warn: catalogue lookup failed (%v); showing local facts only\n", err) + } + + if entry == nil && facts == nil { + fatalHint("invalid_argument", + "try `pilotctl appstore catalogue` (installable) or `pilotctl appstore list` (installed)", + "app %q not found in catalogue or install root", appID) + } + + report := buildAppViewReport(appID, entry, meta, facts) + if jsonOutput { + _ = json.NewEncoder(os.Stdout).Encode(report) + return + } + renderAppView(report, allChangelog) +} + +// buildAppViewReport merges the bands with a clear precedence: the detail +// doc (richest, publisher-authored) wins over the index teaser; verified +// install facts are layered on top and never overwrite publisher copy +// except for the on-disk installed size. +func buildAppViewReport(appID string, entry *catalogueEntry, meta *appMetadata, facts *installedAppFacts) appViewReport { + r := appViewReport{ID: appID} + var sources []string + + if entry != nil { + r.InCatalogue = true + sources = append(sources, "catalogue") + r.Version = entry.Version + r.DisplayName = entry.DisplayName + r.Description = entry.Description // one-line teaser, overridden by detail below + if entry.Vendor != "" { + r.Vendor = &mdVendor{Name: entry.Vendor} + } + r.Categories = entry.Categories + r.License = entry.License + r.SourceURL = entry.SourceURL + r.BundleBytes = entry.BundleSize + } + + if meta != nil { + sources = append(sources, "metadata") + if entry != nil { + r.DetailVerified = metadataPinned(*entry) + } + if meta.DisplayName != "" { + r.DisplayName = meta.DisplayName + } + r.Tagline = meta.Tagline + if meta.DescriptionMD != "" { + r.Description = meta.DescriptionMD + } + if meta.Vendor != nil { + r.Vendor = meta.Vendor + } + if meta.Homepage != "" { + r.Homepage = meta.Homepage + } + if meta.SourceURL != "" { + r.SourceURL = meta.SourceURL + } + if meta.License != "" { + r.License = meta.License + } + if len(meta.Categories) > 0 { + r.Categories = meta.Categories + } + r.Keywords = meta.Keywords + r.Screenshots = meta.Screenshots + r.Methods = meta.Methods + r.Changelog = meta.Changelog + r.Compat = meta.Compat + r.Links = meta.Links + r.Reviews = meta.Reviews + if meta.Size != nil { + if meta.Size.BundleBytes > 0 { + r.BundleBytes = meta.Size.BundleBytes + } + if meta.Size.InstalledBytes > 0 { + r.InstalledBytes = meta.Size.InstalledBytes + } + } + r.PublishedAt = meta.PublishedAt + r.UpdatedAt = meta.UpdatedAt + } + + if facts != nil { + sources = append(sources, "local-manifest") + r.Install = facts + if facts.InstalledBytes > 0 { + r.InstalledBytes = facts.InstalledBytes // real on-disk size wins + } + if r.Version == "" { + r.Version = facts.AppVersion + } + // Fall back to manifest-declared (verified) methods when the detail + // doc didn't enumerate them. + if len(r.Methods) == 0 { + for _, name := range facts.Methods { + r.Methods = append(r.Methods, mdMethod{Name: name}) + } + } + } + + r.Sources = sources + return r +} + +func renderAppView(r appViewReport, allChangelog bool) { + // Title line. + title := r.ID + if r.DisplayName != "" { + title = fmt.Sprintf("%s (%s)", r.DisplayName, r.ID) + } + if r.Version != "" { + title = fmt.Sprintf("%s v%s", title, r.Version) + } + fmt.Println(title) + + // Subtitle: vendor · categories · license. + var sub []string + if r.Vendor != nil && r.Vendor.Name != "" { + sub = append(sub, r.Vendor.Name) + } + if len(r.Categories) > 0 { + sub = append(sub, strings.Join(r.Categories, ", ")) + } + if r.License != "" { + sub = append(sub, r.License) + } + if len(sub) > 0 { + fmt.Println(strings.Join(sub, " · ")) + } + if r.Tagline != "" { + fmt.Println(r.Tagline) + } + fmt.Println() + + // Facts block (label width 14, matching `status`). + if r.Install != nil { + integ := "integrity OK" + if !r.Install.IntegrityOK { + integ = "integrity NOT verified" + } + fmt.Printf(" %-12s yes (%s, %s)\n", "installed:", r.Install.State, integ) + } else { + fmt.Printf(" %-12s no\n", "installed:") + } + if r.BundleBytes > 0 || r.InstalledBytes > 0 { + var parts []string + if r.BundleBytes > 0 { + parts = append(parts, "download "+formatBytes(uint64(r.BundleBytes))) + } + if r.InstalledBytes > 0 { + parts = append(parts, "installed "+formatBytes(uint64(r.InstalledBytes))) + } + fmt.Printf(" %-12s %s\n", "size:", strings.Join(parts, " ")) + } + if r.SourceURL != "" { + fmt.Printf(" %-12s %s\n", "source:", r.SourceURL) + } + if r.Homepage != "" { + fmt.Printf(" %-12s %s\n", "homepage:", r.Homepage) + } + if r.Vendor != nil && r.Vendor.Name != "" { + v := r.Vendor.Name + if r.Vendor.Contact != "" { + v = fmt.Sprintf("%s <%s>", v, r.Vendor.Contact) + } + fmt.Printf(" %-12s %s\n", "vendor:", v) + } + if r.Compat != nil && r.Compat.MinPilotVersion != "" { + fmt.Printf(" %-12s pilot >= %s\n", "requires:", r.Compat.MinPilotVersion) + } + + // Description. + if r.Description != "" { + fmt.Printf("\nDescription\n") + for _, ln := range strings.Split(strings.TrimRight(r.Description, "\n"), "\n") { + fmt.Printf(" %s\n", ln) + } + } + + // Methods. + if len(r.Methods) > 0 { + fmt.Printf("\nMethods (%d)\n", len(r.Methods)) + for _, m := range r.Methods { + if m.Summary != "" { + fmt.Printf(" %-24s %s\n", m.Name, m.Summary) + } else { + fmt.Printf(" %s\n", m.Name) + } + } + } + + // Changelog. + if len(r.Changelog) > 0 { + shown := r.Changelog + if allChangelog { + fmt.Printf("\nChangelog\n") + } else { + latest := shown[0] + hdr := "What's new" + if latest.Version != "" { + hdr = "What's new in " + latest.Version + } + if latest.Date != "" { + hdr = fmt.Sprintf("%s (%s)", hdr, latest.Date) + } + fmt.Printf("\n%s\n", hdr) + shown = shown[:1] + } + for _, c := range shown { + if allChangelog { + head := c.Version + if c.Date != "" { + head = fmt.Sprintf("%s (%s)", head, c.Date) + } + fmt.Printf(" %s\n", head) + } + for _, n := range c.Notes { + fmt.Printf(" • %s\n", n) + } + } + if !allChangelog && len(r.Changelog) > 1 { + fmt.Printf(" (use --all-changelog for full history)\n") + } + } + + // Permissions — from the verified manifest only. + if r.Install != nil && len(r.Install.Grants) > 0 { + fmt.Printf("\nPermissions (granted at install)\n") + for _, g := range r.Install.Grants { + fmt.Printf(" %s\n", g) + } + } + + // Links. + if len(r.Links) > 0 { + fmt.Printf("\nLinks\n") + for _, l := range r.Links { + fmt.Printf(" %-12s %s\n", l.Label, l.URL) + } + } + + // Reviews — reserved. + fmt.Printf("\nReviews: ") + if r.Reviews != nil && r.Reviews.Count > 0 { + fmt.Printf("%.1f from %d (★ distribution %v)\n", r.Reviews.Average, r.Reviews.Count, r.Reviews.Distribution) + } else { + fmt.Printf("n/a (community reviews not yet available)\n") + } + + // Provenance + next step. + detail := "" + if sliceHas(r.Sources, "metadata") { + if r.DetailVerified { + detail = " (detail sha-verified)" + } else { + detail = " (detail UNVERIFIED — no sha pin)" + } + } + fmt.Printf("\nSources: %s%s\n", strings.Join(r.Sources, ", "), detail) + if r.Install == nil && r.InCatalogue { + fmt.Printf("Install: pilotctl appstore install %s\n", r.ID) + } +} + +func sliceHas(ss []string, want string) bool { + for _, s := range ss { + if s == want { + return true + } + } + return false +} diff --git a/cmd/pilotctl/zz_appstore_view_test.go b/cmd/pilotctl/zz_appstore_view_test.go new file mode 100644 index 00000000..16928d71 --- /dev/null +++ b/cmd/pilotctl/zz_appstore_view_test.go @@ -0,0 +1,272 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// stageCatalogue writes a catalogue.json to a tempdir and points +// PILOT_APPSTORE_CATALOG_URL at it via file://. +func stageCatalogue(t *testing.T, catJSON string) { + t.Helper() + p := filepath.Join(t.TempDir(), "catalogue.json") + if err := os.WriteFile(p, []byte(catJSON), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("PILOT_APPSTORE_CATALOG_URL", "file://"+p) +} + +// stageMetadata writes a metadata.json to a tempdir and returns its +// file:// URL plus the sha256 a catalogue entry must pin. +func stageMetadata(t *testing.T, mdJSON string) (url, sha string) { + t.Helper() + p := filepath.Join(t.TempDir(), "metadata.json") + if err := os.WriteFile(p, []byte(mdJSON), 0o600); err != nil { + t.Fatal(err) + } + sum := sha256.Sum256([]byte(mdJSON)) + return "file://" + p, hex.EncodeToString(sum[:]) +} + +// installFake plants a sha-matching manifest + binary under root/id, the +// same layout the installer produces, so gatherInstalledFacts sees a +// "stopped, integrity OK" app. +func installFake(t *testing.T, root, id string) { + t.Helper() + appDir := filepath.Join(root, id) + if err := os.MkdirAll(filepath.Join(appDir, "bin"), 0o755); err != nil { + t.Fatal(err) + } + binPath := filepath.Join(appDir, "bin", "app") + if err := os.WriteFile(binPath, []byte("fake-binary"), 0o755); err != nil { + t.Fatal(err) + } + binSHA := sha256File(binPath) + if err := os.WriteFile(filepath.Join(appDir, "manifest.json"), validManifestJSON(id, binSHA), 0o600); err != nil { + t.Fatal(err) + } +} + +func TestLoadCatalogueVersions(t *testing.T) { + cases := []struct { + name string + version int + wantErr bool + }{ + {"v1 accepted", 1, false}, + {"v2 accepted", 2, false}, + {"v3 rejected", 3, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + stageCatalogue(t, `{"version":`+itoa(tc.version)+`,"updated_at":"x","apps":[]}`) + _, err := loadCatalogue() + if tc.wantErr != (err != nil) { + t.Fatalf("version %d: err=%v wantErr=%v", tc.version, err, tc.wantErr) + } + }) + } +} + +// TestLoadCatalogueV1BackwardCompat confirms a pure v1 entry (no v2 +// fields) still decodes and the new struct fields are simply empty. +func TestLoadCatalogueV1BackwardCompat(t *testing.T) { + stageCatalogue(t, `{"version":1,"updated_at":"x","apps":[ + {"id":"io.old.app","version":"0.1.0","description":"old","bundle_url":"https://x/y.tgz","bundle_sha256":"abc"}]}`) + c, err := loadCatalogue() + if err != nil { + t.Fatal(err) + } + e := c.Apps[0] + if e.ID != "io.old.app" || e.Description != "old" { + t.Fatalf("v1 core fields wrong: %+v", e) + } + if e.DisplayName != "" || e.MetadataURL != "" || len(e.Categories) != 0 || e.BundleSize != 0 { + t.Fatalf("v2 fields should be zero on a v1 entry: %+v", e) + } +} + +func TestLoadAppMetadata(t *testing.T) { + md := `{"schema_version":1,"id":"io.test.md","display_name":"MD","license":"MIT"}` + url, sha := stageMetadata(t, md) + + t.Run("pinned + correct sha parses", func(t *testing.T) { + m, err := loadAppMetadata(catalogueEntry{ID: "io.test.md", MetadataURL: url, MetadataSHA: sha}) + if err != nil { + t.Fatal(err) + } + if m == nil || m.DisplayName != "MD" || m.License != "MIT" { + t.Fatalf("bad parse: %+v", m) + } + }) + + t.Run("sha mismatch is a hard error", func(t *testing.T) { + _, err := loadAppMetadata(catalogueEntry{ID: "io.test.md", MetadataURL: url, MetadataSHA: "deadbeef"}) + if err == nil || !strings.Contains(err.Error(), "sha256 mismatch") { + t.Fatalf("want sha mismatch error, got %v", err) + } + }) + + t.Run("no url means no detail, not an error", func(t *testing.T) { + m, err := loadAppMetadata(catalogueEntry{ID: "io.test.md"}) + if err != nil || m != nil { + t.Fatalf("want (nil,nil), got (%v,%v)", m, err) + } + }) + + t.Run("id mismatch rejected", func(t *testing.T) { + _, err := loadAppMetadata(catalogueEntry{ID: "io.other", MetadataURL: url, MetadataSHA: sha}) + if err == nil || !strings.Contains(err.Error(), "does not match") { + t.Fatalf("want id-mismatch error, got %v", err) + } + }) +} + +// TestBuildAppViewReportMerge checks band precedence: detail doc overrides +// the index teaser, and real on-disk size + manifest methods layer on top. +func TestBuildAppViewReportMerge(t *testing.T) { + entry := &catalogueEntry{ + ID: "io.merge.app", Version: "2.0.0", Description: "teaser desc", + Vendor: "TeaserVendor", License: "TEASER", BundleSize: 999, + MetadataURL: "file:///x", MetadataSHA: "abc", + } + meta := &appMetadata{ + ID: "io.merge.app", DescriptionMD: "full description", + Vendor: &mdVendor{Name: "RealVendor"}, + License: "MIT", + Methods: []mdMethod{{Name: "a.one", Summary: "first"}}, + Size: &mdSize{InstalledBytes: 5000}, + } + facts := &installedAppFacts{ + Installed: true, AppVersion: "2.0.0", State: "ready", IntegrityOK: true, + InstalledBytes: 42, Methods: []string{"a.one", "a.two"}, + } + + r := buildAppViewReport("io.merge.app", entry, meta, facts) + if r.Description != "full description" { + t.Errorf("detail desc should override teaser, got %q", r.Description) + } + if r.Vendor == nil || r.Vendor.Name != "RealVendor" { + t.Errorf("detail vendor should override teaser, got %+v", r.Vendor) + } + if r.License != "MIT" { + t.Errorf("detail license should override teaser, got %q", r.License) + } + if r.InstalledBytes != 42 { + t.Errorf("real on-disk size should win, got %d", r.InstalledBytes) + } + if len(r.Methods) != 1 { // metadata methods present → no manifest fallback + t.Errorf("metadata methods should be kept, got %d", len(r.Methods)) + } + if !r.DetailVerified { + t.Error("DetailVerified should be true when entry is pinned") + } + wantSources := "catalogue,metadata,local-manifest" + if strings.Join(r.Sources, ",") != wantSources { + t.Errorf("sources = %v, want %s", r.Sources, wantSources) + } +} + +// TestBuildAppViewReportSideloaded: installed but absent from catalogue — +// methods fall back to the manifest's exposes. +func TestBuildAppViewReportSideloaded(t *testing.T) { + facts := &installedAppFacts{Installed: true, Methods: []string{"x.a", "x.b"}} + r := buildAppViewReport("io.side.app", nil, nil, facts) + if r.InCatalogue { + t.Error("InCatalogue should be false") + } + if len(r.Methods) != 2 || r.Methods[0].Name != "x.a" { + t.Errorf("methods should fall back to manifest exposes, got %+v", r.Methods) + } + if strings.Join(r.Sources, ",") != "local-manifest" { + t.Errorf("sources = %v", r.Sources) + } +} + +func TestGatherInstalledFacts(t *testing.T) { + root := t.TempDir() + t.Setenv("PILOT_APPSTORE_ROOT", root) + + if f := gatherInstalledFacts("io.absent.app"); f != nil { + t.Errorf("absent app should yield nil, got %+v", f) + } + + installFake(t, root, "io.present.app") + f := gatherInstalledFacts("io.present.app") + if f == nil || !f.Installed || !f.IntegrityOK || f.State != "stopped" { + t.Fatalf("present app facts wrong: %+v", f) + } +} + +// TestCmdAppStoreViewJSON drives the full command in JSON mode for a +// catalogue+metadata+installed app and asserts the merged report. +func TestCmdAppStoreViewJSON(t *testing.T) { + root := t.TempDir() + t.Setenv("PILOT_APPSTORE_ROOT", root) + installFake(t, root, "io.view.app") + + md := `{"schema_version":1,"id":"io.view.app","display_name":"Viewer",` + + `"description_md":"the full thing","license":"Apache-2.0",` + + `"changelog":[{"version":"1.0.0","date":"2026-06-01","notes":["first"]}]}` + url, sha := stageMetadata(t, md) + stageCatalogue(t, `{"version":2,"updated_at":"x","apps":[ + {"id":"io.view.app","version":"1.0.0","description":"teaser", + "bundle_url":"https://x/y.tgz","bundle_sha256":"abc", + "vendor":"Acme","bundle_size":1234, + "metadata_url":"`+url+`","metadata_sha256":"`+sha+`"}]}`) + + prev := jsonOutput + defer func() { jsonOutput = prev }() + jsonOutput = true + + out := captureStdout(t, func() { cmdAppStoreView([]string{"io.view.app"}) }) + var r appViewReport + if err := json.Unmarshal([]byte(out), &r); err != nil { + t.Fatalf("parse: %v\n%s", err, out) + } + if r.ID != "io.view.app" || r.DisplayName != "Viewer" { + t.Errorf("id/name wrong: %+v", r) + } + if r.Description != "the full thing" { + t.Errorf("description should come from metadata, got %q", r.Description) + } + if !r.InCatalogue || !r.DetailVerified { + t.Errorf("InCatalogue/DetailVerified should be true: %+v", r) + } + if r.Install == nil || !r.Install.IntegrityOK { + t.Errorf("install band missing or not verified: %+v", r.Install) + } + if len(r.Changelog) != 1 || r.Changelog[0].Version != "1.0.0" { + t.Errorf("changelog wrong: %+v", r.Changelog) + } + if !sliceHas(r.Sources, "catalogue") || !sliceHas(r.Sources, "metadata") || !sliceHas(r.Sources, "local-manifest") { + t.Errorf("sources incomplete: %v", r.Sources) + } +} + +// itoa avoids pulling strconv into the test for one tiny conversion. +func itoa(n int) string { + if n == 0 { + return "0" + } + neg := n < 0 + if neg { + n = -n + } + var b []byte + for n > 0 { + b = append([]byte{byte('0' + n%10)}, b...) + n /= 10 + } + if neg { + b = append([]byte{'-'}, b...) + } + return string(b) +} diff --git a/scripts/smoke-test-appstore.sh b/scripts/smoke-test-appstore.sh index 75f5389a..67b1a085 100755 --- a/scripts/smoke-test-appstore.sh +++ b/scripts/smoke-test-appstore.sh @@ -114,12 +114,31 @@ ok "manifest signed, binary sha pinned" # ── step 3: stage a catalogue pointing at the local bundle ──────────── -step 3 "stage a catalogue file pointing at the bundle tarball" +step 3 "stage a v2 catalogue + detail doc pointing at the bundle tarball" ( cd "$BUNDLE" && tar czf "$WORK/io.pilot.wallet-test.tar.gz" manifest.json bin/wallet ) BUNDLE_SHA="$(shasum -a 256 "$WORK/io.pilot.wallet-test.tar.gz" | awk '{print $1}')" +BUNDLE_SIZE="$(wc -c < "$WORK/io.pilot.wallet-test.tar.gz" | tr -d ' ')" +# Per-app detail doc consumed by `appstore view`; pinned by metadata_sha256. +mkdir -p "$WORK/apps/io.pilot.wallet" +cat > "$WORK/apps/io.pilot.wallet/metadata.json" < "$WORK/catalogue.json" < "$WORK/catalogue.json" < "$WORK/view.out" 2>&1 \ + || { cat "$WORK/view.out"; fail "view"; } +grep -q "Wallet (io.pilot.wallet)" "$WORK/view.out" || fail "view missing display name" +grep -q "installed:" "$WORK/view.out" && grep -q "yes" "$WORK/view.out" || fail "view didn't report installed" +grep -q "detail sha-verified" "$WORK/view.out" || fail "view didn't sha-verify the detail doc" +grep -q "smoke-test detail doc" "$WORK/view.out" || fail "view missing detail tagline" +ok "view merged catalogue + verified detail + local manifest" + # ── step 6: wait for the daemon to spawn the wallet ─────────────────── step 6 "daemon discovers + spawns the app"