diff --git a/cmd/summer/main.go b/cmd/summer/main.go index f2939a5a..8d392dce 100644 --- a/cmd/summer/main.go +++ b/cmd/summer/main.go @@ -14,6 +14,7 @@ import ( "git.golem15.com/golem15/summercms/internal/build" "git.golem15.com/golem15/summercms/internal/dev" "git.golem15.com/golem15/summercms/modules/bonfire" + "git.golem15.com/golem15/summercms/modules/compass" "git.golem15.com/golem15/summercms/modules/diary" "git.golem15.com/golem15/summercms/modules/kiosk" "git.golem15.com/golem15/summercms/modules/lagoon" @@ -42,6 +43,7 @@ func toolCommands() []bonfire.Command { makeAdminControllerCommand(), addPluginCommand(), pluginManifestCommand(), + pluginInstallCommand(), newCommand(), devCommand(), parityProxyCommand(), @@ -508,6 +510,106 @@ plugin.yaml is missing or differs from what the command would write.`, } } +// registryFor opens the application's config and returns the registry +// client plugins.registry_url names, or nil when the setting is empty or +// the run is --offline. +func registryFor(appDir string, offline bool) (build.Registry, error) { + if offline { + return nil, nil + } + cfg, err := compass.Open(compass.Options{Dir: filepath.Join(appDir, "config")}) + if err != nil { + return nil, err + } + base := kiosk.RegistryURL(cfg) + if base == "" { + return nil, nil + } + client, err := kiosk.NewClient(base, cfg) + if err != nil { + return nil, err + } + return client, nil +} + +func pluginInstallCommand() bonfire.Command { + return bonfire.Command{ + Name: "plugin:install", + Description: "Install a plugin from the registry or a git repository", + Long: `Adds a plugin the application does not have yet. The argument is a plugin ID +(vendor.name), an ID pinned to a release (vendor.name@vX.Y.Z), or a git URL +(https://, ssh:// or user@host:path). + +For an ID, the registry (plugins.registry_url in the application's config, +https://summercms.io by default, SUMMER_PLUGINS__REGISTRY_URL overrides it) +lists the plugin's releases. The newest release whose framework minimum the +application's framework version meets is chosen; a pinned release must meet it +too. When only releases needing a newer framework exist, the command fails and +names the framework version to move to; it never changes the framework. The +plugin is installed as a Go module with go get and listed in summer.yaml. +--source installs it as a git submodule under plugins// instead. + +The plugins to add are printed as id@version (mode) and confirmed with a +prompt. --yes skips the prompt; a run without a terminal needs --yes. Run +summer build afterwards. --offline makes no registry call, so it works only +with a git URL. An empty plugins.registry_url disables registry calls.`, + Examples: []bonfire.Example{ + {Command: "plugin:install acme.blog", Description: "Install the newest compatible release of acme.blog as a Go module"}, + {Command: "plugin:install acme.blog@v0.3.0 --yes", Description: "Install release v0.3.0 without asking"}, + {Command: "plugin:install acme.blog --source", Description: "Install it as a git submodule under plugins/acme/blog"}, + {Command: "plugin:install git@git.example.com:acme/sm-blog-plugin.git", Description: "Install a private plugin from its repository, as a submodule"}, + }, + Args: []bonfire.Arg{{ + Name: "plugin", + Description: "vendor.name, vendor.name@vX.Y.Z or a git URL", + Required: true, + }}, + Flags: []bonfire.Flag{ + {Name: "source", Description: "Install registry plugins as git submodules under plugins//", Bare: true}, + {Name: "yes", Description: "Install without asking for confirmation", Bare: true}, + {Name: "offline", Description: "Make no registry call; dependencies must be installed already", Bare: true}, + }, + Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { + arg, ok := in.Argument("plugin") + if !ok || arg == "" { + return fmt.Errorf("plugin:install requires a plugin ID or a git URL") + } + dir, err := os.Getwd() + if err != nil { + return err + } + appDir, err := build.FindAppDir(dir) + if err != nil { + return err + } + offline := bonfire.FlagBool(in, "offline") + reg, err := registryFor(appDir, offline) + if err != nil { + return err + } + plan, err := build.PlanInstall(ctx, build.InstallOptions{ + StartDir: dir, + Arg: arg, + Source: bonfire.FlagBool(in, "source"), + Yes: bonfire.FlagBool(in, "yes"), + Offline: offline, + Registry: reg, + Interactive: bonfire.Interactive(out), + Confirm: func(q string) (bool, error) { return out.Confirm(q, false) }, + Out: out, + }) + if errors.Is(err, build.ErrCancelled) { + out.Println("Nothing installed.") + return nil + } + if err != nil { + return err + } + return build.ApplyInstall(ctx, plan, out) + }, + } +} + func newCommand() bonfire.Command { return bonfire.Command{ Name: "new", diff --git a/docs/console/scaffolding.md b/docs/console/scaffolding.md index 65b7a584..16a55642 100644 --- a/docs/console/scaffolding.md +++ b/docs/console/scaffolding.md @@ -109,6 +109,7 @@ summer plugin:add plugins/acme/blog |---------|---------| | `summer plugin:add` | Registers a local plugin directory: `summer.yaml`, a `require` and a local `replace` in `go.mod`, and `go.work`. | | `summer plugin:manifest` | Writes the plugin's `plugin.yaml` from its sources (`plugin.go`, `go.mod`, the README). `--pretend` prints it, `--check` exits 1 when the committed file is missing or stale, `--name` and `--description` override the README text. See [Publishing plugins](../plugins/marketplace.md). | +| `summer plugin:install` | Installs a plugin by ID (`acme.blog`, or pinned `acme.blog@v0.3.0`) from the registry as a Go module: the newest release the application's framework version meets, after printing it and asking (`--yes` skips the question). See [Installing plugins](../plugins/marketplace.md#installing-plugins). | ## Generating plugin parts diff --git a/docs/plugins/marketplace.md b/docs/plugins/marketplace.md index 9ded688f..c34e5a3c 100644 --- a/docs/plugins/marketplace.md +++ b/docs/plugins/marketplace.md @@ -1,6 +1,6 @@ --- title: Publishing plugins -description: Describe a plugin for the marketplace with a generated plugin.yaml, keep it current with summer plugin:manifest --check, and read the registry API. +description: Describe a plugin with a generated plugin.yaml, install listed plugins with summer plugin:install, and read the registry API. section: plugins order: 50 --- @@ -92,6 +92,35 @@ func TestPluginManifest(t *testing.T) { `summer build` reads the `plugin.yaml` of every plugin the application lists and bakes the name and description into the generated `plugins.gen.go` as `PluginInfo`, next to each plugin's version and the framework version (`FrameworkVersion`). At start-up the application publishes them as `party.Installed`, which the admin's Plugins screen reads. [Go modules and workspaces](../architecture/go-modules-and-workspaces.md#the-summeryaml-manifest) explains where the versions come from. +## Installing plugins + +`summer plugin:install` adds a listed plugin to an application. Run it inside the application: + +```sh +summer plugin:install acme.blog +summer plugin:install acme.blog@v0.3.0 +summer plugin:install acme.blog --yes +``` + +The command asks the registry for the plugin's releases and picks the newest one whose framework minimum the application meets. The application's framework version is the effective one: the newest tag of a local framework checkout when `go.mod` or `go.work` replaces the framework with a directory, otherwise the version `go.mod` requires. `@vX.Y.Z` pins a release, which must meet the framework minimum too. When every release needs a newer framework, the command stops and names the framework version to move to; it never changes the framework requirement itself. + +The plugin is installed as a Go module: one `go get @` in the application, then an entry with its ID and package in `summer.yaml`. The edit appends to the `plugins:` list and keeps the file's comments and blank lines. Run `summer build` afterwards: it generates the import, and the new require in `go.mod` loses its `// indirect` marker the next time you run `go mod tidy`. A plugin that is already in `summer.yaml` is not installed again. + +Before changing anything, the command prints each plugin it is about to add as `id@version (module)` and asks for confirmation. `--yes` skips the question. A run that cannot ask (no terminal, `-n` or `-q`) stops with an error unless `--yes` is given. + +### The registry setting + +The registry is `https://summercms.io` unless the application's `plugins.registry_url` setting names another one: + +```yaml +# config/plugins.yaml +registry_url: https://registry.example.com +``` + +The environment variable `SUMMER_PLUGINS__REGISTRY_URL` overrides the file. The value must be the final origin, because the client does not follow redirects. An empty value disables registry calls: installs by ID then fail, and the admin's Plugins screen shows only installed plugins. `--offline` disables registry calls for one run. + +Every registry call goes through `kiosk.Client`, which uses `fetchguard` with a 10 second timeout and a 4 MiB limit on the answer. Module paths, package paths and versions from the registry are checked (a plain module path, a strict `vX.Y.Z` version) before any `go` command runs. + ## 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. diff --git a/internal/build/fixtures_test.go b/internal/build/fixtures_test.go new file mode 100644 index 00000000..71e81e2a --- /dev/null +++ b/internal/build/fixtures_test.go @@ -0,0 +1,266 @@ +package build + +import ( + "archive/zip" + "encoding/json" + "fmt" + "io/fs" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "sync" + "testing" + "time" + + "git.golem15.com/golem15/summercms/modules/kiosk" +) + +// writeProxyModule adds modPath@version to the file:// GOPROXY tree at +// proxyDir: @v/list, .info, .mod (files["go.mod"]) and .zip, whose +// entries are prefixed @/ as the module zip format +// requires. Module paths in fixtures are lower case, so no escaping is +// needed. +func writeProxyModule(t *testing.T, proxyDir, modPath, version string, files map[string]string) { + t.Helper() + if strings.ToLower(modPath) != modPath { + t.Fatalf("fixture module path %q must be lower case", modPath) + } + gomod, ok := files["go.mod"] + if !ok { + t.Fatalf("fixture %s@%s has no go.mod", modPath, version) + } + dir := filepath.Join(proxyDir, filepath.FromSlash(modPath), "@v") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + list, _ := os.ReadFile(filepath.Join(dir, "list")) + if !strings.Contains("\n"+string(list), "\n"+version+"\n") { + list = append(list, []byte(version+"\n")...) + } + write(t, filepath.Join(dir, "list"), string(list)) + info, _ := json.Marshal(map[string]string{"Version": version, "Time": "2026-10-01T00:00:00Z"}) + write(t, filepath.Join(dir, version+".info"), string(info)) + write(t, filepath.Join(dir, version+".mod"), gomod) + + f, err := os.Create(filepath.Join(dir, version+".zip")) + if err != nil { + t.Fatal(err) + } + zw := zip.NewWriter(f) + names := make([]string, 0, len(files)) + for name := range files { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + w, err := zw.Create(modPath + "@" + version + "/" + name) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write([]byte(files[name])); err != nil { + t.Fatal(err) + } + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } +} + +// fixturePluginFiles is a compiling plugin package with the given ID in +// a module that requires the framework at framework. +func fixturePluginFiles(modPath, pkg, id, framework string, extraRequires ...string) map[string]string { + var req strings.Builder + req.WriteString("require (\n\t" + frameworkModule + " " + framework + "\n") + for _, r := range extraRequires { + req.WriteString("\t" + r + "\n") + } + req.WriteString(")\n") + gomod := "module " + modPath + "\n\ngo 1.27.0\n\n" + req.String() + src := fmt.Sprintf(`package %s + +import ( + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/party" +) + +// Plugin is the %s fixture plugin. +type Plugin struct{} + +func (p *Plugin) ID() string { return %q } +func (p *Plugin) Requires() []string { return nil } +func (p *Plugin) Register(*backpack.App) error { return nil } +func (p *Plugin) Boot(*backpack.App) error { return nil } + +func init() { party.Register(&Plugin{}) } +`, pkg, id, id) + return map[string]string{"go.mod": gomod, "plugin.go": src} +} + +// offlineGoEnv points the go commands of the test at the file:// proxy +// tree proxyDir, falling back to the developer's module download cache +// for every other module, with a fresh module cache and no checksum +// database: no network is used. The fresh cache is made writable again +// before the temp directory is removed, because summer's go runner drops +// GOFLAGS (and with it -modcacherw). +func offlineGoEnv(t *testing.T, proxyDir string) { + t.Helper() + out, err := exec.CommandContext(t.Context(), "go", "env", "GOMODCACHE").Output() + if err != nil { + t.Fatalf("go env GOMODCACHE: %v", err) + } + realCache := strings.TrimSpace(string(out)) + modCache := t.TempDir() + t.Cleanup(func() { makeWritable(modCache) }) + t.Setenv("GOPROXY", "file://"+filepath.ToSlash(proxyDir)+",file://"+filepath.ToSlash(filepath.Join(realCache, "cache", "download"))) + t.Setenv("GOMODCACHE", modCache) + t.Setenv("GOSUMDB", "off") + t.Setenv("GOFLAGS", "-modcacherw") + t.Setenv("GOTOOLCHAIN", "local") + t.Setenv("GONOSUMDB", "") + t.Setenv("GOPRIVATE", "") + t.Setenv("GONOPROXY", "") +} + +func makeWritable(root string) { + _ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err == nil { + _ = os.Chmod(path, 0o755) + } + return nil + }) +} + +// fakePlugin is one plugin a fakeRegistry serves. +type fakePlugin struct { + Detail kiosk.RegistryPluginDetail + Versions []kiosk.RegistryVersion +} + +// fakeReg is an httptest registry speaking the kiosk v1 wire types. +type fakeReg struct { + Client *kiosk.Client + URL string + + mu sync.Mutex + plugins map[string]fakePlugin + requests []string +} + +// fakeRegistry serves plugins through httptest and returns a real +// kiosk.Client for it, so tests exercise the HTTP client too. +func fakeRegistry(t *testing.T, plugins map[string]fakePlugin) *fakeReg { + t.Helper() + r := &fakeReg{plugins: plugins} + mux := http.NewServeMux() + notFound := func(w http.ResponseWriter, id string) { + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(kiosk.ErrorResponse{Error: kiosk.RegistryError{Code: kiosk.RegistryErrorNotFound, Message: id + " not found"}}) + } + mux.HandleFunc("GET "+kiosk.RegistryAPIPath+"/plugins", func(w http.ResponseWriter, req *http.Request) { + r.record(req) + r.mu.Lock() + defer r.mu.Unlock() + var resp kiosk.ListResponse + for _, p := range r.plugins { + resp.Data = append(resp.Data, p.Detail.RegistryPlugin) + } + resp.Meta.Total = len(resp.Data) + _ = json.NewEncoder(w).Encode(resp) + }) + mux.HandleFunc("GET "+kiosk.RegistryAPIPath+"/plugins/{id}", func(w http.ResponseWriter, req *http.Request) { + r.record(req) + r.mu.Lock() + defer r.mu.Unlock() + id := req.PathValue("id") + p, ok := r.plugins[id] + if !ok { + notFound(w, id) + return + } + _ = json.NewEncoder(w).Encode(kiosk.DetailResponse{Data: p.Detail}) + }) + mux.HandleFunc("GET "+kiosk.RegistryAPIPath+"/plugins/{id}/versions", func(w http.ResponseWriter, req *http.Request) { + r.record(req) + r.mu.Lock() + defer r.mu.Unlock() + id := req.PathValue("id") + p, ok := r.plugins[id] + if !ok { + notFound(w, id) + return + } + _ = json.NewEncoder(w).Encode(kiosk.VersionsResponse{Data: p.Versions}) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + client, err := kiosk.NewClient(srv.URL, nil) + if err != nil { + t.Fatal(err) + } + r.Client, r.URL = client, srv.URL + return r +} + +func (r *fakeReg) record(req *http.Request) { + r.mu.Lock() + defer r.mu.Unlock() + r.requests = append(r.requests, req.URL.Path) +} + +// Requests returns the request paths served so far. +func (r *fakeReg) Requests() []string { + r.mu.Lock() + defer r.mu.Unlock() + return append([]string(nil), r.requests...) +} + +// set replaces (or adds) one plugin. +func (r *fakeReg) set(id string, p fakePlugin) { + r.mu.Lock() + defer r.mu.Unlock() + r.plugins[id] = p +} + +// regVersion is a registry release of a module plugin. +func regVersion(version, framework, module, pkg string, requires kiosk.Requires) kiosk.RegistryVersion { + return kiosk.RegistryVersion{ + Version: version, + Framework: framework, + Requires: requires, + Module: module, + Package: pkg, + Commit: strings.Repeat("a", 40), + TaggedAt: time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC), + } +} + +// regPlugin is a registry listing whose versions are given newest first. +func regPlugin(id, repo string, versions ...kiosk.RegistryVersion) fakePlugin { + p := fakePlugin{Versions: versions} + p.Detail.ID = id + p.Detail.Name = id + p.Detail.Repository = repo + if len(versions) > 0 { + p.Detail.Module = versions[0].Module + p.Detail.Package = versions[0].Package + p.Detail.LatestVersion = versions[0].Version + p.Detail.LatestFramework = versions[0].Framework + p.Detail.Requires = versions[0].Requires + } + return p +} + +// helloCopy is a copy of examples/hello in a temp directory whose go.mod +// replaces the framework with this checkout (absolute path), as +// TestMakeAndAddPluginOnHelloCopy uses it. +func helloCopy(t *testing.T) string { + t.Helper() + return copyHelloApp(t) +} diff --git a/internal/build/install.go b/internal/build/install.go new file mode 100644 index 00000000..f2a2fee5 --- /dev/null +++ b/internal/build/install.go @@ -0,0 +1,374 @@ +package build + +import ( + "context" + "errors" + "fmt" + "io" + "path/filepath" + "strings" + + "git.golem15.com/golem15/summercms/modules/bonfire" + "git.golem15.com/golem15/summercms/modules/kiosk" +) + +// Install modes of an InstallStep. +const ( + // ModeModule installs the plugin as a Go module with go get. + ModeModule = "module" + // ModeSubmodule installs the plugin as a git submodule under + // plugins//. + ModeSubmodule = "submodule" +) + +// ErrCancelled is returned by PlanInstall and Update when the user +// declined the confirmation prompt; nothing was changed. +var ErrCancelled = errors.New("cancelled; nothing was changed") + +// Registry is the part of the registry API v1 the installer uses. +// kiosk.Client implements it; a nil Registry means registry calls are +// disabled. +type Registry interface { + List(ctx context.Context, q, category string) (kiosk.ListResponse, error) + Plugin(ctx context.Context, id string) (kiosk.RegistryPluginDetail, error) + Versions(ctx context.Context, id string) ([]kiosk.RegistryVersion, error) +} + +// InstallOptions are the inputs of summer plugin:install. +type InstallOptions struct { + // StartDir is a directory inside the application. + StartDir string + // Arg is vendor.name, vendor.name@vX.Y.Z or a git URL. + Arg string + // Source installs registry plugins as git submodules. + Source bool + // Yes skips the confirmation prompt. + Yes bool + // Offline makes no registry call. + Offline bool + // Registry answers ID lookups; nil when registry calls are disabled. + Registry Registry + // Interactive reports that Confirm can ask a person. + Interactive bool + // Confirm asks the question and returns the answer. + Confirm func(question string) (bool, error) + // Out receives the plan and progress lines; nil discards them. + Out io.Writer +} + +// InstallPlan is what PlanInstall resolved and ApplyInstall carries out. +type InstallPlan struct { + AppDir string + Framework string + // Steps are in dependency order: a plugin's requirements come first. + Steps []InstallStep +} + +// InstallStep is one plugin to add. +type InstallStep struct { + ID string + Version string + Module string + Package string + Repository string + Mode string +} + +// installer carries the state of one PlanInstall run. +type installer struct { + ctx context.Context + opts InstallOptions + out io.Writer + appDir string + manifest Manifest + fw string + plan *InstallPlan +} + +// PlanInstall resolves what plugin:install would add: the requested +// plugin at the newest release whose framework minimum the application's +// effective framework version satisfies (or the pinned release), checks +// every registry string, prints the additions and asks for confirmation +// unless opts.Yes. A non-interactive run without opts.Yes is an error +// naming --yes; a declined prompt returns ErrCancelled. It changes +// nothing in the application. +func PlanInstall(ctx context.Context, opts InstallOptions) (*InstallPlan, error) { + appDir, err := findAppDir(opts.StartDir) + if err != nil { + return nil, err + } + m, err := LoadManifest(filepath.Join(appDir, manifestName)) + if err != nil { + return nil, err + } + in := &installer{ctx: ctx, opts: opts, out: opts.Out, appDir: appDir, manifest: m} + if in.out == nil { + in.out = io.Discard + } + fw, err := EffectiveFramework(ctx, appDir) + if err != nil { + if fw == "" { + return nil, err + } + fmt.Fprintf(in.out, "warning: %v\n", err) + } + in.fw = fw + in.plan = &InstallPlan{AppDir: appDir, Framework: fw} + + id, pin, err := parseInstallArg(opts.Arg) + if err != nil { + return nil, err + } + if err := in.requireAbsent(id); err != nil { + return nil, err + } + if err := in.requireRegistry(id); err != nil { + return nil, err + } + step, err := in.choose(id, pin, "") + if err != nil { + return nil, err + } + in.plan.Steps = append(in.plan.Steps, step) + + if err := in.confirm(); err != nil { + return nil, err + } + return in.plan, nil +} + +// parseInstallArg splits vendor.name[@vX.Y.Z]. +func parseInstallArg(arg string) (id, pin string, err error) { + arg = strings.TrimSpace(arg) + id, pin, _ = strings.Cut(arg, "@") + if err := ValidatePluginID(id); err != nil { + return "", "", bonfire.WithHint(fmt.Errorf("build: %w", err), + "plugin:install takes a plugin ID (vendor.name), optionally pinned to a release with @vX.Y.Z.", + "{bin} plugin:install acme.blog@v0.1.0") + } + if strings.Contains(arg, "@") && !kiosk.ValidVersion(pin) { + return "", "", fmt.Errorf("build: %q is not a release version; pin with @vX.Y.Z", pin) + } + return id, pin, nil +} + +// requireAbsent fails when summer.yaml already lists id. +func (in *installer) requireAbsent(id string) error { + for _, p := range in.manifest.Plugins { + if p.ID == id { + return bonfire.WithHint(fmt.Errorf("build: %s is already in summer.yaml (%s)", id, p.Module), + "plugin:install adds plugins the application does not have yet; move an installed plugin to a newer release with plugin:update.", + "{bin} plugin:update "+id) + } + } + return nil +} + +// requireRegistry fails when an ID cannot be looked up. +func (in *installer) requireRegistry(id string) error { + if in.opts.Registry != nil && !in.opts.Offline { + return nil + } + reason := "the plugin registry is disabled (plugins.registry_url is empty)" + if in.opts.Offline { + reason = "--offline makes no registry calls" + } + return bonfire.WithHint(fmt.Errorf("build: cannot look up %s: %s", id, reason), + "Installing by plugin ID asks the registry for the plugin's releases. Install from the plugin's git repository instead, or enable the registry.", + "{bin} plugin:install ") +} + +// choose picks the release of id to install: the pinned one, or the +// newest whose framework minimum the app satisfies and which is at least +// min. It validates every string ApplyInstall will pass to go or git. +func (in *installer) choose(id, pin, min string) (InstallStep, error) { + versions, err := in.opts.Registry.Versions(in.ctx, id) + if err != nil { + return InstallStep{}, fmt.Errorf("build: %w", err) + } + var chosen kiosk.RegistryVersion + if pin != "" { + found := false + var available []string + for _, v := range versions { + available = append(available, v.Version) + if v.Version == pin { + chosen, found = v, true + } + } + if !found { + list := strings.Join(available, ", ") + if list == "" { + list = "none" + } + return InstallStep{}, fmt.Errorf("build: %s has no release %s; available: %s", id, pin, list) + } + if !kiosk.Satisfies(in.fw, chosen.Framework) { + return InstallStep{}, in.frameworkError(id, chosen) + } + if !kiosk.Satisfies(pin, min) { + return InstallStep{}, fmt.Errorf("build: %s %s is older than the required minimum %s", id, pin, min) + } + } else { + var eligible []kiosk.RegistryVersion + for _, v := range versions { + if kiosk.ValidVersion(v.Version) && kiosk.Satisfies(v.Version, min) { + eligible = append(eligible, v) + } + } + best, ok := kiosk.NewestCompatible(eligible, in.fw) + if !ok { + if len(eligible) == 0 { + if min != "" { + return InstallStep{}, fmt.Errorf("build: %s has no release at or above the required minimum %s", id, min) + } + return InstallStep{}, fmt.Errorf("build: %s has no release in the registry", id) + } + return InstallStep{}, in.frameworkError(id, newestOf(eligible)) + } + chosen = best + } + step := InstallStep{ + ID: id, + Version: chosen.Version, + Module: chosen.Module, + Package: chosen.Package, + Mode: ModeModule, + } + if err := validateStep(step); err != nil { + return InstallStep{}, err + } + return step, nil +} + +// frameworkError names the framework release v needs. +func (in *installer) frameworkError(id string, v kiosk.RegistryVersion) error { + have := in.fw + if have == "" { + have = "unknown" + } + return bonfire.WithHint(fmt.Errorf("build: %s %s needs framework %s or newer; this application builds against %s", id, v.Version, v.Framework, have), + "plugin:install never moves the framework. Update the framework requirement in go.mod (or the local framework checkout) to "+v.Framework+" first, or pin an older release of the plugin.", + "go get "+frameworkModule+"@"+v.Framework) +} + +// newestOf returns the newest valid release in versions. +func newestOf(versions []kiosk.RegistryVersion) kiosk.RegistryVersion { + var best kiosk.RegistryVersion + for _, v := range versions { + if !kiosk.ValidVersion(v.Version) { + continue + } + if best.Version == "" || kiosk.CompareVersions(v.Version, best.Version) > 0 { + best = v + } + } + return best +} + +// validateStep refuses registry strings that are not safe to hand to go: +// the version must be a strict vX.Y.Z and the module and package must be +// plain module paths, the package inside the module. +func validateStep(s InstallStep) error { + if !kiosk.ValidVersion(s.Version) { + return fmt.Errorf("build: registry entry %s: version %q is not a vX.Y.Z release; refused", s.ID, s.Version) + } + if err := validateModulePath(s.Module); err != nil { + return fmt.Errorf("build: registry entry %s: module %w; refused", s.ID, err) + } + if err := validateModulePath(s.Package); err != nil { + return fmt.Errorf("build: registry entry %s: package %w; refused", s.ID, err) + } + if s.Package != s.Module && !strings.HasPrefix(s.Package, s.Module+"/") { + return fmt.Errorf("build: registry entry %s: package %s is not inside module %s; refused", s.ID, s.Package, s.Module) + } + return nil +} + +// summary renders one line per step. +func (p *InstallPlan) summary() string { + var b strings.Builder + for _, s := range p.Steps { + fmt.Fprintf(&b, " %s@%s (%s)\n", s.ID, s.Version, s.Mode) + } + return b.String() +} + +// confirm prints the plan and asks for confirmation unless --yes. +func (in *installer) confirm() error { + n := len(in.plan.Steps) + noun := "plugin" + if n != 1 { + noun = "plugins" + } + fmt.Fprintf(in.out, "Installing %d %s into %s (framework %s):\n%s", n, noun, in.appDir, displayVersion(in.fw), in.plan.summary()) + return askToProceed(in.opts.Yes, in.opts.Interactive, in.opts.Confirm, fmt.Sprintf("Install %d %s?", n, noun)) +} + +// askToProceed is the prompt rule of plugin:install and plugin:update +// (research F4): --yes proceeds, an interactive run asks with "no" as the +// default, and a non-interactive run without --yes is an error. +func askToProceed(yes, interactive bool, confirm func(string) (bool, error), question string) error { + if yes { + return nil + } + if !interactive || confirm == nil { + return bonfire.WithHint(errors.New("build: this run cannot ask for confirmation; pass --yes to proceed"), + "Every addition is printed above. A run without a terminal (or with -n or -q) needs --yes to go ahead.", + "{bin} "+cliInvocation()+" --yes") + } + ok, err := confirm(question) + if err != nil { + return err + } + if !ok { + return ErrCancelled + } + return nil +} + +func displayVersion(v string) string { + if v == "" { + return "unknown" + } + return v +} + +// ApplyInstall carries out a plan from PlanInstall: one go get for every +// module step, so minimal version selection sees the whole set, then the +// summer.yaml entries in plan order. It does not run go mod tidy: until +// summer build imports the new packages, tidy would drop them. +func ApplyInstall(ctx context.Context, plan *InstallPlan, out io.Writer) error { + if plan == nil || len(plan.Steps) == 0 { + return fmt.Errorf("build: nothing to install") + } + if out == nil { + out = io.Discard + } + var getArgs []string + var entries []Plugin + for _, s := range plan.Steps { + if err := validateStep(s); err != nil { + return err + } + if s.Mode == ModeModule { + getArgs = append(getArgs, s.Module+"@"+s.Version) + } + entries = append(entries, Plugin{ID: s.ID, Module: s.Package}) + } + if len(getArgs) > 0 { + fmt.Fprintf(out, "go get %s\n", strings.Join(getArgs, " ")) + if err := runGo(ctx, plan.AppDir, append([]string{"get", "--"}, getArgs...)...); err != nil { + return err + } + } + if err := appendManifestPlugins(filepath.Join(plan.AppDir, manifestName), entries); err != nil { + return err + } + ids := make([]string, len(plan.Steps)) + for i, s := range plan.Steps { + ids[i] = s.ID + "@" + s.Version + } + fmt.Fprintf(out, "Installed %s; run summer build\n", strings.Join(ids, ", ")) + return nil +} diff --git a/internal/build/install_smoke_test.go b/internal/build/install_smoke_test.go new file mode 100644 index 00000000..2684e550 --- /dev/null +++ b/internal/build/install_smoke_test.go @@ -0,0 +1,213 @@ +package build + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "git.golem15.com/golem15/summercms/modules/kiosk" +) + +const ( + blogModule = "example.com/acme/blog" + // plantedComment is written into summer.yaml before an install; the + // install must keep it. + plantedComment = "# planted: plugin:install keeps this comment" +) + +// plantComment puts a comment line and a blank line above the plugins +// list of the app's summer.yaml. +func plantComment(t *testing.T, appDir string) { + t.Helper() + path := filepath.Join(appDir, manifestName) + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + s := strings.Replace(string(raw), "plugins:\n", "\n"+plantedComment+"\nplugins:\n", 1) + write(t, path, s) +} + +func assertManifestKeepsComment(t *testing.T, appDir string) { + t.Helper() + raw, err := os.ReadFile(filepath.Join(appDir, manifestName)) + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(raw, []byte(plantedComment)) { + t.Fatalf("summer.yaml lost the planted comment:\n%s", raw) + } +} + +func manifestIDs(t *testing.T, appDir string) []string { + t.Helper() + m, err := LoadManifest(filepath.Join(appDir, manifestName)) + if err != nil { + t.Fatal(err) + } + ids := make([]string, len(m.Plugins)) + for i, p := range m.Plugins { + ids[i] = p.ID + "=" + p.Module + } + return ids +} + +func TestInstallSmoke(t *testing.T) { + blogV1 := regVersion("v0.1.0", "v0.5.0", blogModule, blogModule, nil) + blogV2 := regVersion("v0.2.0", "v9.0.0", blogModule, blogModule, nil) + + t.Run("module", func(t *testing.T) { + if testing.Short() { + t.Skip("builds an app against a file:// module proxy") + } + app := helloCopy(t) + plantComment(t, app) + proxy := t.TempDir() + writeProxyModule(t, proxy, blogModule, "v0.1.0", fixturePluginFiles(blogModule, "blog", "acme.blog", "v0.5.0")) + offlineGoEnv(t, proxy) + reg := fakeRegistry(t, map[string]fakePlugin{ + "acme.blog": regPlugin("acme.blog", "https://example.com/acme/blog.git", blogV2, blogV1), + }) + + var out bytes.Buffer + plan, err := PlanInstall(t.Context(), InstallOptions{StartDir: app, Arg: "acme.blog", Yes: true, Registry: reg.Client, Out: &out}) + if err != nil { + t.Fatalf("plan: %v\n%s", err, out.String()) + } + if len(plan.Steps) != 1 || plan.Steps[0].Version != "v0.1.0" || plan.Steps[0].Mode != ModeModule { + t.Fatalf("plan steps = %+v", plan.Steps) + } + if !strings.Contains(out.String(), "acme.blog@v0.1.0 (module)") { + t.Fatalf("plan output lacks the step:\n%s", out.String()) + } + if err := ApplyInstall(t.Context(), plan, &out); err != nil { + t.Fatalf("apply: %v\n%s", err, out.String()) + } + if !strings.Contains(out.String(), "run summer build") { + t.Fatalf("apply output:\n%s", out.String()) + } + gomod, err := os.ReadFile(filepath.Join(app, "go.mod")) + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(gomod, []byte(blogModule+" v0.1.0")) { + t.Fatalf("go.mod does not require %s v0.1.0:\n%s", blogModule, gomod) + } + ids := manifestIDs(t, app) + if last := ids[len(ids)-1]; last != "acme.blog="+blogModule { + t.Fatalf("summer.yaml plugins = %v", ids) + } + assertManifestKeepsComment(t, app) + + var log bytes.Buffer + if err := App(t.Context(), app, &log); err != nil { + t.Fatalf("build: %v\n%s", err, log.String()) + } + gen, err := os.ReadFile(filepath.Join(app, "plugins.gen.go")) + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(gen, []byte(`_ "`+blogModule+`"`)) || !bytes.Contains(gen, []byte(`Version: "v0.1.0"`)) { + t.Fatalf("plugins.gen.go does not import %s at v0.1.0:\n%s", blogModule, gen) + } + }) + + t.Run("module_pin_framework", func(t *testing.T) { + app := helloCopy(t) + reg := fakeRegistry(t, map[string]fakePlugin{ + "acme.blog": regPlugin("acme.blog", "https://example.com/acme/blog.git", blogV2, blogV1), + }) + before := manifestIDs(t, app) + _, err := PlanInstall(t.Context(), InstallOptions{StartDir: app, Arg: "acme.blog@v0.2.0", Yes: true, Registry: reg.Client}) + if err == nil || !strings.Contains(err.Error(), "v9.0.0") { + t.Fatalf("pin to v0.2.0: err = %v, want one naming framework v9.0.0", err) + } + if got := manifestIDs(t, app); strings.Join(got, ",") != strings.Join(before, ",") { + t.Fatalf("summer.yaml changed: %v", got) + } + }) + + t.Run("append_manifest", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, manifestName) + cases := []struct { + name, in, want string + }{ + { + name: "indented", + in: "# app\nmodule: example.com/app\nbinary: app\n\n# the plugins\nplugins:\n - id: a.one\n module: example.com/one # first\n\n # trailing note\nbuild:\n tags: [x]\n", + want: "# app\nmodule: example.com/app\nbinary: app\n\n# the plugins\nplugins:\n - id: a.one\n module: example.com/one # first\n - id: acme.blog\n module: example.com/acme/blog\n\n # trailing note\nbuild:\n tags: [x]\n", + }, + { + name: "flush", + in: "module: example.com/app\nbinary: app\nplugins:\n- id: a.one\n module: example.com/one\n", + want: "module: example.com/app\nbinary: app\nplugins:\n- id: a.one\n module: example.com/one\n- id: acme.blog\n module: example.com/acme/blog\n", + }, + { + name: "empty", + in: "module: example.com/app\nbinary: app\nplugins: []\n# end\n", + want: "module: example.com/app\nbinary: app\nplugins:\n - id: acme.blog\n module: example.com/acme/blog\n# end\n", + }, + { + name: "absent", + in: "module: example.com/app\nbinary: app", + want: "module: example.com/app\nbinary: app\nplugins:\n - id: acme.blog\n module: example.com/acme/blog\n", + }, + } + for _, c := range cases { + write(t, path, c.in) + if err := appendManifestPlugins(path, []Plugin{{ID: "acme.blog", Module: blogModule}}); err != nil { + t.Fatalf("%s: %v", c.name, err) + } + got, _ := os.ReadFile(path) + if string(got) != c.want { + t.Fatalf("%s:\n got %q\nwant %q", c.name, got, c.want) + } + } + inline := "module: example.com/app\nbinary: app\nplugins: [{id: a.one, module: example.com/one}]\n" + write(t, path, inline) + if err := appendManifestPlugins(path, []Plugin{{ID: "acme.blog", Module: blogModule}}); err == nil { + t.Fatal("inline plugins list: want an error") + } + if got, _ := os.ReadFile(path); string(got) != inline { + t.Fatalf("inline list was changed: %q", got) + } + dup := "module: example.com/app\nbinary: app\nplugins:\n - id: acme.blog\n module: example.com/other\n" + write(t, path, dup) + if err := appendManifestPlugins(path, []Plugin{{ID: "acme.blog", Module: blogModule}}); err == nil { + t.Fatal("duplicate ID: want an error") + } + if got, _ := os.ReadFile(path); string(got) != dup { + t.Fatalf("summer.yaml not restored: %q", got) + } + }) + + t.Run("registry_client", func(t *testing.T) { + reg := fakeRegistry(t, map[string]fakePlugin{ + "acme.blog": regPlugin("acme.blog", "https://example.com/acme/blog.git", blogV2, blogV1), + }) + vs, err := reg.Client.Versions(t.Context(), "acme.blog") + if err != nil || len(vs) != 2 || vs[0].Version != "v0.2.0" { + t.Fatalf("versions = %+v, %v", vs, err) + } + _, err = reg.Client.Versions(t.Context(), "acme.nope") + if !errors.Is(err, kiosk.ErrNotFound) || !strings.Contains(err.Error(), reg.URL) { + t.Fatalf("unknown id: err = %v", err) + } + list, err := reg.Client.List(t.Context(), "blog", "") + if err != nil || list.Meta.Total != 1 { + t.Fatalf("list = %+v, %v", list, err) + } + if _, err := kiosk.NewClient("", nil); !errors.Is(err, kiosk.ErrRegistryDisabled) { + t.Fatalf("empty URL: err = %v", err) + } + for _, bad := range []string{"ftp://x", "https://user:pw@x", "https://x/?q=1", "x"} { + if _, err := kiosk.NewClient(bad, nil); err == nil { + t.Fatalf("NewClient(%q) accepted", bad) + } + } + }) +} diff --git a/internal/build/manifest.go b/internal/build/manifest.go index 5adc44c1..ef59fc85 100644 --- a/internal/build/manifest.go +++ b/internal/build/manifest.go @@ -180,6 +180,143 @@ func SaveManifest(path string, m Manifest) error { return nil } +// appendManifestPlugins adds plugins to the end of the plugins list in the +// summer.yaml at path by inserting text, so comments, blank lines and the +// order of the other keys stay as they are. The entries take the +// indentation of the existing items; a file without a plugins key gets a +// new block at its end. The result is parsed again with LoadManifest, and +// any error restores the original bytes. +func appendManifestPlugins(path string, plugins []Plugin) error { + if len(plugins) == 0 { + return nil + } + for i, p := range plugins { + if err := ValidatePluginID(p.ID); err != nil { + return fmt.Errorf("build: plugins[%d]: %w", i, err) + } + if err := validateModulePath(p.Module); err != nil { + return fmt.Errorf("build: plugins[%d] module: %w", i, err) + } + } + orig, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("build: read %s: %w", path, err) + } + before, err := LoadManifest(path) + if err != nil { + return err + } + updated, err := insertManifestPlugins(string(orig), plugins) + if err != nil { + return fmt.Errorf("build: edit %s: %w", path, err) + } + if err := os.WriteFile(path, []byte(updated), 0o644); err != nil { + return fmt.Errorf("build: write %s: %w", path, err) + } + restore := func(cause error) error { + if werr := os.WriteFile(path, orig, 0o644); werr != nil { + return fmt.Errorf("%w (restoring %s also failed: %v)", cause, path, werr) + } + return cause + } + after, err := LoadManifest(path) + if err != nil { + return restore(fmt.Errorf("build: summer.yaml edit did not parse, left unchanged: %w", err)) + } + want := append(append([]Plugin(nil), before.Plugins...), plugins...) + if len(after.Plugins) != len(want) { + return restore(fmt.Errorf("build: summer.yaml edit gave %d plugins instead of %d, left unchanged", len(after.Plugins), len(want))) + } + for i := range want { + if after.Plugins[i] != want[i] { + return restore(fmt.Errorf("build: summer.yaml edit changed plugins[%d], left unchanged", i)) + } + } + return nil +} + +// insertManifestPlugins returns src with plugins appended to its +// top-level plugins block. +func insertManifestPlugins(src string, plugins []Plugin) (string, error) { + lines := strings.SplitAfter(src, "\n") + if n := len(lines); n > 0 && lines[n-1] == "" { + lines = lines[:n-1] + } + key := -1 + for i, l := range lines { + if strings.HasPrefix(l, "plugins:") { + key = i + break + } + } + render := func(dash, keyIndent string) string { + var b strings.Builder + for _, p := range plugins { + fmt.Fprintf(&b, "%s- id: %s\n%smodule: %s\n", dash, p.ID, keyIndent, p.Module) + } + return b.String() + } + if key < 0 { + out := strings.Join(lines, "") + if out != "" && !strings.HasSuffix(out, "\n") { + out += "\n" + } + return out + "plugins:\n" + render(" ", " "), nil + } + rest := strings.TrimSpace(stripYAMLComment(strings.TrimPrefix(lines[key], "plugins:"))) + switch rest { + case "", "[]", "~", "null": + default: + return "", fmt.Errorf("the plugins list is written inline; write it as a block list (one \"- id:\" item per plugin) and run the command again") + } + // The block runs until the next line that starts a top-level key. + end := key + 1 + last := key + dash, keyIndent := "", "" + for ; end < len(lines); end++ { + l := strings.TrimRight(lines[end], "\r\n") + trimmed := strings.TrimSpace(l) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + indented := l[0] == ' ' || l[0] == '\t' + if !indented && !strings.HasPrefix(l, "-") { + break + } + last = end + if dash == "" && strings.HasPrefix(trimmed, "-") { + dash = l[:len(l)-len(strings.TrimLeft(l, " \t"))] + afterDash := strings.TrimLeft(trimmed[1:], " \t") + keyIndent = strings.Repeat(" ", len(dash)+len(trimmed)-len(afterDash)) + } + } + if dash == "" && keyIndent == "" { + dash, keyIndent = " ", " " + } + head := lines[:last+1] + if rest != "" { + head = append(append([]string(nil), lines[:key]...), "plugins:\n") + head = append(head, lines[key+1:last+1]...) + } + tail := lines[last+1:] + out := strings.Join(head, "") + if !strings.HasSuffix(out, "\n") { + out += "\n" + } + return out + render(dash, keyIndent) + strings.Join(tail, ""), nil +} + +// stripYAMLComment drops a trailing " # comment" from a YAML value. +func stripYAMLComment(s string) string { + if i := strings.Index(s, " #"); i >= 0 { + return s[:i] + } + if strings.HasPrefix(strings.TrimSpace(s), "#") { + return "" + } + return s +} + // ValidatePluginID requires a lowercase vendor.plugin identifier. func ValidatePluginID(id string) error { if id == "" { diff --git a/internal/build/scaffold.go b/internal/build/scaffold.go index deab25a7..9b762c09 100644 --- a/internal/build/scaffold.go +++ b/internal/build/scaffold.go @@ -204,7 +204,7 @@ func AddPlugin(ctx context.Context, startDir, pluginDir string) error { if already { return nil } - return SaveManifest(manifestPath, m) + return appendManifestPlugins(manifestPath, []Plugin{{ID: id, Module: modPath}}) } func pluginName(id string) string { diff --git a/modules/kiosk/README.md b/modules/kiosk/README.md index fb87f5fb..5b181916 100644 --- a/modules/kiosk/README.md +++ b/modules/kiosk/README.md @@ -21,6 +21,7 @@ WinterCMS keeps this data in a plugin's `Plugin.php` `pluginDetails()` and in `c - 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. +- A registry client, `kiosk.Client`, for the summer tool and the admin: `kiosk.NewClient` builds it over a `fetchguard` client in trusted mode with a 4 MiB body cap, a 10 second timeout and no redirects, and `kiosk.RegistryURL` reads the registry origin from the `plugins.registry_url` config key (default `kiosk.DefaultRegistryURL`; an empty value disables registry calls). - `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 @@ -58,6 +59,22 @@ if !ok { fmt.Println("go get", v.Module+"@"+v.Version) ``` +Asking the configured registry for a plugin's releases: + +```go +client, err := kiosk.NewClient(kiosk.RegistryURL(app.Config), app.Config) +if errors.Is(err, kiosk.ErrRegistryDisabled) { + return nil // plugins.registry_url is empty: no registry calls +} +if err != nil { + return err +} +versions, err := client.Versions(ctx, "acme.blog") +if errors.Is(err, kiosk.ErrNotFound) { + return fmt.Errorf("acme.blog is not listed") +} +``` + A plugin repository keeps its manifest current with one test: ```go @@ -111,21 +128,38 @@ func TestPluginManifest(t *testing.T) { | `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.DefaultRegistryURL` | `https://summercms.io`, the registry used when `plugins.registry_url` is not set. | +| `kiosk.RegistryURL` | The registry origin from a `compass.Config`: the `plugins.registry_url` value without a trailing slash, `kiosk.DefaultRegistryURL` when the key is absent, `""` (disabled) when it is set empty. | +| `kiosk.ErrRegistryDisabled` | Returned by `kiosk.NewClient` for an empty base URL. | +| `kiosk.ErrNotFound` | Wrapped by a `kiosk.Client` error when the registry answers 404; the message names the ID and the registry origin. | +| `kiosk.Client` | Calls the registry API v1 of one origin through `fetchguard` (trusted mode, 4 MiB cap, 10 s timeout, no redirects). | +| `kiosk.NewClient` | Builds a client for an `http` or `https` origin without user info, query or fragment; the config supplies fetchguard defaults and may be nil. | +| `kiosk.Client.BaseURL` | The origin the client calls. | +| `kiosk.Client.List` | `GET /plugins?q=&category=`, decoded as a `kiosk.ListResponse`. | +| `kiosk.Client.Plugin` | `GET /plugins/{id}`: one `kiosk.RegistryPluginDetail`. | +| `kiosk.Client.Versions` | `GET /plugins/{id}/versions`: the plugin's `kiosk.RegistryVersion` list, newest first. A non-2xx answer other than 404 is an error carrying the registry's error code; a redirect is an error asking for the final origin. | | `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. | +## Configuration + +| Key | Default | Meaning | +|-----|---------|---------| +| `plugins.registry_url` | `https://summercms.io` | Registry origin for `summer plugin:install` and the admin's Plugins screen (file `config/plugins.yaml`, env `SUMMER_PLUGINS__REGISTRY_URL`). It must be the final origin, because redirects are not followed. An empty value disables registry calls. | + ## CLI commands | Command | Purpose | |---------|---------| | `summer plugin:manifest [dir]` | Writes `plugin.yaml` for the plugin in `dir` (default: the current directory). `--pretend` prints it instead, `--check` fails when the committed file is missing or stale, `--name` and `--description` set or (with an empty value) clear the overrides. | +| `summer plugin:install ` | Installs a plugin; reads its releases through `kiosk.Client` and picks one with `kiosk.NewestCompatible`. See [Installing plugins](../../docs/plugins/marketplace.md#installing-plugins). | ## Dependencies -- SummerCMS modules: none. +- SummerCMS modules: `compass` (the `plugins.registry_url` setting), `fetchguard` (registry HTTP calls). - 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`, `time`. +- Standard library: `bytes`, `context`, `encoding/json`, `errors`, `fmt`, `go/ast`, `go/parser`, `go/token`, `go/types` (expression printing only), `net/http`, `net/url`, `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 @@ -135,3 +169,5 @@ 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`, 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. + +The registry client (`kiosk.Client`) is exercised by `TestInstallSmoke` in `internal/build`, against an `httptest` registry that serves these wire types. diff --git a/modules/kiosk/client.go b/modules/kiosk/client.go new file mode 100644 index 00000000..1c6c7973 --- /dev/null +++ b/modules/kiosk/client.go @@ -0,0 +1,161 @@ +package kiosk + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "git.golem15.com/golem15/summercms/modules/compass" + "git.golem15.com/golem15/summercms/modules/fetchguard" +) + +// DefaultRegistryURL is the registry an application uses when its config +// has no plugins.registry_url. +const DefaultRegistryURL = "https://summercms.io" + +// registryURLKey is the config key naming the registry origin: the file +// config/plugins.yaml, key registry_url, or SUMMER_PLUGINS__REGISTRY_URL. +const registryURLKey = "plugins.registry_url" + +// Limits of every registry call: a registry answer is small JSON, so a +// larger body or a slow server is an error, not something to wait for. +const ( + registryMaxBytes = 4 << 20 + registryTimeout = 10 * time.Second +) + +var ( + // ErrRegistryDisabled is returned by NewClient for an empty base URL: + // plugins.registry_url is set to "" and registry calls are off. + ErrRegistryDisabled = errors.New("the plugin registry is disabled (plugins.registry_url is empty)") + // ErrNotFound is wrapped by the errors of a Client call the registry + // answered with 404: no plugin has that ID. + ErrNotFound = errors.New("not in the registry") +) + +// RegistryURL returns the registry origin configured in cfg: the +// DefaultRegistryURL when plugins.registry_url is absent (or cfg is nil), +// the configured value without a trailing slash otherwise. An empty +// result means registry calls are disabled. +func RegistryURL(cfg *compass.Config) string { + if cfg == nil || !cfg.Has(registryURLKey) { + return DefaultRegistryURL + } + return strings.TrimRight(strings.TrimSpace(cfg.String(registryURLKey)), "/") +} + +// Client reads the registry API v1 of one registry origin. Every request +// goes through a fetchguard client in TrustedMode (the origin is operator +// configuration) with a 4 MiB body cap, a 10 second timeout and no +// redirects, so the base URL must be the final origin. +type Client struct { + base string + http *fetchguard.Client +} + +// NewClient builds a Client for the registry at baseURL, an http or https +// origin without user info, query or fragment, for example +// https://summercms.io. An empty baseURL returns ErrRegistryDisabled. cfg +// may be nil. +func NewClient(baseURL string, cfg *compass.Config) (*Client, error) { + baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") + if baseURL == "" { + return nil, ErrRegistryDisabled + } + u, err := url.Parse(baseURL) + if err != nil { + return nil, fmt.Errorf("kiosk: registry URL %q: %w", baseURL, err) + } + switch { + case u.Scheme != "http" && u.Scheme != "https": + return nil, fmt.Errorf("kiosk: registry URL %q must start with https:// or http://", baseURL) + case u.Host == "": + return nil, fmt.Errorf("kiosk: registry URL %q has no host", baseURL) + case u.User != nil: + return nil, fmt.Errorf("kiosk: registry URL must not carry user info") + case u.RawQuery != "" || u.Fragment != "" || u.ForceQuery: + return nil, fmt.Errorf("kiosk: registry URL %q must not have a query or fragment", baseURL) + } + hc, err := fetchguard.NewClient(fetchguard.Policy{ + Mode: fetchguard.TrustedMode, + MaxBytes: registryMaxBytes, + Timeout: registryTimeout, + }, cfg) + if err != nil { + return nil, fmt.Errorf("kiosk: registry client: %w", err) + } + return &Client{base: baseURL, http: hc}, nil +} + +// BaseURL returns the registry origin the client calls. +func (c *Client) BaseURL() string { + return c.base +} + +// List returns the registry's plugins matching the search q and the +// category (both optional). +func (c *Client) List(ctx context.Context, q, category string) (ListResponse, error) { + query := url.Values{} + if q != "" { + query.Set("q", q) + } + if category != "" { + query.Set("category", category) + } + path := "/plugins" + if len(query) > 0 { + path += "?" + query.Encode() + } + var resp ListResponse + err := c.get(ctx, path, "", &resp) + return resp, err +} + +// Plugin returns one plugin with its README and the requirements of its +// latest release. An unknown ID gives an error wrapping ErrNotFound. +func (c *Client) Plugin(ctx context.Context, id string) (RegistryPluginDetail, error) { + var resp DetailResponse + err := c.get(ctx, "/plugins/"+url.PathEscape(id), id, &resp) + return resp.Data, err +} + +// Versions returns every release of a plugin, newest first. An unknown ID +// gives an error wrapping ErrNotFound. +func (c *Client) Versions(ctx context.Context, id string) ([]RegistryVersion, error) { + var resp VersionsResponse + err := c.get(ctx, "/plugins/"+url.PathEscape(id)+"/versions", id, &resp) + return resp.Data, err +} + +func (c *Client) get(ctx context.Context, path, id string, out any) error { + header := http.Header{} + header.Set("Accept", "application/json") + res, err := c.http.Get(ctx, c.base+RegistryAPIPath+path, header) + if err != nil { + return fmt.Errorf("registry %s: %w", c.base, err) + } + switch { + case res.StatusCode == http.StatusNotFound: + if id != "" { + return fmt.Errorf("%s is %w at %s", id, ErrNotFound, c.base) + } + return fmt.Errorf("registry %s: %w", c.base, ErrNotFound) + case res.StatusCode >= 300 && res.StatusCode < 400: + return fmt.Errorf("registry %s answered with a redirect to %q; set plugins.registry_url to the final origin", c.base, res.Header.Get("Location")) + case res.StatusCode < 200 || res.StatusCode >= 300: + var e ErrorResponse + if json.Unmarshal(res.Body, &e) == nil && e.Error.Code != "" { + return fmt.Errorf("registry %s: HTTP %d %s: %s", c.base, res.StatusCode, e.Error.Code, e.Error.Message) + } + return fmt.Errorf("registry %s: HTTP %d", c.base, res.StatusCode) + } + if err := json.Unmarshal(res.Body, out); err != nil { + return fmt.Errorf("registry %s: decode %s: %w", c.base, path, err) + } + return nil +}