Skip to content

Commit 3837cfc

Browse files
authored
Reinstall script plugins on init when the install URL changes (#15)
1 parent d3445c6 commit 3837cfc

4 files changed

Lines changed: 170 additions & 20 deletions

File tree

cmd/init.go

Lines changed: 37 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -49,64 +49,81 @@ Otherwise, the built-in default plugin list is used.`,
4949
manifest = &plugin.Manifest{}
5050
}
5151

52-
// Filter out already-installed plugins.
53-
var missing []plugin.Plugin
54-
var skipped []string
55-
for _, p := range plugins {
56-
if _, exists := manifest.Get(p.Name); exists {
57-
skipped = append(skipped, p.Name)
58-
} else {
59-
missing = append(missing, p)
60-
}
61-
}
52+
toInstall, toReinstall, skipped := plugin.CategorizeForInit(plugins, manifest)
6253

6354
if len(skipped) > 0 {
6455
terminal.Infof("Skipping %d already installed plugin(s): %s", len(skipped), formatNames(skipped))
6556
}
6657

67-
if len(missing) == 0 {
58+
if len(toInstall) == 0 && len(toReinstall) == 0 {
6859
terminal.Success("All plugins are already installed.")
6960
return nil
7061
}
7162

72-
terminal.Infof("Installing %d missing plugin(s)...", len(missing))
63+
if len(toInstall) > 0 {
64+
terminal.Infof("Installing %d new plugin(s)...", len(toInstall))
65+
}
66+
if len(toReinstall) > 0 {
67+
terminal.Infof("Reinstalling %d plugin(s) due to install URL changes:", len(toReinstall))
68+
for _, p := range toReinstall {
69+
if entry, ok := manifest.Get(p.Name); ok {
70+
fmt.Printf(" • %s: %s → %s\n", p.Name, entry.Source, p.Script)
71+
}
72+
}
73+
}
7374
fmt.Println()
7475

7576
var failed []string
7677

77-
for _, p := range missing {
78+
runInstall := func(p plugin.Plugin, reinstall bool) {
79+
verb := "Installing"
80+
if reinstall {
81+
verb = "Reinstalling"
82+
}
83+
7884
spinner := uicli.NewSpinner().
7985
WithStyle(uicli.SpinnerDots).
8086
WithColor(uicli.CyanColor).
81-
WithMessage(fmt.Sprintf("Installing %q...", p.Name)).
87+
WithMessage(fmt.Sprintf("%s %q...", verb, p.Name)).
8288
Start()
8389

8490
inst, err := installer.FromPlugin(p)
8591
if err != nil {
8692
spinner.Error(fmt.Sprintf("Failed to install %q: %v", p.Name, err))
8793
failed = append(failed, fmt.Sprintf("%s (%v)", p.Name, err))
88-
continue
94+
return
8995
}
9096

9197
version, installErr := inst.Install(p.Name)
9298
if installErr != nil {
9399
spinner.Error(fmt.Sprintf("Failed to install %q: %v", p.Name, installErr))
94100
failed = append(failed, fmt.Sprintf("%s (%v)", p.Name, installErr))
95-
continue
101+
return
96102
}
97103

98104
manifest.Add(p.Name, version, inst.PluginType(), inst.Source(), "")
99105
if p.Description != "" {
100106
manifest.SetDescription(p.Name, p.Description)
101107
}
102108

109+
doneVerb := "Installed"
110+
if reinstall {
111+
doneVerb = "Reinstalled"
112+
}
103113
if path, ok := plugin.Find(p.Name); ok {
104-
spinner.Success(fmt.Sprintf("Installed %q (%s)", p.Name, path))
114+
spinner.Success(fmt.Sprintf("%s %q (%s)", doneVerb, p.Name, path))
105115
} else {
106-
spinner.Success(fmt.Sprintf("Installed %q", p.Name))
116+
spinner.Success(fmt.Sprintf("%s %q", doneVerb, p.Name))
107117
}
108118
}
109119

120+
for _, p := range toInstall {
121+
runInstall(p, false)
122+
}
123+
for _, p := range toReinstall {
124+
runInstall(p, true)
125+
}
126+
110127
if err := manifest.Save(); err != nil {
111128
return fmt.Errorf("failed to save manifest: %w", err)
112129
}
@@ -117,7 +134,8 @@ Otherwise, the built-in default plugin list is used.`,
117134
}
118135

119136
fmt.Println()
120-
terminal.Successf("All %d plugins installed!", len(missing))
137+
total := len(toInstall) + len(toReinstall)
138+
terminal.Successf("All %d plugin(s) processed!", total)
121139
return nil
122140
},
123141
}

