The comment-keeping summer.yaml edit refused a flow-style plugins list
(plugins: [{id: a, module: b}]) that plugin:add used to handle with a
full rewrite. The edit now replaces an inline list, over one or several
lines, with a block list of its items plus the new ones and keeps every
other line. A list it cannot edit in place (a tagged one, say) falls
back to the full SaveManifest rewrite with a note that the comments were
not kept. plugin:install gets the same behaviour.
750 lines
25 KiB
Go
750 lines
25 KiB
Go
package build
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"sort"
|
|
"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
|
|
|
|
// temps are checkouts under plugins/ made while planning or applying;
|
|
// whatever is left of them is removed at the end.
|
|
temps []string
|
|
interactive bool
|
|
// snap is what ApplyInstall restores when it fails.
|
|
snap *appSnapshot
|
|
}
|
|
|
|
// InstallStep is one plugin to add.
|
|
type InstallStep struct {
|
|
ID string
|
|
Version string
|
|
Module string
|
|
Package string
|
|
Repository string
|
|
Mode string
|
|
|
|
// clone is the checkout a git URL step was planned from.
|
|
clone string
|
|
// existing marks a plugin in a module the application already has (a
|
|
// go.mod require or a submodule checkout): it is installed at that
|
|
// version and ApplyInstall fetches nothing for it, only adds its
|
|
// summer.yaml entry (CR-02).
|
|
existing bool
|
|
}
|
|
|
|
// 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
|
|
|
|
// 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
|
|
// installed holds the modules the application already has, by module
|
|
// path: a plugin in one of them installs at the version the
|
|
// application has, never moving it.
|
|
installed map[string]installedModule
|
|
// source installs registry plugins as submodules: --source, or the
|
|
// requirements of a git URL install.
|
|
source bool
|
|
}
|
|
|
|
// 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), 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.
|
|
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}
|
|
|
|
in.planned = map[string]InstallStep{}
|
|
in.moduleVersion = map[string]string{}
|
|
if in.installed, err = installedModules(ctx, appDir, m); err != nil {
|
|
return nil, err
|
|
}
|
|
in.plan.interactive = opts.Interactive
|
|
in.source = opts.Source
|
|
ok := false
|
|
defer func() {
|
|
if !ok {
|
|
in.plan.removeTemps()
|
|
}
|
|
}()
|
|
|
|
arg := strings.TrimSpace(opts.Arg)
|
|
if isGitURL(arg) {
|
|
in.source = true
|
|
if err := in.planGitURL(arg); err != nil {
|
|
return nil, err
|
|
}
|
|
} else {
|
|
id, pin, err := parseInstallArg(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
|
|
}
|
|
if in.source {
|
|
if err := requireGitRepo(ctx, appDir); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := in.resolve(id, pin, ""); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if err := in.confirm(); err != nil {
|
|
return nil, err
|
|
}
|
|
ok = true
|
|
return in.plan, nil
|
|
}
|
|
|
|
// installedModule is a module the application already has, with the
|
|
// summer.yaml plugin that brought it.
|
|
type installedModule struct {
|
|
src pluginSource
|
|
by string
|
|
}
|
|
|
|
// release is the version a plugin of the module installs at: the go.mod
|
|
// release of a module install, the base tag of a submodule checkout; ""
|
|
// when there is none to pin (a pseudo-version, an untagged checkout, a
|
|
// local directory).
|
|
func (im installedModule) release() string {
|
|
switch im.src.Kind {
|
|
case ModeModule, ModeSubmodule:
|
|
return releaseOrEmpty(im.src.Version)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// installedModules maps every module that provides a summer.yaml plugin
|
|
// to how it is installed (see installedSource). Packages of the
|
|
// application module itself are left out.
|
|
func installedModules(ctx context.Context, appDir string, m Manifest) (map[string]installedModule, error) {
|
|
out := map[string]installedModule{}
|
|
for _, p := range m.Plugins {
|
|
src, err := installedSource(ctx, appDir, p.Module)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if src.Module == "" {
|
|
continue
|
|
}
|
|
if _, ok := out[src.Module]; !ok {
|
|
out[src.Module] = installedModule{src: src, by: p.ID}
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// removeTemps deletes the leftover temporary checkouts of the plan.
|
|
func (p *InstallPlan) removeTemps() {
|
|
for _, dir := range p.temps {
|
|
_ = os.RemoveAll(dir)
|
|
}
|
|
p.temps = 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>")
|
|
}
|
|
|
|
// 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)
|
|
if err := in.resolveRequires(id, step.Version, requires); 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
|
|
}
|
|
|
|
// resolveRequires resolves the requirements of id (at version), in ID
|
|
// order.
|
|
func (in *installer) resolveRequires(id, version string, requires kiosk.Requires) error {
|
|
for _, dep := range sortedKeys(requires) {
|
|
if err := ValidatePluginID(dep); err != nil {
|
|
return fmt.Errorf("build: %s %s requires %w; refused", id, displayRef(version), err)
|
|
}
|
|
if err := in.resolve(dep, "", requires[dep]); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
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 installed release of the plugin package
|
|
// pkg (see installedSource): the module version of a module install, the
|
|
// base tag of git describe for a submodule, "" for an in-app plugin or a
|
|
// module at a pseudo-version.
|
|
func installedVersion(ctx context.Context, appDir, pkg string) (string, error) {
|
|
src, err := installedSource(ctx, appDir, pkg)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return releaseOrEmpty(src.Version), 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, kiosk.Requires, error) {
|
|
versions, err := in.opts.Registry.Versions(in.ctx, id)
|
|
if err != nil {
|
|
return InstallStep{}, nil, 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{}, nil, fmt.Errorf("build: %s has no release %s; available: %s", id, pin, list)
|
|
}
|
|
if !kiosk.Satisfies(in.fw, chosen.Framework) {
|
|
return InstallStep{}, nil, in.frameworkError(id, chosen)
|
|
}
|
|
if !kiosk.Satisfies(pin, min) {
|
|
return InstallStep{}, nil, fmt.Errorf("build: %s %s is older than the required minimum %s", id, pin, min)
|
|
}
|
|
if im, ok := in.installed[chosen.Module]; ok && im.release() != pin {
|
|
return InstallStep{}, nil, in.installedModuleError(id, im)
|
|
}
|
|
} else {
|
|
var eligible []kiosk.RegistryVersion
|
|
var held *installedModule
|
|
for _, v := range versions {
|
|
if !kiosk.ValidVersion(v.Version) || !kiosk.Satisfies(v.Version, min) {
|
|
continue
|
|
}
|
|
// A plugin in a module the application already has installs
|
|
// at the version it has: installing never moves the plugins
|
|
// already there.
|
|
if im, ok := in.installed[v.Module]; ok && v.Version != im.release() {
|
|
held = &im
|
|
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 && held != nil {
|
|
return InstallStep{}, nil, in.installedModuleError(id, *held)
|
|
}
|
|
if !ok {
|
|
if len(eligible) == 0 {
|
|
if min != "" {
|
|
return InstallStep{}, nil, 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 in the registry", id)
|
|
}
|
|
return InstallStep{}, nil, in.frameworkError(id, newestOf(eligible))
|
|
}
|
|
chosen = best
|
|
}
|
|
step := InstallStep{
|
|
ID: id,
|
|
Version: chosen.Version,
|
|
Module: chosen.Module,
|
|
Package: chosen.Package,
|
|
Mode: ModeModule,
|
|
}
|
|
if im, ok := in.installed[chosen.Module]; ok {
|
|
// The module is there already, as a go.mod require or a
|
|
// checkout: nothing is fetched or cloned, whatever --source says.
|
|
step.existing, step.Mode = true, im.src.Kind
|
|
if im.src.Kind == ModeSubmodule {
|
|
rel := strings.TrimPrefix(strings.TrimPrefix(chosen.Package, chosen.Module), "/")
|
|
if !dirExists(filepath.Join(im.src.Dir, filepath.FromSlash(rel))) {
|
|
return InstallStep{}, nil, bonfire.WithHint(fmt.Errorf("build: %s lives in %s, which the application has checked out at %s (for %s), and that checkout has no package %s", id, chosen.Module, displayRef(im.src.Version), im.by, chosen.Package),
|
|
"plugin:install does not move plugins the application already has. Update the checkout with plugin:update first.",
|
|
"{bin} plugin:update "+im.by)
|
|
}
|
|
}
|
|
if err := validateStep(step); err != nil {
|
|
return InstallStep{}, nil, err
|
|
}
|
|
return step, chosen.Requires, nil
|
|
}
|
|
if in.source {
|
|
detail, err := in.opts.Registry.Plugin(in.ctx, id)
|
|
if err != nil {
|
|
return InstallStep{}, nil, fmt.Errorf("build: %w", err)
|
|
}
|
|
if err := validateRepositoryURL(id, detail.Repository); err != nil {
|
|
return InstallStep{}, nil, err
|
|
}
|
|
step.Mode, step.Repository = ModeSubmodule, detail.Repository
|
|
if dir := filepath.Join(in.appDir, "plugins", pluginVendor(id), pluginName(id)); dirExists(dir) {
|
|
return InstallStep{}, nil, fmt.Errorf("build: %s already exists; remove it or register it with plugin:add", dir)
|
|
}
|
|
}
|
|
if err := validateStep(step); err != nil {
|
|
return InstallStep{}, nil, err
|
|
}
|
|
return step, chosen.Requires, nil
|
|
}
|
|
|
|
// installedModuleError is the error for a plugin whose module the
|
|
// application already has, when no release of the plugin matches the
|
|
// version the application has.
|
|
func (in *installer) installedModuleError(id string, im installedModule) error {
|
|
have := im.release()
|
|
why := "the registry has no release of " + id + " at " + have
|
|
if have == "" {
|
|
why = "that version is not a release plugin:install can pin"
|
|
have = displayVersion(im.src.Version)
|
|
if im.src.Kind == sourceLocal {
|
|
have = "a local directory"
|
|
}
|
|
}
|
|
return bonfire.WithHint(fmt.Errorf("build: %s lives in %s, which the application already has at %s (for %s), and %s", id, im.src.Module, have, im.by, why),
|
|
"plugin:install does not move plugins the application already has. Move "+im.by+" to a release that ships "+id+" with plugin:update, then install it.",
|
|
"{bin} plugin:update "+im.by)
|
|
}
|
|
|
|
// 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 {
|
|
// A git URL install without a fitting release tag takes the default
|
|
// branch head, which has no version.
|
|
headOfClone := s.Version == "" && s.clone != ""
|
|
if !kiosk.ValidVersion(s.Version) && !headOfClone {
|
|
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)
|
|
}
|
|
switch s.Mode {
|
|
case ModeModule:
|
|
case ModeSubmodule:
|
|
if s.existing {
|
|
break
|
|
}
|
|
if err := validateGitURL(s.Repository); err != nil {
|
|
return fmt.Errorf("build: %s: %w; refused", s.ID, err)
|
|
}
|
|
default:
|
|
return fmt.Errorf("build: %s: unknown install mode %q", s.ID, s.Mode)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// summary renders one line per step.
|
|
func (p *InstallPlan) summary() string {
|
|
var b strings.Builder
|
|
for _, s := range p.Steps {
|
|
mode := s.Mode
|
|
if s.existing {
|
|
mode += ", module already in the application"
|
|
}
|
|
fmt.Fprintf(&b, " %s@%s (%s)\n", s.ID, displayRef(s.Version), 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 dirExists(dir string) bool {
|
|
_, err := os.Stat(dir)
|
|
return err == nil
|
|
}
|
|
|
|
func displayVersion(v string) string {
|
|
if v == "" {
|
|
return "unknown"
|
|
}
|
|
return v
|
|
}
|
|
|
|
// ApplyInstall carries out a plan from PlanInstall. Submodule steps are
|
|
// cloned at their tag, added as git submodules under
|
|
// plugins/<vendor>/<name> and registered in go.mod (require at the tag
|
|
// plus a local replace) and go.work, followed by the go.work replace
|
|
// overrides the workspace needs. Module steps go through one go get, so
|
|
// minimal version selection sees the whole set. The summer.yaml entries
|
|
// are appended last, in plan order. It does not run go mod tidy: until
|
|
// summer build imports the new packages, tidy would drop them. When a
|
|
// step fails, the files it edits (go.mod, go.sum, go.work, summer.yaml,
|
|
// .gitmodules) are restored and the submodules this run added are
|
|
// removed again; what cannot be undone is named in the error.
|
|
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")
|
|
}
|
|
defer plan.removeTemps()
|
|
if out == nil {
|
|
out = io.Discard
|
|
}
|
|
var getArgs []string
|
|
var entries []Plugin
|
|
submodules := false
|
|
for _, s := range plan.Steps {
|
|
if err := validateStep(s); err != nil {
|
|
return err
|
|
}
|
|
entries = append(entries, Plugin{ID: s.ID, Module: s.Package})
|
|
if s.existing {
|
|
continue
|
|
}
|
|
switch s.Mode {
|
|
case ModeModule:
|
|
if !slices.Contains(getArgs, s.Module+"@"+s.Version) {
|
|
getArgs = append(getArgs, s.Module+"@"+s.Version)
|
|
}
|
|
case ModeSubmodule:
|
|
submodules = true
|
|
}
|
|
}
|
|
snap, err := snapshotApp(ctx, plan.AppDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
plan.snap = snap
|
|
// A failure from here on puts the application back (WR-02).
|
|
fail := func(err error) error { return snap.undo(ctx, out, err) }
|
|
if submodules {
|
|
if err := requireGitRepo(ctx, plan.AppDir); err != nil {
|
|
return err
|
|
}
|
|
done := map[string]bool{}
|
|
for i := range plan.Steps {
|
|
s := &plan.Steps[i]
|
|
if s.Mode != ModeSubmodule || s.existing || done[s.Module] {
|
|
continue
|
|
}
|
|
if err := applySubmodule(ctx, plan, s, plan.interactive, out); err != nil {
|
|
return fail(err)
|
|
}
|
|
done[s.Module] = true
|
|
}
|
|
if err := refreshWorkOverrides(ctx, plan.AppDir, out); err != nil {
|
|
return fail(err)
|
|
}
|
|
}
|
|
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 fail(err)
|
|
}
|
|
}
|
|
rewrote, err := appendManifestPlugins(filepath.Join(plan.AppDir, manifestName), entries)
|
|
if err != nil {
|
|
return fail(err)
|
|
}
|
|
if rewrote {
|
|
fmt.Fprintf(out, "note: %s had an inline plugins list summer could not edit in place; it was written again and its comments were not kept\n", manifestName)
|
|
}
|
|
ids := make([]string, len(plan.Steps))
|
|
for i, s := range plan.Steps {
|
|
ids[i] = s.ID + "@" + displayRef(s.Version)
|
|
}
|
|
fmt.Fprintf(out, "Installed %s; run summer build\n", strings.Join(ids, ", "))
|
|
return nil
|
|
}
|