diff --git a/docs/plugins/marketplace.md b/docs/plugins/marketplace.md index 6a88b4a8..7dcf2f36 100644 --- a/docs/plugins/marketplace.md +++ b/docs/plugins/marketplace.md @@ -87,3 +87,36 @@ func TestPluginManifest(t *testing.T) { ``` `kiosk.CheckT` runs the same check as `summer plugin:manifest --check` and names the command to rerun. A nested plugin gets its own test in its own directory. + +## Registry API v1 + +summercms.io publishes the plugins it lists through a read-only JSON API. The marketplace pages, `summer` and the admin's Plugins screen read it. Its base path is `/api/registry/v1` (`kiosk.RegistryAPIPath`), and the `kiosk` module holds its wire types, so every client and the server share one definition of the shape. + +| Request | Answer | +|---------|--------| +| `GET /api/registry/v1/plugins?q=&category=` | `{"data": [plugin, ...], "meta": {"total": N}}` (`kiosk.ListResponse`). `q` searches ID, name and description; `category` filters. | +| `GET /api/registry/v1/plugins/{id}` | `{"data": detail}` (`kiosk.DetailResponse`). | +| `GET /api/registry/v1/plugins/{id}/versions` | `{"data": [version, ...]}`, newest first (`kiosk.VersionsResponse`). | +| `POST /api/registry/v1/ingest` | Authenticated with an API token that has the `registry` scope; body `{"repository": ""}` (`kiosk.IngestRequest`). Answers 202 with `{"data": {"repository": "...", "queued": true}}` (`kiosk.IngestResponse`). | + +A plugin in a list (`kiosk.RegistryPlugin`) has these fields: + +| Field | Meaning | +|-------|---------| +| `id`, `name`, `description` | From the latest release's `plugin.yaml`. | +| `module`, `package` | What `go get` and `summer.yaml` take. | +| `repository` | The HTTPS clone URL. | +| `category`, `featured`, `icon_url` | Presentation fields edited on summercms.io. | +| `latest_version`, `latest_framework` | The newest tag and its framework minimum. | +| `updated_at` | When the listing last changed, RFC 3339. | + +The detail (`kiosk.RegistryPluginDetail`) adds `readme_markdown`, `readme_html`, `screenshots` (a list of `url` and `caption`) and `requires` of the latest release. A version (`kiosk.RegistryVersion`) has `version`, `framework`, `requires`, `module`, `package`, `commit` and `tagged_at`. + +Errors answer with `{"error": {"code": "...", "message": "..."}}` (`kiosk.ErrorResponse`): 404 with `not_found` for an unknown ID, 400 with `invalid_query` for a bad parameter. + +Compatibility rules: + +- Every answer is an envelope object (`data`, `meta` or `error`), never a bare array. +- Clients ignore fields they do not know, and v1 changes only by adding fields. A change that removes or renames a field needs a `/api/registry/v2` path. +- The GET endpoints need no credentials, allow any origin (CORS) and answer with `Cache-Control: public, max-age=300` and an ETag. +- Versions are strict `vX.Y.Z` tags (`kiosk.ValidVersion`) compared numerically (`kiosk.CompareVersions`). A client picks the release to install with `kiosk.NewestCompatible`: the newest one whose framework minimum the application's framework version satisfies (`kiosk.Satisfies`). diff --git a/modules/kiosk/README.md b/modules/kiosk/README.md index bc86ec97..fb87f5fb 100644 --- a/modules/kiosk/README.md +++ b/modules/kiosk/README.md @@ -19,6 +19,8 @@ WinterCMS keeps this data in a plugin's `Plugin.php` `pluginDetails()` and in `c - A schema version (`kiosk.SchemaVersion`) and a fixed header line (`kiosk.Header`); a manifest with a newer schema is refused with a hint to update summer. - Go-style minimums: `framework` is the `go.mod` require of `kiosk.FrameworkModule`, and each required plugin gets the version its module is required at. A requirement at `v0.0.0` is an error (not a released version); a pseudo-version is accepted with a warning. - Name and description overrides (`--name`, `--description`) stored in an `overrides:` block that regeneration keeps. +- The registry API v1 contract in Go: `kiosk.RegistryAPIPath` and the wire types (`kiosk.ListResponse`, `kiosk.DetailResponse`, `kiosk.VersionsResponse`, `kiosk.ErrorResponse`, `kiosk.IngestRequest`, `kiosk.IngestResponse`). The registry server encodes with them and the summer tool and the admin decode with them, so the JSON shape exists once. +- Strict version rules: `kiosk.ValidVersion` (only `vX.Y.Z`), `kiosk.CompareVersions`, `kiosk.BaseVersion` for `git describe` output, `kiosk.Satisfies` for Go-style minimums and `kiosk.NewestCompatible` to pick the release an application can install. - `kiosk.Check` regenerates in memory and returns a `kiosk.StaleError` when the committed file is missing or differs, which `summer plugin:manifest --check` turns into exit status 1, and `kiosk.CheckT` turns into a failing test in the plugin's own `go test`. ## Usage @@ -42,6 +44,20 @@ if err := kiosk.Check(ctx, "plugins/acme/blog"); err != nil { } ``` +Picking the newest release of a plugin that an application on framework v0.6.2 can install: + +```go +var versions kiosk.VersionsResponse +if err := json.Unmarshal(body, &versions); err != nil { + return err +} +v, ok := kiosk.NewestCompatible(versions.Data, "v0.6.2") +if !ok { + return errors.New("every release needs a newer framework") +} +fmt.Println("go get", v.Module+"@"+v.Version) +``` + A plugin repository keeps its manifest current with one test: ```go @@ -73,6 +89,28 @@ func TestPluginManifest(t *testing.T) { | `kiosk.TB` | The part of `testing.TB` that `kiosk.CheckT` calls (`Helper`, `Fatalf`); kiosk does not import `testing`. | | `kiosk.CheckT` | Fails the test with the `kiosk.Check` error and the command to rerun when the manifest is missing or stale. | | `kiosk.Validate` | Checks a manifest: schema, ID and required IDs, module and package paths (no leading `-`), name, description, and `vX.Y.Z` or pseudo-version minimums. `kiosk.Parse` and `kiosk.Generate` call it. | +| `kiosk.Requires.MarshalJSON` | Encodes the map as a JSON object, `{}` when empty, so the registry never sends `null` for requirements. | +| `kiosk.RegistryAPIPath` | `/api/registry/v1`, the base path of the registry API. | +| `kiosk.RegistryPlugin` | One listed plugin: `id`, `name`, `description`, `module`, `package`, `repository`, `category`, `featured`, `icon_url`, `latest_version`, `latest_framework`, `updated_at` (RFC 3339). | +| `kiosk.RegistryPluginDetail` | `kiosk.RegistryPlugin` plus `readme_markdown`, `readme_html`, `screenshots` and the `requires` of the latest version. | +| `kiosk.RegistryScreenshot` | One screenshot: `url`, `caption`. | +| `kiosk.RegistryVersion` | One tagged release: `version`, `framework`, `requires`, `module`, `package`, `commit`, `tagged_at`. | +| `kiosk.ListResponse` | `{"data": [...], "meta": {"total": N}}` for the plugin list. | +| `kiosk.ListMeta` | The `meta` object of a list: `total`. | +| `kiosk.DetailResponse` | `{"data": {...}}` for one plugin. | +| `kiosk.VersionsResponse` | `{"data": [...]}` for a plugin's releases, newest first. | +| `kiosk.ErrorResponse` | `{"error": {"code", "message"}}`, the body of every error. | +| `kiosk.RegistryError` | A stable `code` and a `message`. | +| `kiosk.RegistryErrorNotFound` | The `not_found` code of a 404. | +| `kiosk.RegistryErrorInvalidQuery` | The `invalid_query` code of a 400. | +| `kiosk.IngestRequest` | The body of the authenticated ingest request: `repository`. | +| `kiosk.IngestResponse` | The 202 answer to an ingest request: `{"data": {"repository", "queued"}}`. | +| `kiosk.IngestResult` | The `data` of an ingest answer. | +| `kiosk.ValidVersion` | Reports whether a version is a strict `vX.Y.Z` release (no pre-release, build metadata or leading zeros). | +| `kiosk.CompareVersions` | Compares two valid versions numerically per component: `v0.10.0` sorts after `v0.9.9`. | +| `kiosk.BaseVersion` | The `vX.Y.Z` tag a `git describe --tags` string starts from (`v0.3.1` for `v0.3.1-4-gabc1234`), or `""`. | +| `kiosk.Satisfies` | Whether a release meets a minimum; an empty minimum is always met, and a pseudo-version minimum is met by its base release and later. | +| `kiosk.NewestCompatible` | The newest release whose framework minimum a framework version satisfies. | | `kiosk.Identity` | The plugin ID and the required plugin IDs. | | `kiosk.ReadIdentity` | Reads what `ID()` and `Requires()` return from the sources of a directory, resolving constants across packages and modules without running code. Any other expression is an error naming the method. | | `kiosk.StaleError` | `Path`, `Missing`, and the generated (`Want`) and committed (`Got`) bytes; its message names the first differing line. | @@ -87,7 +125,7 @@ func TestPluginManifest(t *testing.T) { - SummerCMS modules: none. - Third-party: `github.com/goccy/go-yaml` (manifest YAML), `github.com/yuin/goldmark` (README headings and paragraphs). -- Standard library: `bytes`, `context`, `encoding/json`, `errors`, `fmt`, `go/ast`, `go/parser`, `go/token`, `go/types` (expression printing only), `os`, `os/exec`, `path/filepath`, `regexp`, `slices`, `sort`, `strconv`, `strings`. +- Standard library: `bytes`, `context`, `encoding/json`, `errors`, `fmt`, `go/ast`, `go/parser`, `go/token`, `go/types` (expression printing only), `os`, `os/exec`, `path/filepath`, `regexp`, `slices`, `sort`, `strconv`, `strings`, `time`. - Tools: the `go` command (`go mod edit -json`, `go list -m -json`, and `go mod download -json` for a required module not in the module cache), run without a shell and with workspaces off. ## Testing @@ -96,4 +134,4 @@ func TestPluginManifest(t *testing.T) { go test ./modules/kiosk/... ``` -`TestManifestSmoke` copies the fixture plugins in `testdata/` to a temporary directory and generates, checks and edits their manifests: a literal-ID plugin, a plugin whose identity is spread over constants of three packages and two modules, a nested plugin, the error cases, overrides, `kiosk.CheckT` and `kiosk.Validate`. Every fixture module replaces the framework with the stub in `testdata/framework`, so the tests run the local `go` command but need no network and no database. +`TestManifestSmoke` copies the fixture plugins in `testdata/` to a temporary directory and generates, checks and edits their manifests: a literal-ID plugin, a plugin whose identity is spread over constants of three packages and two modules, a nested plugin, the error cases, overrides, `kiosk.CheckT` and `kiosk.Validate`, the registry JSON (golden bytes, unknown fields ignored) and the version rules. Every fixture module replaces the framework with the stub in `testdata/framework`, so the tests run the local `go` command but need no network and no database. diff --git a/modules/kiosk/manifest.go b/modules/kiosk/manifest.go index 3d6d72b9..75f834be 100644 --- a/modules/kiosk/manifest.go +++ b/modules/kiosk/manifest.go @@ -94,10 +94,7 @@ func Parse(data []byte) (Manifest, error) { return m, nil } -var ( - pluginIDRe = regexp.MustCompile(`^[a-z][a-z0-9]*\.[a-z][a-z0-9]*$`) - releaseVersion = regexp.MustCompile(`^v\d+\.\d+\.\d+$`) -) +var pluginIDRe = regexp.MustCompile(`^[a-z][a-z0-9]*\.[a-z][a-z0-9]*$`) // Validate checks a manifest: the schema is between 1 and SchemaVersion, // the ID and every required ID are lowercase vendor.plugin, module and @@ -141,7 +138,7 @@ func Validate(m Manifest) error { } func releaseOrPseudo(v string) bool { - return releaseVersion.MatchString(v) || isPseudoVersion(v) + return ValidVersion(v) || isPseudoVersion(v) } // validPath accepts a slash-separated module or import path. diff --git a/modules/kiosk/manifest_smoke_test.go b/modules/kiosk/manifest_smoke_test.go index eb4703a6..ec4de1ab 100644 --- a/modules/kiosk/manifest_smoke_test.go +++ b/modules/kiosk/manifest_smoke_test.go @@ -2,6 +2,7 @@ package kiosk import ( "context" + "encoding/json" "errors" "fmt" "io/fs" @@ -9,6 +10,7 @@ import ( "path/filepath" "strings" "testing" + "time" ) // copyTestdata copies testdata/ into a fresh temp dir, keeping @@ -348,8 +350,140 @@ func (*Plugin) Requires() []string { return []string{dep} }`, "Requires()"}, t.Errorf("Parse of a newer schema: err = %v", err) } }) + + t.Run("registry", func(t *testing.T) { + at := time.Date(2026, 10, 29, 10, 0, 0, 0, time.UTC) + plugin := RegistryPlugin{ + ID: "acme.blog", Name: "Blog", Description: "A blog.", Module: "example.test/sm-blog-plugin", + Package: "example.test/sm-blog-plugin", Repository: "https://example.test/sm-blog-plugin.git", + Category: "content", Featured: true, IconURL: "https://example.test/icon.png", + LatestVersion: "v0.2.5", LatestFramework: "v0.6.0", UpdatedAt: at, + } + wantPlugin := `{"id":"acme.blog","name":"Blog","description":"A blog.","module":"example.test/sm-blog-plugin",` + + `"package":"example.test/sm-blog-plugin","repository":"https://example.test/sm-blog-plugin.git",` + + `"category":"content","featured":true,"icon_url":"https://example.test/icon.png",` + + `"latest_version":"v0.2.5","latest_framework":"v0.6.0","updated_at":"2026-10-29T10:00:00Z"}` + assertJSON(t, ListResponse{Data: []RegistryPlugin{plugin}, Meta: ListMeta{Total: 1}}, + `{"data":[`+wantPlugin+`],"meta":{"total":1}}`) + + detail := RegistryPluginDetail{ + RegistryPlugin: plugin, ReadmeMarkdown: "# Blog", ReadmeHTML: "

Blog

", + Screenshots: []RegistryScreenshot{{URL: "https://example.test/1.png", Caption: "List"}}, + Requires: Requires{"acme.translate": "v0.1.0"}, + } + assertJSON(t, DetailResponse{Data: detail}, `{"data":`+wantPlugin[:len(wantPlugin)-1]+ + `,"readme_markdown":"# Blog","readme_html":"\u003ch1\u003eBlog\u003c/h1\u003e",`+ + `"screenshots":[{"url":"https://example.test/1.png","caption":"List"}],"requires":{"acme.translate":"v0.1.0"}}}`) + + version := RegistryVersion{Version: "v0.2.5", Framework: "v0.6.0", Requires: Requires{"acme.translate": "v0.1.0"}, + Module: "example.test/sm-blog-plugin", Package: "example.test/sm-blog-plugin", Commit: "abc123", TaggedAt: at} + assertJSON(t, VersionsResponse{Data: []RegistryVersion{version}}, + `{"data":[{"version":"v0.2.5","framework":"v0.6.0","requires":{"acme.translate":"v0.1.0"},`+ + `"module":"example.test/sm-blog-plugin","package":"example.test/sm-blog-plugin","commit":"abc123","tagged_at":"2026-10-29T10:00:00Z"}]}`) + assertJSON(t, RegistryVersion{Version: "v0.1.0", TaggedAt: at}, + `{"version":"v0.1.0","framework":"","requires":{},"module":"","package":"","commit":"","tagged_at":"2026-10-29T10:00:00Z"}`) + assertJSON(t, ErrorResponse{Error: RegistryError{Code: RegistryErrorNotFound, Message: "no plugin acme.x"}}, + `{"error":{"code":"not_found","message":"no plugin acme.x"}}`) + assertJSON(t, IngestRequest{Repository: "https://example.test/sm-blog-plugin.git"}, + `{"repository":"https://example.test/sm-blog-plugin.git"}`) + assertJSON(t, IngestResponse{Data: IngestResult{Repository: "https://example.test/sm-blog-plugin.git", Queued: true}}, + `{"data":{"repository":"https://example.test/sm-blog-plugin.git","queued":true}}`) + if RegistryAPIPath != "/api/registry/v1" || RegistryErrorInvalidQuery != "invalid_query" { + t.Errorf("RegistryAPIPath = %q, RegistryErrorInvalidQuery = %q", RegistryAPIPath, RegistryErrorInvalidQuery) + } + + // Clients ignore fields a later v1 server adds. + var got DetailResponse + extra := `{"data":{"id":"acme.blog","latest_version":"v0.2.5","downloads":12,"requires":{"acme.x":""},"new":{"a":[1]}},"links":{}}` + if err := json.Unmarshal([]byte(extra), &got); err != nil { + t.Fatalf("unknown fields: %v", err) + } + if got.Data.ID != "acme.blog" || got.Data.LatestVersion != "v0.2.5" || len(got.Data.Requires) != 1 { + t.Errorf("decoded = %+v", got.Data) + } + }) + + t.Run("versions", func(t *testing.T) { + for _, v := range []string{"v0.6.0", "v10.2.33", "v0.0.0", "v1.0.0"} { + if !ValidVersion(v) { + t.Errorf("ValidVersion(%q) = false", v) + } + } + for _, v := range []string{"0.6.0", "v0.6", "v0.6.0-rc1", "v0.6.0+meta", "-v0.6.0", "", "v01.2.3", "v0.3.1-4-gabc1234"} { + if ValidVersion(v) { + t.Errorf("ValidVersion(%q) = true", v) + } + } + cmp := []struct { + a, b string + want int + }{ + {"v0.10.0", "v0.9.9", 1}, {"v0.9.9", "v0.10.0", -1}, {"v1.2.3", "v1.2.3", 0}, + {"v2.0.0", "v1.99.99", 1}, {"v0.5.3", "v0.6.0", -1}, {"v0.0.10", "v0.0.9", 1}, + } + for _, c := range cmp { + if got := CompareVersions(c.a, c.b); got != c.want { + t.Errorf("CompareVersions(%s, %s) = %d, want %d", c.a, c.b, got, c.want) + } + } + for in, want := range map[string]string{ + "v0.3.1-4-gabc1234": "v0.3.1", "v0.3.1": "v0.3.1", "abc1234": "", "": "", + "v0.3.1-rc1": "", "v0.3": "", + } { + if got := BaseVersion(in); got != want { + t.Errorf("BaseVersion(%q) = %q, want %q", in, got, want) + } + } + pseudo := "v0.5.4-0.20261029101500-abcdefabcdef" + sat := []struct { + have, min string + want bool + }{ + {"v0.6.0", "", true}, {"", "", true}, {"v0.6.0", "v0.6.0", true}, {"v0.7.1", "v0.6.0", true}, + {"v0.5.3", "v0.6.0", false}, {"", "v0.6.0", false}, {"v0.3.1-4-gabc1234", "v0.3.0", false}, + {"v0.5.4", pseudo, true}, {"v0.6.0", pseudo, true}, {"v0.5.3", pseudo, false}, + {"v0.6.0", "latest", false}, + } + for _, c := range sat { + if got := Satisfies(c.have, c.min); got != c.want { + t.Errorf("Satisfies(%q, %q) = %v, want %v", c.have, c.min, got, c.want) + } + } + versions := []RegistryVersion{ + {Version: "v0.3.0", Framework: "v0.7.0"}, + {Version: "v0.2.5", Framework: "v0.6.0"}, + {Version: "v0.10.0", Framework: "v0.8.0"}, + {Version: "v0.2.4", Framework: "v0.5.0"}, + {Version: "v0.2.6-rc1", Framework: "v0.5.0"}, + } + if got, ok := NewestCompatible(versions, "v0.6.2"); !ok || got.Version != "v0.2.5" { + t.Errorf("NewestCompatible(v0.6.2) = %+v, %v", got, ok) + } + if got, ok := NewestCompatible(versions, "v0.9.0"); !ok || got.Version != "v0.10.0" { + t.Errorf("NewestCompatible(v0.9.0) = %+v, %v", got, ok) + } + if _, ok := NewestCompatible(versions, "v0.4.0"); ok { + t.Error("NewestCompatible(v0.4.0) found a version") + } + if _, ok := NewestCompatible(nil, "v1.0.0"); ok { + t.Error("NewestCompatible(nil) found a version") + } + }) } +// assertJSON marshals v and compares it with want byte for byte. +func assertJSON(t *testing.T, v any, want string) { + t.Helper() + got, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal %T: %v", v, err) + } + if string(got) != want { + t.Errorf("%T JSON\n got %s\nwant %s", v, got, want) + } +} + + // fakeTB records CheckT's calls. type fakeTB struct { helpers int diff --git a/modules/kiosk/registry.go b/modules/kiosk/registry.go new file mode 100644 index 00000000..f0dde3bd --- /dev/null +++ b/modules/kiosk/registry.go @@ -0,0 +1,133 @@ +package kiosk + +import ( + "encoding/json" + "time" +) + +// The registry API v1 contract. The summercms.io registry encodes its +// responses with these types and the summer tool and the admin decode +// them, so no other copy of the shape exists. Rules: every response is an +// envelope object (data, meta or error); clients ignore unknown fields; +// changes within v1 are additive only. The API is GET-only for clients, +// needs no credentials and answers with open CORS and +// Cache-Control: public, max-age=300 plus an ETag. + +// RegistryAPIPath is the base path of the registry API v1: +// +// GET {base}/plugins?q=&category= ListResponse +// GET {base}/plugins/{id} DetailResponse +// GET {base}/plugins/{id}/versions VersionsResponse, newest first +// POST {base}/ingest IngestRequest -> 202 IngestResponse (authenticated) +// +// Errors answer with an ErrorResponse: 404 not_found, 400 invalid_query. +const RegistryAPIPath = "/api/registry/v1" + +// Error codes of a RegistryError. +const ( + // RegistryErrorNotFound is the code of a 404: no such plugin. + RegistryErrorNotFound = "not_found" + // RegistryErrorInvalidQuery is the code of a 400: a bad query parameter. + RegistryErrorInvalidQuery = "invalid_query" +) + +// RegistryPlugin is one plugin in the registry list. +type RegistryPlugin struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Module string `json:"module"` + Package string `json:"package"` + Repository string `json:"repository"` + Category string `json:"category"` + Featured bool `json:"featured"` + IconURL string `json:"icon_url"` + LatestVersion string `json:"latest_version"` + LatestFramework string `json:"latest_framework"` + UpdatedAt time.Time `json:"updated_at"` +} + +// RegistryPluginDetail is one plugin with its README, screenshots and the +// requirements of its latest version. +type RegistryPluginDetail struct { + RegistryPlugin + ReadmeMarkdown string `json:"readme_markdown"` + ReadmeHTML string `json:"readme_html"` + Screenshots []RegistryScreenshot `json:"screenshots"` + Requires Requires `json:"requires"` +} + +// RegistryScreenshot is one screenshot of a plugin listing. +type RegistryScreenshot struct { + URL string `json:"url"` + Caption string `json:"caption"` +} + +// RegistryVersion is one tagged release of a plugin, with the minimums +// its plugin.yaml declared at that tag. +type RegistryVersion struct { + Version string `json:"version"` + Framework string `json:"framework"` + Requires Requires `json:"requires"` + Module string `json:"module"` + Package string `json:"package"` + Commit string `json:"commit"` + TaggedAt time.Time `json:"tagged_at"` +} + +// ListResponse answers GET {base}/plugins. +type ListResponse struct { + Data []RegistryPlugin `json:"data"` + Meta ListMeta `json:"meta"` +} + +// ListMeta carries the number of plugins that matched. +type ListMeta struct { + Total int `json:"total"` +} + +// DetailResponse answers GET {base}/plugins/{id}. +type DetailResponse struct { + Data RegistryPluginDetail `json:"data"` +} + +// VersionsResponse answers GET {base}/plugins/{id}/versions, newest first. +type VersionsResponse struct { + Data []RegistryVersion `json:"data"` +} + +// ErrorResponse is the body of every registry error. +type ErrorResponse struct { + Error RegistryError `json:"error"` +} + +// RegistryError is a stable code and a human-readable message. +type RegistryError struct { + Code string `json:"code"` + Message string `json:"message"` +} + +// IngestRequest is the body of POST {base}/ingest. +type IngestRequest struct { + Repository string `json:"repository"` +} + +// IngestResponse answers an accepted ingest request (202). +type IngestResponse struct { + Data IngestResult `json:"data"` +} + +// IngestResult names the repository queued for ingestion. +type IngestResult struct { + Repository string `json:"repository"` + Queued bool `json:"queued"` +} + +// MarshalJSON encodes the map as an object, {} when it is empty, so the +// registry never sends null for requires. +func (r Requires) MarshalJSON() ([]byte, error) { + if r == nil { + return []byte("{}"), nil + } + return json.Marshal(map[string]string(r)) +} diff --git a/modules/kiosk/version.go b/modules/kiosk/version.go new file mode 100644 index 00000000..3eff68e4 --- /dev/null +++ b/modules/kiosk/version.go @@ -0,0 +1,105 @@ +package kiosk + +import ( + "regexp" + "strings" +) + +var ( + validVersionRe = regexp.MustCompile(`^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$`) + describeRe = regexp.MustCompile(`^(v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*))(?:-[0-9]+-g[0-9a-f]+)?$`) + pseudoBaseRe = regexp.MustCompile(`^(v[0-9]+\.[0-9]+\.[0-9]+)-`) +) + +// ValidVersion reports whether v is a release version in the strict form +// vX.Y.Z: no pre-release, no build metadata, no leading zeros. +func ValidVersion(v string) bool { + return validVersionRe.MatchString(v) +} + +// CompareVersions compares two valid versions numerically per component +// and returns -1, 0 or +1. It is only defined for versions ValidVersion +// accepts; never pass a git describe string (use BaseVersion first). +func CompareVersions(a, b string) int { + pa := strings.Split(strings.TrimPrefix(a, "v"), ".") + pb := strings.Split(strings.TrimPrefix(b, "v"), ".") + for i := 0; i < len(pa) && i < len(pb); i++ { + if c := compareNumeric(pa[i], pb[i]); c != 0 { + return c + } + } + switch { + case len(pa) < len(pb): + return -1 + case len(pa) > len(pb): + return 1 + } + return 0 +} + +// compareNumeric compares two decimal strings without leading zeros, of +// any length. +func compareNumeric(a, b string) int { + switch { + case len(a) != len(b): + if len(a) < len(b) { + return -1 + } + return 1 + case a < b: + return -1 + case a > b: + return 1 + } + return 0 +} + +// BaseVersion returns the vX.Y.Z tag a `git describe --tags` string starts +// from: "v0.3.1" for "v0.3.1" and for "v0.3.1-4-gabc1234". Anything else +// (a bare commit hash, an empty string, a pre-release tag) gives "". +func BaseVersion(describe string) string { + m := describeRe.FindStringSubmatch(describe) + if m == nil { + return "" + } + return m[1] +} + +// Satisfies reports whether the release have meets the minimum min. An +// empty minimum is always met. A pseudo-version minimum such as +// vX.Y.Z-0.- sorts just below vX.Y.Z, so it is met by +// vX.Y.Z and later. have must be a valid release; anything else meets +// only the empty minimum. +func Satisfies(have, min string) bool { + if min == "" { + return true + } + if !ValidVersion(have) { + return false + } + if ValidVersion(min) { + return CompareVersions(have, min) >= 0 + } + if isPseudoVersion(min) { + base := pseudoBaseRe.FindStringSubmatch(min) + return base != nil && ValidVersion(base[1]) && CompareVersions(have, base[1]) >= 0 + } + return false +} + +// NewestCompatible returns the newest release in versions whose framework +// minimum the given framework version satisfies, and false when there is +// none. Entries whose Version is not a valid release are skipped. +func NewestCompatible(versions []RegistryVersion, framework string) (RegistryVersion, bool) { + var best RegistryVersion + found := false + for _, v := range versions { + if !ValidVersion(v.Version) || !Satisfies(framework, v.Framework) { + continue + } + if !found || CompareVersions(v.Version, best.Version) > 0 { + best, found = v, true + } + } + return best, found +}