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

@@ -106,7 +106,23 @@ The command asks the registry for the plugin's releases and picks the newest one
The plugin is installed as a Go module: one `go get <module>@<version>` in the application, then an entry with its ID and package in `summer.yaml`. The edit appends to the `plugins:` list and keeps the file's comments and blank lines. Run `summer build` afterwards: it generates the import, and the new require in `go.mod` loses its `// indirect` marker the next time you run `go mod tidy`. A plugin that is already in `summer.yaml` is not installed again.
Before changing anything, the command prints each plugin it is about to add as `id@version (module)` and asks for confirmation. `--yes` skips the question. A run that cannot ask (no terminal, `-n` or `-q`) stops with an error unless `--yes` is given.
### Required plugins
A plugin's `requires` (from its `plugin.yaml`, as the registry lists it for the chosen release) are installed with it. For each required plugin the application does not have yet, the command picks the newest release that the framework version meets and that is at least the required minimum, then resolves that plugin's own requirements the same way. Required plugins come first in the list and in `summer.yaml`, and all module installs go through a single `go get`, so Go's minimal version selection sees the whole set. Two plugins that ship in the same module are installed from the same release of it.
A required plugin the application already has is not touched, but its version must be at least the minimum: the go.mod require for a module install, the tag `git describe` finds for a plugin checked out as a submodule. When it is older, the command stops and asks you to update that plugin first. A version it cannot read, such as that of a plugin kept in the application's own repository, is accepted with a warning. Requirements that form a cycle in the registry data are reported as an error.
### Confirmation
Before changing anything, the command prints each plugin it is about to add, one line per plugin:
```text
Installing 2 plugins into /srv/acme (framework v0.6.2):
acme.translate@v0.2.1 (module)
acme.blog@v0.3.0 (module)
```
Then it asks for confirmation, with "no" as the default. `--yes` skips the question. A run that cannot ask (no terminal, `-n` or `-q`) stops with an error naming `--yes` unless it is given. Declining changes nothing.
### The registry setting

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})

View File

