feat(build): plugin:install resolves requirements, pins and prompts

- required plugins resolve transitively against the registry, each at the
  newest release meeting the framework and its minimum, dependencies first
- an installed requirement below its minimum stops with a plugin:update
  hint; cycles in registry data are reported
- one go get for the whole set; plugins sharing a module share a release
- behavior subtests: deps, installed_old, cycle, pin, unknown, prompt,
  offline, invalid
This commit is contained in:
Jakub Zych
2026-10-29 18:26:05 +01:00
parent 9b23e877a6
commit a462bf12c9
3 changed files with 439 additions and 19 deletions

View File

@@ -6,6 +6,8 @@ import (
"fmt"
"io"
"path/filepath"
"slices"
"sort"
"strings"
"git.golem15.com/golem15/summercms/modules/bonfire"
@@ -83,12 +85,22 @@ type installer struct {
manifest Manifest
fw string
plan *InstallPlan
// planned holds the steps chosen so far by ID; visiting is the chain
// of IDs being resolved, for cycle reports; moduleVersion pins the
// version of a module once one of its plugins is chosen, so plugins
// that share a module install at one version.
planned map[string]InstallStep
visiting []string
moduleVersion map[string]string
}
// 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
// effective framework version satisfies (or the pinned release), and,
// before it, every required plugin the application lacks, each at the
// newest compatible release at or above its minimum. It 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.
@@ -115,6 +127,9 @@ func PlanInstall(ctx context.Context, opts InstallOptions) (*InstallPlan, error)
in.fw = fw
in.plan = &InstallPlan{AppDir: appDir, Framework: fw}
in.planned = map[string]InstallStep{}
in.moduleVersion = map[string]string{}
id, pin, err := parseInstallArg(opts.Arg)
if err != nil {
return nil, err
@@ -125,11 +140,9 @@ func PlanInstall(ctx context.Context, opts InstallOptions) (*InstallPlan, error)
if err := in.requireRegistry(id); err != nil {
return nil, err
}
step, err := in.choose(id, pin, "")
if err != nil {
if err := in.resolve(id, pin, ""); err != nil {
return nil, err
}
in.plan.Steps = append(in.plan.Steps, step)
if err := in.confirm(); err != nil {
return nil, err
@@ -178,13 +191,175 @@ func (in *installer) requireRegistry(id string) error {
"{bin} plugin:install <git-url>")
}
// resolve adds id and, first, every plugin it requires that the
// application does not have: the depth-first post-order puts each
// requirement before the plugins that need it. An installed requirement
// must be at least its minimum. A cycle in the registry data is an error.
func (in *installer) resolve(id, pin, min string) error {
for i, v := range in.visiting {
if v == id {
chain := append(append([]string(nil), in.visiting[i:]...), id)
return fmt.Errorf("build: dependency cycle in the registry data: %s", strings.Join(chain, " -> "))
}
}
if step, ok := in.planned[id]; ok {
if !kiosk.Satisfies(step.Version, min) {
return fmt.Errorf("build: %s needs %s %s or newer, but %s was chosen for an earlier requirement", in.parent(), id, min, step.Version)
}
return nil
}
if len(in.visiting) > 0 {
if done, err := in.installedSatisfies(id, min); done || err != nil {
return err
}
if in.opts.Registry == nil || in.opts.Offline {
return in.missingOffline(id)
}
}
step, requires, err := in.choose(id, pin, min)
if err != nil {
return err
}
in.moduleVersion[step.Module] = step.Version
in.visiting = append(in.visiting, id)
for _, dep := range sortedKeys(requires) {
if err := ValidatePluginID(dep); err != nil {
return fmt.Errorf("build: registry entry %s %s requires %w; refused", id, step.Version, err)
}
if err := in.resolve(dep, "", requires[dep]); err != nil {
return err
}
}
in.visiting = in.visiting[:len(in.visiting)-1]
in.planned[id] = step
in.plan.Steps = append(in.plan.Steps, step)
return nil
}
// parent names the plugin whose requirement is being resolved.
func (in *installer) parent() string {
if n := len(in.visiting); n > 0 {
return in.visiting[n-1]
}
return "the application"
}
// missingOffline is the error for a requirement that is not installed
// when the registry cannot be asked.
func (in *installer) missingOffline(id string) error {
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: %s requires %s, which is not installed, and %s", in.parent(), id, reason),
"Install the required plugin first, from its repository or with the registry enabled, then run this install again.",
"{bin} plugin:install "+id)
}
// installedSatisfies reports whether summer.yaml already lists id; it
// returns an error when the installed version is below min. An installed
// plugin whose version cannot be read (an in-app plugin) is accepted with
// a warning.
func (in *installer) installedSatisfies(id, min string) (bool, error) {
var pkg string
for _, p := range in.manifest.Plugins {
if p.ID == id {
pkg = p.Module
}
}
if pkg == "" {
return false, nil
}
if min == "" {
return true, nil
}
have, err := installedVersion(in.ctx, in.appDir, pkg)
if err != nil {
return true, err
}
if have == "" {
fmt.Fprintf(in.out, "warning: %s needs %s %s or newer; the installed version cannot be read, so make sure it is\n", in.parent(), id, min)
return true, nil
}
if !kiosk.Satisfies(have, min) {
return true, bonfire.WithHint(fmt.Errorf("build: %s needs %s %s or newer, but the application has %s; run summer plugin:update %s first", in.parent(), id, min, have, id),
"plugin:install does not move plugins the application already has.",
"{bin} plugin:update "+id)
}
return true, nil
}
// installedVersion returns the release of the module providing pkg: the
// go.mod require, a version replace from go.mod or go.work, or the base
// tag of `git describe` in a plugin directory that is its own repository
// (a submodule). It returns "" when the version cannot be told.
func installedVersion(ctx context.Context, appDir, pkg string) (string, error) {
gomod, err := editJSON(ctx, appDir, "mod", "edit", "-json", filepath.Join(appDir, "go.mod"))
if err != nil {
return "", fmt.Errorf("build: %w", err)
}
mod, version := "", ""
for _, r := range gomod.Require {
if (pkg == r.Path || strings.HasPrefix(pkg, r.Path+"/")) && len(r.Path) > len(mod) {
mod, version = r.Path, r.Version
}
}
if mod == "" {
return "", nil
}
localDir := ""
apply := func(replaces []struct {
Old struct{ Path string }
New struct{ Path, Version string }
}, base string) {
for _, r := range replaces {
if r.Old.Path != mod {
continue
}
if r.New.Version != "" {
version, localDir = r.New.Version, ""
continue
}
localDir = r.New.Path
if !filepath.IsAbs(localDir) {
localDir = filepath.Join(base, localDir)
}
}
}
apply(gomod.Replace, appDir)
if gw := findGoWork(appDir); gw != "" {
if work, err := editJSON(ctx, appDir, "work", "edit", "-json", gw); err == nil {
apply(work.Replace, filepath.Dir(gw))
}
}
if localDir == "" {
if kiosk.ValidVersion(version) {
return version, nil
}
return "", nil
}
if !isOwnRepository(ctx, localDir) {
return "", nil
}
return kiosk.BaseVersion(gitDescribe(ctx, localDir)), nil
}
func sortedKeys(m kiosk.Requires) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// 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) {
func (in *installer) choose(id, pin, min string) (InstallStep, kiosk.Requires, error) {
versions, err := in.opts.Registry.Versions(in.ctx, id)
if err != nil {
return InstallStep{}, fmt.Errorf("build: %w", err)
return InstallStep{}, nil, fmt.Errorf("build: %w", err)
}
var chosen kiosk.RegistryVersion
if pin != "" {
@@ -201,30 +376,36 @@ func (in *installer) choose(id, pin, min string) (InstallStep, error) {
if list == "" {
list = "none"
}
return InstallStep{}, fmt.Errorf("build: %s has no release %s; available: %s", id, pin, list)
return InstallStep{}, nil, 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)
return InstallStep{}, nil, 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)
return InstallStep{}, nil, 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)
if !kiosk.ValidVersion(v.Version) || !kiosk.Satisfies(v.Version, min) {
continue
}
// A plugin in a module another chosen plugin lives in must
// come from the same release of that module.
if want, ok := in.moduleVersion[v.Module]; ok && v.Version != want {
continue
}
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{}, nil, 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{}, nil, fmt.Errorf("build: %s has no release in the registry", id)
}
return InstallStep{}, in.frameworkError(id, newestOf(eligible))
return InstallStep{}, nil, in.frameworkError(id, newestOf(eligible))
}
chosen = best
}
@@ -236,9 +417,9 @@ func (in *installer) choose(id, pin, min string) (InstallStep, error) {
Mode: ModeModule,
}
if err := validateStep(step); err != nil {
return InstallStep{}, err
return InstallStep{}, nil, err
}
return step, nil
return step, chosen.Requires, nil
}
// frameworkError names the framework release v needs.
@@ -351,7 +532,7 @@ func ApplyInstall(ctx context.Context, plan *InstallPlan, out io.Writer) error {
if err := validateStep(s); err != nil {
return err
}
if s.Mode == ModeModule {
if s.Mode == ModeModule && !slices.Contains(getArgs, s.Module+"@"+s.Version) {
getArgs = append(getArgs, s.Module+"@"+s.Version)
}
entries = append(entries, Plugin{ID: s.ID, Module: s.Package})