Files
summercms/internal/build/install.go
Jakub Zych 9b23e877a6 feat(build): summer plugin:install for registry plugins as Go modules
- kiosk.Client reads the registry API v1 over fetchguard (trusted mode,
  4 MiB, 10 s, no redirects); kiosk.RegistryURL reads plugins.registry_url
  (default https://summercms.io, empty disables)
- build.PlanInstall/ApplyInstall pick the newest release the effective
  framework satisfies (or a pin), validate registry strings, go get it and
  append it to summer.yaml
- summer.yaml edits (plugin:add and plugin:install) keep comments and
  blank lines
- TestInstallSmoke/module installs from a file:// GOPROXY and builds
2026-10-29 18:22:01 +01:00

375 lines
12 KiB
Go

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/<vendor>/<name>.
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 <git-url>")
}
// 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
}