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:
@@ -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})
|
||||
|
||||
Reference in New Issue
Block a user