@@ -8,6 +8,7 @@ import (
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/kiosk"
)
@@ -55,6 +56,20 @@ func manifestIDs(t *testing.T, appDir string) []string {
return ids
}
// fullError is the error text plus a bonfire hint's explanation and Try
// line, where the hints name commands and flags.
func fullError(err error) string {
if err == nil {
return ""
}
text := err.Error()
var h *bonfire.HintError
if errors.As(err, &h) {
text += "\n" + h.Explanation + "\n" + h.Try
}
return text
}
func TestInstallSmoke(t *testing.T) {
blogV1 := regVersion("v0.1.0", "v0.5.0", blogModule, blogModule, nil)
blogV2 := regVersion("v0.2.0", "v9.0.0", blogModule, blogModule, nil)
@@ -130,6 +145,214 @@ func TestInstallSmoke(t *testing.T) {
}
})
translateModule := "example.com/acme/translate"
depsRegistry := func(t *testing.T) *fakeReg {
return fakeRegistry(t, map[string]fakePlugin{
"acme.blog": regPlugin("acme.blog", "https://example.com/acme/blog.git",
regVersion("v0.1.0", "v0.5.0", blogModule, blogModule, kiosk.Requires{"acme.translate": "v0.1.0"})),
"acme.translate": regPlugin("acme.translate", "https://example.com/acme/translate.git",
regVersion("v0.2.0", "v9.0.0", translateModule, translateModule, nil),
regVersion("v0.1.1", "v0.5.0", translateModule, translateModule, nil),
regVersion("v0.1.0", "v0.5.0", translateModule, translateModule, nil),
regVersion("v0.0.9", "v0.5.0", translateModule, translateModule, nil)),
})
}
stepList := func(p *InstallPlan) string {
var parts []string
for _, s := range p.Steps {
parts = append(parts, s.ID+"@"+s.Version+"/"+s.Mode)
}
return strings.Join(parts, ",")
}
t.Run("deps", func(t *testing.T) {
app := helloCopy(t)
reg := depsRegistry(t)
var out bytes.Buffer
plan, err := PlanInstall(t.Context(), InstallOptions{StartDir: app, Arg: "acme.blog", Yes: true, Registry: reg.Client, Out: &out})
if err != nil {
t.Fatalf("plan: %v\n%s", err, out.String())
}
if got := stepList(plan); got != "acme.translate@v0.1.1/module,acme.blog@v0.1.0/module" {
t.Fatalf("steps = %s", got)
}
if !strings.Contains(out.String(), "acme.translate@v0.1.1 (module)\n acme.blog@v0.1.0 (module)") {
t.Fatalf("summary:\n%s", out.String())
}
if testing.Short() {
return
}
proxy := t.TempDir()
writeProxyModule(t, proxy, blogModule, "v0.1.0", fixturePluginFiles(blogModule, "blog", "acme.blog", "v0.5.0", translateModule+" v0.1.0"))
writeProxyModule(t, proxy, translateModule, "v0.1.0", fixturePluginFiles(translateModule, "translate", "acme.translate", "v0.5.0"))
writeProxyModule(t, proxy, translateModule, "v0.1.1", fixturePluginFiles(translateModule, "translate", "acme.translate", "v0.5.0"))
offlineGoEnv(t, proxy)
if err := ApplyInstall(t.Context(), plan, &out); err != nil {
t.Fatalf("apply: %v\n%s", err, out.String())
}
if n := strings.Count(out.String(), "go get "); n != 1 {
t.Fatalf("want one go get, got %d:\n%s", n, out.String())
}
gomod, _ := os.ReadFile(filepath.Join(app, "go.mod"))
for _, want := range []string{blogModule + " v0.1.0", translateModule + " v0.1.1"} {
if !bytes.Contains(gomod, []byte(want)) {
t.Fatalf("go.mod lacks %s:\n%s", want, gomod)
}
}
ids := manifestIDs(t, app)
if got := strings.Join(ids[len(ids)-2:], ","); got != "acme.translate="+translateModule+",acme.blog="+blogModule {
t.Fatalf("summer.yaml order = %v", ids)
}
})
t.Run("installed_old", func(t *testing.T) {
app := helloCopy(t)
if err := appendManifestPlugins(filepath.Join(app, manifestName), []Plugin{{ID: "acme.translate", Module: translateModule}}); err != nil {
t.Fatal(err)
}
if err := runGo(t.Context(), app, "mod", "edit", "-require="+translateModule+"@v0.0.9"); err != nil {
t.Fatal(err)
}
reg := depsRegistry(t)
_, err := PlanInstall(t.Context(), InstallOptions{StartDir: app, Arg: "acme.blog", Yes: true, Registry: reg.Client})
if err == nil || !strings.Contains(err.Error(), "v0.0.9") || !strings.Contains(fullError(err), "summer plugin:update acme.translate") {
t.Fatalf("err = %v (%s), want installed v0.0.9 too old and a plugin:update hint", err, fullError(err))
}
// At the minimum it is fine, and only blog is planned.
if err := runGo(t.Context(), app, "mod", "edit", "-require="+translateModule+"@v0.1.0"); err != nil {
t.Fatal(err)
}
plan, err := PlanInstall(t.Context(), InstallOptions{StartDir: app, Arg: "acme.blog", Yes: true, Registry: reg.Client})
if err != nil {
t.Fatal(err)
}
if got := stepList(plan); got != "acme.blog@v0.1.0/module" {
t.Fatalf("steps = %s", got)
}
})
t.Run("cycle", func(t *testing.T) {
app := helloCopy(t)
reg := fakeRegistry(t, map[string]fakePlugin{
"acme.one": regPlugin("acme.one", "", regVersion("v0.1.0", "v0.5.0", "example.com/acme/one", "example.com/acme/one", kiosk.Requires{"acme.two": "v0.1.0"})),
"acme.two": regPlugin("acme.two", "", regVersion("v0.1.0", "v0.5.0", "example.com/acme/two", "example.com/acme/two", kiosk.Requires{"acme.one": "v0.1.0"})),
})
done := make(chan error, 1)
go func() {
_, err := PlanInstall(t.Context(), InstallOptions{StartDir: app, Arg: "acme.one", Yes: true, Registry: reg.Client})
done <- err
}()
err := <-done
if err == nil || !strings.Contains(err.Error(), "cycle") || !strings.Contains(err.Error(), "acme.one -> acme.two -> acme.one") {
t.Fatalf("err = %v, want a reported cycle", err)
}
})
t.Run("pin", func(t *testing.T) {
app := helloCopy(t)
reg := depsRegistry(t)
plan, err := PlanInstall(t.Context(), InstallOptions{StartDir: app, Arg: "acme.translate@v0.1.0", Yes: true, Registry: reg.Client})
if err != nil {
t.Fatal(err)
}
if got := stepList(plan); got != "acme.translate@v0.1.0/module" {
t.Fatalf("steps = %s", got)
}
_, err = PlanInstall(t.Context(), InstallOptions{StartDir: app, Arg: "acme.translate@v0.7.0", Yes: true, Registry: reg.Client})
if err == nil || !strings.Contains(err.Error(), "v0.7.0") || !strings.Contains(err.Error(), "v0.2.0, v0.1.1, v0.1.0, v0.0.9") {
t.Fatalf("unknown pin: err = %v, want the available versions listed", err)
}
for _, bad := range []string{"acme.translate@latest", "acme.translate@v1.0", "Acme.Translate", "acme.translate@-v1.0.0"} {
if _, err := PlanInstall(t.Context(), InstallOptions{StartDir: app, Arg: bad, Yes: true, Registry: reg.Client}); err == nil {
t.Fatalf("%s: want an error", bad)
}
}
})
t.Run("unknown", func(t *testing.T) {
app := helloCopy(t)
reg := depsRegistry(t)
_, err := PlanInstall(t.Context(), InstallOptions{StartDir: app, Arg: "acme.nope", Yes: true, Registry: reg.Client})
if err == nil || !strings.Contains(err.Error(), "not in the registry") || !strings.Contains(err.Error(), reg.URL) {
t.Fatalf("err = %v, want not in the registry at %s", err, reg.URL)
}
})
t.Run("prompt", func(t *testing.T) {
app := helloCopy(t)
reg := depsRegistry(t)
before, _ := os.ReadFile(filepath.Join(app, manifestName))
var out bytes.Buffer
_, err := PlanInstall(t.Context(), InstallOptions{StartDir: app, Arg: "acme.blog", Registry: reg.Client, Out: &out})
if err == nil || !strings.Contains(err.Error(), "--yes") {
t.Fatalf("non-interactive without --yes: err = %v", err)
}
if !strings.Contains(out.String(), "acme.blog@v0.1.0 (module)") {
t.Fatalf("plan not printed before the error:\n%s", out.String())
}
asked := ""
_, err = PlanInstall(t.Context(), InstallOptions{StartDir: app, Arg: "acme.blog", Registry: reg.Client, Interactive: true,
Confirm: func(q string) (bool, error) { asked = q; return false, nil }})
if !errors.Is(err, ErrCancelled) || asked == "" {
t.Fatalf("declined prompt: err = %v, asked %q", err, asked)
}
plan, err := PlanInstall(t.Context(), InstallOptions{StartDir: app, Arg: "acme.blog", Registry: reg.Client, Interactive: true,
Confirm: func(string) (bool, error) { return true, nil }})
if err != nil || len(plan.Steps) != 2 {
t.Fatalf("accepted prompt: plan %+v, err %v", plan, err)
}
after, _ := os.ReadFile(filepath.Join(app, manifestName))
if !bytes.Equal(before, after) {
t.Fatal("planning changed summer.yaml")
}
})
t.Run("offline", func(t *testing.T) {
app := helloCopy(t)
reg := depsRegistry(t)
_, err := PlanInstall(t.Context(), InstallOptions{StartDir: app, Arg: "acme.blog", Yes: true, Offline: true, Registry: reg.Client})
if err == nil || !strings.Contains(err.Error(), "--offline") || !strings.Contains(fullError(err), "git") {
t.Fatalf("offline: err = %v (%s)", err, fullError(err))
}
if n := len(reg.Requests()); n != 0 {
t.Fatalf("offline made %d registry requests", n)
}
_, err = PlanInstall(t.Context(), InstallOptions{StartDir: app, Arg: "acme.blog", Yes: true})
if err == nil || !strings.Contains(err.Error(), "plugins.registry_url") || !strings.Contains(fullError(err), "git") {
t.Fatalf("disabled registry: err = %v (%s)", err, fullError(err))
}
})
t.Run("invalid", func(t *testing.T) {
app := helloCopy(t)
for name, v := range map[string]kiosk.RegistryVersion{
"dash module": regVersion("v0.1.0", "v0.5.0", "-modfile=/tmp/x", "-modfile=/tmp/x", nil),
"bad version": regVersion("v0.1", "v0.5.0", blogModule, blogModule, nil),
"outside": regVersion("v0.1.0", "v0.5.0", blogModule, "example.com/other/pkg", nil),
"bad requireid": regVersion("v0.1.0", "v0.5.0", blogModule, blogModule, kiosk.Requires{"-x": "v0.1.0"}),
} {
reg := fakeRegistry(t, map[string]fakePlugin{"acme.blog": regPlugin("acme.blog", "", v)})
plan, err := PlanInstall(t.Context(), InstallOptions{StartDir: app, Arg: "acme.blog", Yes: true, Registry: reg.Client})
if err == nil {
t.Fatalf("%s: accepted, plan %+v", name, plan.Steps)
}
if name == "bad version" {
if !strings.Contains(err.Error(), "no release") && !strings.Contains(err.Error(), "refused") {
t.Fatalf("%s: err = %v", name, err)
}
continue
}
if !strings.Contains(err.Error(), "refused") {
t.Fatalf("%s: err = %v, want refused", name, err)
}
}
// ApplyInstall checks again, so a hand-built plan cannot reach go.
bad := &InstallPlan{AppDir: app, Steps: []InstallStep{{ID: "acme.blog", Version: "v0.1.0", Module: "-x", Package: "-x", Mode: ModeModule}}}
if err := ApplyInstall(t.Context(), bad, nil); err == nil || !strings.Contains(err.Error(), "refused") {
t.Fatalf("apply: err = %v", err)
}
})
t.Run("append_manifest", func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, manifestName)