From ae69c9349b6c7256764040695107f46b0a457455 Mon Sep 17 00:00:00 2001 From: Lance726 <158132389@qq.com> Date: Wed, 29 Apr 2026 17:18:53 +0800 Subject: [PATCH] Reinstall script plugins on init when the install URL changes --- cmd/init.go | 56 ++++++++++------ cmd/init_test.go | 2 +- internal/plugin/defaults.go | 23 +++++++ internal/plugin/defaults_test.go | 109 +++++++++++++++++++++++++++++++ 4 files changed, 170 insertions(+), 20 deletions(-) create mode 100644 internal/plugin/defaults_test.go diff --git a/cmd/init.go b/cmd/init.go index d4cbec4..9b7d1dd 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -49,50 +49,56 @@ Otherwise, the built-in default plugin list is used.`, manifest = &plugin.Manifest{} } - // Filter out already-installed plugins. - var missing []plugin.Plugin - var skipped []string - for _, p := range plugins { - if _, exists := manifest.Get(p.Name); exists { - skipped = append(skipped, p.Name) - } else { - missing = append(missing, p) - } - } + toInstall, toReinstall, skipped := plugin.CategorizeForInit(plugins, manifest) if len(skipped) > 0 { terminal.Infof("Skipping %d already installed plugin(s): %s", len(skipped), formatNames(skipped)) } - if len(missing) == 0 { + if len(toInstall) == 0 && len(toReinstall) == 0 { terminal.Success("All plugins are already installed.") return nil } - terminal.Infof("Installing %d missing plugin(s)...", len(missing)) + if len(toInstall) > 0 { + terminal.Infof("Installing %d new plugin(s)...", len(toInstall)) + } + if len(toReinstall) > 0 { + terminal.Infof("Reinstalling %d plugin(s) due to install URL changes:", len(toReinstall)) + for _, p := range toReinstall { + if entry, ok := manifest.Get(p.Name); ok { + fmt.Printf(" • %s: %s → %s\n", p.Name, entry.Source, p.Script) + } + } + } fmt.Println() var failed []string - for _, p := range missing { + runInstall := func(p plugin.Plugin, reinstall bool) { + verb := "Installing" + if reinstall { + verb = "Reinstalling" + } + spinner := uicli.NewSpinner(). WithStyle(uicli.SpinnerDots). WithColor(uicli.CyanColor). - WithMessage(fmt.Sprintf("Installing %q...", p.Name)). + WithMessage(fmt.Sprintf("%s %q...", verb, p.Name)). Start() inst, err := installer.FromPlugin(p) if err != nil { spinner.Error(fmt.Sprintf("Failed to install %q: %v", p.Name, err)) failed = append(failed, fmt.Sprintf("%s (%v)", p.Name, err)) - continue + return } version, installErr := inst.Install(p.Name) if installErr != nil { spinner.Error(fmt.Sprintf("Failed to install %q: %v", p.Name, installErr)) failed = append(failed, fmt.Sprintf("%s (%v)", p.Name, installErr)) - continue + return } manifest.Add(p.Name, version, inst.PluginType(), inst.Source(), "") @@ -100,13 +106,24 @@ Otherwise, the built-in default plugin list is used.`, manifest.SetDescription(p.Name, p.Description) } + doneVerb := "Installed" + if reinstall { + doneVerb = "Reinstalled" + } if path, ok := plugin.Find(p.Name); ok { - spinner.Success(fmt.Sprintf("Installed %q (%s)", p.Name, path)) + spinner.Success(fmt.Sprintf("%s %q (%s)", doneVerb, p.Name, path)) } else { - spinner.Success(fmt.Sprintf("Installed %q", p.Name)) + spinner.Success(fmt.Sprintf("%s %q", doneVerb, p.Name)) } } + for _, p := range toInstall { + runInstall(p, false) + } + for _, p := range toReinstall { + runInstall(p, true) + } + if err := manifest.Save(); err != nil { return fmt.Errorf("failed to save manifest: %w", err) } @@ -117,7 +134,8 @@ Otherwise, the built-in default plugin list is used.`, } fmt.Println() - terminal.Successf("All %d plugins installed!", len(missing)) + total := len(toInstall) + len(toReinstall) + terminal.Successf("All %d plugin(s) processed!", total) return nil }, } diff --git a/cmd/init_test.go b/cmd/init_test.go index 1b04440..8038d2d 100644 --- a/cmd/init_test.go +++ b/cmd/init_test.go @@ -8,7 +8,7 @@ import ( func TestFilterPluginsByTags(t *testing.T) { plugins := []plugin.Plugin{ - {Name: "core"}, // untagged + {Name: "core"}, // untagged {Name: "devops-tool", Tags: []string{"devops"}}, // single tag {Name: "email", Tags: []string{"devops", "email"}}, // multi tag {Name: "infra", Tags: []string{"infra"}}, // single tag diff --git a/internal/plugin/defaults.go b/internal/plugin/defaults.go index ba2e92a..9827f84 100644 --- a/internal/plugin/defaults.go +++ b/internal/plugin/defaults.go @@ -42,6 +42,29 @@ func LoadPluginsFromFile(path string) (*PluginList, error) { return &defaults, nil } +// CategorizeForInit splits the resolved plugin list into: +// - toInstall: not present in the manifest. +// - toReinstall: present with type=script but the local source URL differs +// from the remote-declared script URL. Drift for brew/npm/github is +// intentionally not detected here — those install methods touch +// system-wide state and need explicit user action to switch source. +// - skipped: already installed and matching, or non-script type. +func CategorizeForInit(plugins []Plugin, manifest *Manifest) (toInstall, toReinstall []Plugin, skipped []string) { + for _, p := range plugins { + entry, exists := manifest.Get(p.Name) + if !exists { + toInstall = append(toInstall, p) + continue + } + if p.Script != "" && entry.Type == SourceTypeScript && entry.Source != p.Script { + toReinstall = append(toReinstall, p) + continue + } + skipped = append(skipped, p.Name) + } + return +} + // FilterByTags returns plugins that should be installed based on the given // tags. Untagged plugins are always included. Tagged plugins are only included // if tags are provided and they share at least one tag with the provided list. diff --git a/internal/plugin/defaults_test.go b/internal/plugin/defaults_test.go new file mode 100644 index 0000000..9eca3a9 --- /dev/null +++ b/internal/plugin/defaults_test.go @@ -0,0 +1,109 @@ +package plugin + +import ( + "reflect" + "testing" +) + +func TestCategorizeForInit(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + plugins []Plugin + manifest *Manifest + wantInstall []string + wantReinstall []string + wantSkipped []string + }{ + { + name: "new plugin gets installed", + plugins: []Plugin{ + {Name: "auth", Script: "https://example.com/install.sh"}, + }, + manifest: &Manifest{}, + wantInstall: []string{"auth"}, + }, + { + name: "matching script URL is skipped", + plugins: []Plugin{ + {Name: "auth", Script: "https://example.com/install.sh"}, + }, + manifest: &Manifest{Plugins: []ManifestEntry{ + {Name: "auth", Type: SourceTypeScript, Source: "https://example.com/install.sh"}, + }}, + wantSkipped: []string{"auth"}, + }, + { + name: "drifted script URL triggers reinstall", + plugins: []Plugin{ + {Name: "auth", Script: "https://NEW.example.com/install.sh"}, + }, + manifest: &Manifest{Plugins: []ManifestEntry{ + {Name: "auth", Type: SourceTypeScript, Source: "https://OLD.example.com/install.sh"}, + }}, + wantReinstall: []string{"auth"}, + }, + { + name: "brew-installed plugin with brew remote is skipped", + plugins: []Plugin{ + {Name: "lint", Brew: "golangci-lint"}, + }, + manifest: &Manifest{Plugins: []ManifestEntry{ + {Name: "lint", Type: SourceTypeBrew, Source: "golangci-lint"}, + }}, + wantSkipped: []string{"lint"}, + }, + { + name: "type mismatch (local brew, remote script) is skipped", + plugins: []Plugin{ + {Name: "lint", Script: "https://example.com/lint.sh"}, + }, + manifest: &Manifest{Plugins: []ManifestEntry{ + {Name: "lint", Type: SourceTypeBrew, Source: "golangci-lint"}, + }}, + wantSkipped: []string{"lint"}, + }, + { + name: "mixed list: install + reinstall + skip", + plugins: []Plugin{ + {Name: "auth", Script: "https://NEW/install.sh"}, + {Name: "employee", Script: "https://example.com/employee.sh"}, + {Name: "newbie", Script: "https://example.com/newbie.sh"}, + }, + manifest: &Manifest{Plugins: []ManifestEntry{ + {Name: "auth", Type: SourceTypeScript, Source: "https://OLD/install.sh"}, + {Name: "employee", Type: SourceTypeScript, Source: "https://example.com/employee.sh"}, + }}, + wantInstall: []string{"newbie"}, + wantReinstall: []string{"auth"}, + wantSkipped: []string{"employee"}, + }, + } + + names := func(ps []Plugin) []string { + if ps == nil { + return nil + } + out := make([]string, len(ps)) + for i, p := range ps { + out[i] = p.Name + } + return out + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + install, reinstall, skipped := CategorizeForInit(tt.plugins, tt.manifest) + if got := names(install); !reflect.DeepEqual(got, tt.wantInstall) { + t.Errorf("install: got %v, want %v", got, tt.wantInstall) + } + if got := names(reinstall); !reflect.DeepEqual(got, tt.wantReinstall) { + t.Errorf("reinstall: got %v, want %v", got, tt.wantReinstall) + } + if !reflect.DeepEqual(skipped, tt.wantSkipped) { + t.Errorf("skipped: got %v, want %v", skipped, tt.wantSkipped) + } + }) + } +}