diff --git a/cmd/task/plugins.go b/cmd/task/plugins.go index 26fa8510..e8d79132 100644 --- a/cmd/task/plugins.go +++ b/cmd/task/plugins.go @@ -71,6 +71,33 @@ same plugin updates it in place with git pull.`, addCmd.Flags().String("name", "", "Install under this directory name (default: derived from the source)") pluginsCmd.AddCommand(addCmd) + removeCmd := &cobra.Command{ + Use: "remove ", + Aliases: []string{"rm", "uninstall"}, + Short: "Uninstall a plugin by deleting its directory", + Long: `Delete an installed plugin's directory. The name is the one shown by +` + "`ty plugins list`" + ` (the manifest name), even when it differs from the +directory name. When the plugin lives inside a multi-plugin collection checkout, +only its own subdirectory is removed; the shared git checkout and its sibling +plugins are left in place (and re-running ` + "`ty plugins add`" + ` on the +source may restore it).`, + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + dir, inCheckout, err := removePlugin(args[0], hooks.DefaultPluginsDir()) + if err != nil { + return err + } + fmt.Printf("Removed plugin %q (%s).\n", args[0], dir) + if inCheckout { + fmt.Println("Note: it was part of a shared git checkout; its sibling plugins remain, " + + "and re-adding that source may restore it.") + } + return nil + }, + } + pluginsCmd.AddCommand(removeCmd) + runCmd := &cobra.Command{ Use: "run [task-id]", Short: "Run a plugin action, optionally in the context of a task", @@ -137,6 +164,63 @@ func addPlugin(source, pluginsDir, name string) (installed []string, updated boo return installed, updated, nil } +// removePlugin uninstalls the plugin named name by deleting its directory. The +// name is matched against the loaded plugins' manifest names (what `plugins list` +// shows), falling back to the directory's base name, so it works even when a +// plugin's directory name differs from its manifest name. +// +// It returns the removed directory and whether that directory sat inside a +// multi-plugin collection checkout — in which case only the plugin's own subdir is +// deleted (leaving the shared git checkout and its siblings), and a later `add` of +// the source may restore it. The plugin dir is verified to live strictly inside +// pluginsDir before anything is deleted, so a corrupt manifest can't point removal +// at an arbitrary path. +func removePlugin(name, pluginsDir string) (dir string, inCheckout bool, err error) { + if pluginsDir == "" { + return "", false, fmt.Errorf("no plugins directory configured") + } + plugins, _ := hooks.LoadPlugins(pluginsDir) + var match *hooks.Plugin + for i := range plugins { + if plugins[i].Name == name || filepath.Base(plugins[i].Dir) == name { + match = &plugins[i] + break + } + } + if match == nil { + return "", false, fmt.Errorf("no plugin named %q installed in %s; run `ty plugins list` to see what's installed", name, pluginsDir) + } + + dir = match.Dir + // Safety: refuse to delete anything that is not strictly under pluginsDir. + rel, relErr := filepath.Rel(pluginsDir, dir) + if relErr != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return "", false, fmt.Errorf("refusing to remove %s: not inside plugins dir %s", dir, pluginsDir) + } + + inCheckout = insideCollectionCheckout(dir, pluginsDir) + if err := os.RemoveAll(dir); err != nil { + return "", false, fmt.Errorf("remove %s: %w", dir, err) + } + return dir, inCheckout, nil +} + +// insideCollectionCheckout reports whether dir is a plugin nested inside a shared +// git checkout (a collection repo cloned by `plugins add`), rather than being its +// own checkout. It's true when dir itself is not a git checkout but an ancestor +// strictly between it and pluginsDir is. +func insideCollectionCheckout(dir, pluginsDir string) bool { + if isGitCheckout(dir) { + return false + } + for parent := filepath.Dir(dir); len(parent) > len(pluginsDir); parent = filepath.Dir(parent) { + if isGitCheckout(parent) { + return true + } + } + return false +} + // derivePluginName turns a git URL or path into a plugin directory name. func derivePluginName(source string) string { s := strings.TrimSuffix(strings.TrimRight(source, "/"), ".git") diff --git a/cmd/task/plugins_remove_test.go b/cmd/task/plugins_remove_test.go new file mode 100644 index 00000000..920190c8 --- /dev/null +++ b/cmd/task/plugins_remove_test.go @@ -0,0 +1,116 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/bborn/workflow/internal/hooks" +) + +// TestRemovePlugin_DeletesSingleRepo installs a single-plugin repo, then removes +// it: the whole checkout is deleted and it's no longer discoverable. +func TestRemovePlugin_DeletesSingleRepo(t *testing.T) { + source := makeSourcePluginRepo(t, "rpi-pack") + pluginsDir := filepath.Join(t.TempDir(), "plugins") + if _, _, err := addPlugin(source, pluginsDir, ""); err != nil { + t.Fatalf("addPlugin: %v", err) + } + + dir, inCheckout, err := removePlugin("rpi-pack", pluginsDir) + if err != nil { + t.Fatalf("removePlugin: %v", err) + } + if inCheckout { + t.Error("inCheckout = true, want false (its own single-plugin checkout)") + } + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Errorf("plugin dir %s still exists after remove", dir) + } + plugins, _ := hooks.LoadPlugins(pluginsDir) + for _, p := range plugins { + if p.Name == "rpi-pack" { + t.Fatalf("rpi-pack still discoverable after remove: %v", plugins) + } + } +} + +// TestRemovePlugin_CollectionLeavesSiblings removes one plugin from a multi-plugin +// collection checkout: only its subdir goes, the sibling stays, and the caller is +// told it lived inside a shared checkout. +func TestRemovePlugin_CollectionLeavesSiblings(t *testing.T) { + repo := filepath.Join(t.TempDir(), "ty-plugins") + mk := func(rel, body string) { + p := filepath.Join(repo, rel) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + mk("rpi-go/plugin.yaml", "name: rpi-go\ndescription: d\n") + mk("rpi-go/workflows/rpi-go.yaml", "name: rpi-go\nsteps:\n - name: b\n prompt: x\n") + mk("rpi-rails/plugin.yaml", "name: rpi-rails\ndescription: d\n") + mk("rpi-rails/workflows/rpi-rails.yaml", "name: rpi-rails\nsteps:\n - name: b\n prompt: x\n") + for _, args := range [][]string{{"init", "-q"}, {"config", "user.email", "t@t.local"}, {"config", "user.name", "t"}, {"add", "-A"}, {"commit", "-qm", "x"}} { + cmd := exec.Command("git", args...) + cmd.Dir = repo + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + } + pluginsDir := filepath.Join(t.TempDir(), "plugins") + if _, _, err := addPlugin(repo, pluginsDir, ""); err != nil { + t.Fatalf("addPlugin collection: %v", err) + } + + _, inCheckout, err := removePlugin("rpi-go", pluginsDir) + if err != nil { + t.Fatalf("removePlugin: %v", err) + } + if !inCheckout { + t.Error("inCheckout = false, want true (nested in a shared collection checkout)") + } + + plugins, _ := hooks.LoadPlugins(pluginsDir) + var names []string + for _, p := range plugins { + names = append(names, p.Name) + } + if len(names) != 1 || names[0] != "rpi-rails" { + t.Errorf("after removing rpi-go, remaining plugins = %v, want [rpi-rails]", names) + } +} + +// TestRemovePlugin_MatchesByDirName removes a plugin whose directory name differs +// from its manifest name, using the directory name. +func TestRemovePlugin_MatchesByDirName(t *testing.T) { + pluginsDir := filepath.Join(t.TempDir(), "plugins") + dir := filepath.Join(pluginsDir, "my-dir") + if err := os.MkdirAll(filepath.Join(dir, "workflows"), 0o755); err != nil { + t.Fatal(err) + } + os.WriteFile(filepath.Join(dir, "plugin.yaml"), []byte("name: fancy-name\ndescription: d\n"), 0o644) + os.WriteFile(filepath.Join(dir, "workflows", "w.yaml"), []byte("name: w\nsteps:\n - name: b\n prompt: x\n"), 0o644) + + if _, _, err := removePlugin("my-dir", pluginsDir); err != nil { + t.Fatalf("removePlugin by dir name: %v", err) + } + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Errorf("plugin dir %s still exists after remove", dir) + } +} + +// TestRemovePlugin_UnknownErrors errors (rather than deleting anything) when no +// plugin matches the given name. +func TestRemovePlugin_UnknownErrors(t *testing.T) { + pluginsDir := filepath.Join(t.TempDir(), "plugins") + if err := os.MkdirAll(pluginsDir, 0o755); err != nil { + t.Fatal(err) + } + if _, _, err := removePlugin("nope", pluginsDir); err == nil { + t.Error("expected an error removing an unknown plugin, got nil") + } +} diff --git a/docs/plugins.md b/docs/plugins.md index 51637898..3f9701a9 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -28,6 +28,18 @@ its root or many nested in subdirectories, and every plugin inside becomes activ Re-running `add` on the same source updates it in place (`git pull`). You can also just drop a directory into `~/.config/task/plugins/` by hand — see [Examples](#examples). +## Removing + +```bash +ty plugins remove # aliases: rm, uninstall +``` + +`remove` deletes the named plugin's directory (the name is the one from +`ty plugins list`, even if it differs from the directory name). If the plugin was +installed as part of a multi-plugin collection checkout, only its own subdirectory +is removed — the shared checkout and its sibling plugins stay, and re-running +`ty plugins add` on the collection source may restore it. + ## Anatomy ```