cmd/init_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import (
88

99
func TestFilterPluginsByTags(t *testing.T) {
1010
plugins := []plugin.Plugin{
11-
{Name: "core"}, // untagged
11+
{Name: "core"}, // untagged
1212
{Name: "devops-tool", Tags: []string{"devops"}}, // single tag
1313
{Name: "email", Tags: []string{"devops", "email"}}, // multi tag
1414
{Name: "infra", Tags: []string{"infra"}}, // single tag

internal/plugin/defaults.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,29 @@ func LoadPluginsFromFile(path string) (*PluginList, error) {
4242
return &defaults, nil
4343
}
4444

45+
// CategorizeForInit splits the resolved plugin list into:
46+
// - toInstall: not present in the manifest.
47+
// - toReinstall: present with type=script but the local source URL differs
48+
// from the remote-declared script URL. Drift for brew/npm/github is
49+
// intentionally not detected here — those install methods touch
50+
// system-wide state and need explicit user action to switch source.
51+
// - skipped: already installed and matching, or non-script type.
52+
func CategorizeForInit(plugins []Plugin, manifest *Manifest) (toInstall, toReinstall []Plugin, skipped []string) {
53+
for _, p := range plugins {
54+
entry, exists := manifest.Get(p.Name)
55+
if !exists {
56+
toInstall = append(toInstall, p)
57+
continue
58+
}
59+
if p.Script != "" && entry.Type == SourceTypeScript && entry.Source != p.Script {
60+
toReinstall = append(toReinstall, p)
61+
continue
62+
}
63+
skipped = append(skipped, p.Name)
64+
}
65+
return
66+
}
67+
4568
// FilterByTags returns plugins that should be installed based on the given
4669
// tags. Untagged plugins are always included. Tagged plugins are only included
4770
// if tags are provided and they share at least one tag with the provided list.

internal/plugin/defaults_test.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package plugin
2+
3+
import (
4+
"reflect"
5+
"testing"
6+
)
7+
8+
func TestCategorizeForInit(t *testing.T) {
9+
t.Parallel()
10+
11+
tests := []struct {
12+
name string
13+
plugins []Plugin
14+
manifest *Manifest
15+
wantInstall []string
16+
wantReinstall []string
17+
wantSkipped []string
18+
}{
19+
{
20+
name: "new plugin gets installed",
21+
plugins: []Plugin{
22+
{Name: "auth", Script: "https://example.com/install.sh"},
23+
},
24+
manifest: &Manifest{},
25+
wantInstall: []string{"auth"},
26+
},
27+
{
28+
name: "matching script URL is skipped",
29+
plugins: []Plugin{
30+
{Name: "auth", Script: "https://example.com/install.sh"},
31+
},
32+
manifest: &Manifest{Plugins: []ManifestEntry{
33+
{Name: "auth", Type: SourceTypeScript, Source: "https://example.com/install.sh"},
34+
}},
35+
wantSkipped: []string{"auth"},
36+
},
37+
{
38+
name: "drifted script URL triggers reinstall",
39+
plugins: []Plugin{
40+
{Name: "auth", Script: "https://NEW.example.com/install.sh"},
41+
},
42+
manifest: &Manifest{Plugins: []ManifestEntry{
43+
{Name: "auth", Type: SourceTypeScript, Source: "https://OLD.example.com/install.sh"},
44+
}},
45+
wantReinstall: []string{"auth"},
46+
},
47+
{
48+
name: "brew-installed plugin with brew remote is skipped",
49+
plugins: []Plugin{
50+
{Name: "lint", Brew: "golangci-lint"},
51+
},
52+
manifest: &Manifest{Plugins: []ManifestEntry{
53+
{Name: "lint", Type: SourceTypeBrew, Source: "golangci-lint"},
54+
}},
55+
wantSkipped: []string{"lint"},
56+
},
57+
{
58+
name: "type mismatch (local brew, remote script) is skipped",
59+
plugins: []Plugin{
60+
{Name: "lint", Script: "https://example.com/lint.sh"},
61+
},
62+
manifest: &Manifest{Plugins: []ManifestEntry{
63+
{Name: "lint", Type: SourceTypeBrew, Source: "golangci-lint"},
64+
}},
65+
wantSkipped: []string{"lint"},
66+
},
67+
{
68+
name: "mixed list: install + reinstall + skip",
69+
plugins: []Plugin{
70+
{Name: "auth", Script: "https://NEW/install.sh"},
71+
{Name: "employee", Script: "https://example.com/employee.sh"},
72+
{Name: "newbie", Script: "https://example.com/newbie.sh"},
73+
},
74+
manifest: &Manifest{Plugins: []ManifestEntry{
75+
{Name: "auth", Type: SourceTypeScript, Source: "https://OLD/install.sh"},
76+
{Name: "employee", Type: SourceTypeScript, Source: "https://example.com/employee.sh"},
77+
}},
78+
wantInstall: []string{"newbie"},
79+
wantReinstall: []string{"auth"},
80+
wantSkipped: []string{"employee"},
81+
},
82+
}
83+
84+
names := func(ps []Plugin) []string {
85+
if ps == nil {
86+
return nil
87+
}
88+
out := make([]string, len(ps))
89+
for i, p := range ps {
90+
out[i] = p.Name
91+
}
92+
return out
93+
}
94+
95+
for _, tt := range tests {
96+
t.Run(tt.name, func(t *testing.T) {
97+
install, reinstall, skipped := CategorizeForInit(tt.plugins, tt.manifest)
98+
if got := names(install); !reflect.DeepEqual(got, tt.wantInstall) {
99+
t.Errorf("install: got %v, want %v", got, tt.wantInstall)
100+
}
101+
if got := names(reinstall); !reflect.DeepEqual(got, tt.wantReinstall) {
102+
t.Errorf("reinstall: got %v, want %v", got, tt.wantReinstall)
103+
}
104+
if !reflect.DeepEqual(skipped, tt.wantSkipped) {
105+
t.Errorf("skipped: got %v, want %v", skipped, tt.wantSkipped)
106+
}
107+
})
108+
}
109+
}

0 commit comments

Comments
 (0)