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//. 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 ") } // 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// 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 }