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