Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 37 additions & 19 deletions cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,64 +49,81 @@ 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(), "")
if p.Description != "" {
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)
}
Expand All @@ -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
},
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions internal/plugin/defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
109 changes: 109 additions & 0 deletions internal/plugin/defaults_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}