Skip to content
Merged
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
84 changes: 84 additions & 0 deletions cmd/task/plugins.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>",
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 <plugin> <action> [task-id]",
Short: "Run a plugin action, optionally in the context of a task",
Expand Down Expand Up @@ -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")
Expand Down
116 changes: 116 additions & 0 deletions cmd/task/plugins_remove_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
12 changes: 12 additions & 0 deletions docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> # 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

```
Expand Down