diff --git a/docs/advanced-usage/plugin-management.md b/docs/advanced-usage/plugin-management.md index cf0bbc426..c1a9c161f 100644 --- a/docs/advanced-usage/plugin-management.md +++ b/docs/advanced-usage/plugin-management.md @@ -9,7 +9,7 @@ plugin:enable # Enable a previously disabled plugin plugin:install [--core|git-url] [--committish branch|commit|tag] [--name custom-plugin-name] [--skip-install-trigger] # Optionally download git-url (and pin to the specified branch/commit/tag) & run install trigger for active plugins (or only core ones) plugin:installed # Checks if a plugin is installed plugin:install-dependencies [--core] # Run install-dependencies trigger for active plugins (or only core ones) -plugin:list # Print active plugins +plugin:list [--format stdout|json] # Print active plugins plugin:trigger . # Trigger an arbitrary plugin hook plugin:uninstall # Uninstall a plugin (third-party only) plugin:update [name [branch|commit|tag]] # Optionally update named plugin from git (and pin to the specified branch/commit/tag) & run update trigger for active plugins @@ -67,6 +67,39 @@ plugn: dev trace 0.38.21 enabled dokku core trace plugin ``` +The list can also be emitted as JSON via the `--format json` flag. In addition to the name, version, enabled state, and description shown in the default output, the JSON output includes whether a plugin is a core plugin and - for git-based third-party plugins - the install source (the git remote URL, the currently checked-out commit, and the followed branch). This is useful for tooling that reconstructs a server's set of installed plugins: + +```shell +dokku plugin:list --format json +``` + +```json +[ + { + "name": "apps", + "version": "0.38.21", + "enabled": true, + "core": true, + "description": "dokku core apps plugin", + "source_url": "", + "committish": "", + "branch": "" + }, + { + "name": "postgres", + "version": "1.42.0", + "enabled": true, + "core": false, + "description": "dokku postgres service plugin", + "source_url": "https://github.com/dokku/dokku-postgres.git", + "committish": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", + "branch": "master" + } +] +``` + +The `source_url`, `committish`, and `branch` fields are only populated for plugins installed from a git repository. They are empty for core plugins as well as for plugins installed from a tarball or a local `file://` path. When a plugin is pinned to a specific commit or tag (a detached checkout), the `branch` field is empty while `committish` still reports the exact commit. + > [!WARNING] > All plugin commands other than `plugin:list` and `plugin:help` require sudo access and must be run directly from the Dokku server. diff --git a/go.work b/go.work index bf66069d0..98e6dad67 100644 --- a/go.work +++ b/go.work @@ -12,6 +12,7 @@ use ( ./plugins/logs ./plugins/network ./plugins/nginx-vhosts + ./plugins/plugin ./plugins/ports ./plugins/proxy ./plugins/ps diff --git a/plugins/plugin/.gitignore b/plugins/plugin/.gitignore new file mode 100644 index 000000000..77feddec8 --- /dev/null +++ b/plugins/plugin/.gitignore @@ -0,0 +1 @@ +/plugin-list diff --git a/plugins/plugin/Makefile b/plugins/plugin/Makefile new file mode 100644 index 000000000..d3455e6be --- /dev/null +++ b/plugins/plugin/Makefile @@ -0,0 +1,11 @@ +GOARCH ?= amd64 +BUILD = plugin-list +PLUGIN_NAME = plugin + +clean-plugin-list: + rm -rf plugin-list + +plugin-list: clean-plugin-list **/**/plugin-list.go + GOARCH=$(GOARCH) go build -ldflags="-s -w" $(GO_ARGS) -o plugin-list src/plugin-list/plugin-list.go + +include ../../common.mk diff --git a/plugins/plugin/go.mod b/plugins/plugin/go.mod new file mode 100644 index 000000000..bf2a6ddd3 --- /dev/null +++ b/plugins/plugin/go.mod @@ -0,0 +1,29 @@ +module github.com/dokku/dokku/plugins/plugin + +go 1.26.2 + +require ( + github.com/dokku/dokku/plugins/common v0.0.0-00010101000000-000000000000 + github.com/spf13/pflag v1.0.10 + golang.org/x/sync v0.21.0 +) + +require ( + github.com/alexellis/go-execute/v2 v2.2.1 // indirect + github.com/fatih/color v1.19.0 // indirect + github.com/hashicorp/errwrap v1.0.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/kr/fs v0.1.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/melbahja/goph v1.5.1 // indirect + github.com/otiai10/copy v1.14.1 // indirect + github.com/otiai10/mint v1.6.3 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pkg/sftp v1.13.10 // indirect + github.com/ryanuber/columnize v2.1.2+incompatible // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/sys v0.46.0 // indirect +) + +replace github.com/dokku/dokku/plugins/common => ../common diff --git a/plugins/plugin/go.sum b/plugins/plugin/go.sum new file mode 100644 index 000000000..5a5292407 --- /dev/null +++ b/plugins/plugin/go.sum @@ -0,0 +1,55 @@ +github.com/alexellis/go-execute/v2 v2.2.1 h1:4Ye3jiCKQarstODOEmqDSRCqxMHLkC92Bhse743RdOI= +github.com/alexellis/go-execute/v2 v2.2.1/go.mod h1:FMdRnUTiFAmYXcv23txrp3VYZfLo24nMpiIneWgKHTQ= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/melbahja/goph v1.5.1 h1:gdZyjn5nM6FO6mAZrv7XqE2gArQbb+V6FdIyH3Ru3vA= +github.com/melbahja/goph v1.5.1/go.mod h1:dDwo+44cmvfDLdiVpc6fJxexf5BA5yEDUeE5YgtuDO4= +github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I= +github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg= +github.com/otiai10/copy v1.14.1 h1:5/7E6qsUMBaH5AnQ0sSLzzTg1oTECmcCmT6lvF45Na8= +github.com/otiai10/copy v1.14.1/go.mod h1:oQwrEDDOci3IM8dJF0d8+jnbfPDllW6vUjNc3DoZm9I= +github.com/otiai10/mint v1.6.3 h1:87qsV/aw1F5as1eH1zS/yqHY85ANKVMgkDrf9rcxbQs= +github.com/otiai10/mint v1.6.3/go.mod h1:MJm72SBthJjz8qhefc4z1PYEieWmy8Bku7CjcAqyUSM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU= +github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/ryanuber/columnize v2.1.2+incompatible h1:C89EOx/XBWwIXl8wm8OPJBd7kPF25UfsK2X7Ph/zCAk= +github.com/ryanuber/columnize v2.1.2+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/plugins/plugin/help-functions b/plugins/plugin/help-functions index 408de0c62..03a4601e8 100755 --- a/plugins/plugin/help-functions +++ b/plugins/plugin/help-functions @@ -31,7 +31,7 @@ fn-help-content() { plugin:enable , Enable a previously disabled plugin plugin:install [--core|--git-url] [--committish branch|commit|commit] [--name custom-plugin-name] [--skip-install-trigger], Optionally download git-url (and pin to the specified branch/commit/tag) & run install trigger for active plugins (or only core ones) plugin:install-dependencies [--core], Run install-dependencies trigger for active plugins (or only core ones) - plugin:list, Print active plugins + plugin:list [--format stdout|json], Print active plugins plugin:trigger , Trigger an arbitrary plugin hook plugin:uninstall , Uninstall a plugin (third-party only) plugin:update [name [branch|commit|tag]], Optionally update named plugin from git (and pin to the specified branch/commit/tag) & run update trigger for active plugins diff --git a/plugins/plugin/internal-functions b/plugins/plugin/internal-functions index b8312d162..cee137e14 100755 --- a/plugins/plugin/internal-functions +++ b/plugins/plugin/internal-functions @@ -8,6 +8,35 @@ cmd-plugin-list() { declare cmd="plugin" [[ "$1" == "$cmd" ]] && shift 1 + local FORMAT="stdout" + local arg + while [[ "$#" -gt 0 ]]; do + arg="$1" + shift + case "$arg" in + --format) + if [[ "$#" -gt 0 ]]; then + FORMAT="$1" + shift + else + FORMAT="" + fi + ;; + --format=*) + FORMAT="${arg#--format=}" + ;; + esac + done + + if [[ "$FORMAT" != "stdout" ]] && [[ "$FORMAT" != "json" ]]; then + dokku_log_fail "Invalid output format specified, supported formats: json, stdout" + fi + + if [[ "$FORMAT" == "json" ]]; then + "$PLUGIN_AVAILABLE_PATH/plugin/plugin-list" --format json + return "$?" + fi + plugn list } diff --git a/plugins/plugin/plugin.go b/plugins/plugin/plugin.go new file mode 100644 index 000000000..f553618c2 --- /dev/null +++ b/plugins/plugin/plugin.go @@ -0,0 +1,136 @@ +package plugin + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/dokku/dokku/plugins/common" + + "golang.org/x/sync/errgroup" +) + +// PluginInfo describes an installed dokku plugin +type PluginInfo struct { + Name string `json:"name"` + Version string `json:"version"` + Enabled bool `json:"enabled"` + Core bool `json:"core"` + Description string `json:"description"` + SourceURL string `json:"source_url"` + Committish string `json:"committish"` + Branch string `json:"branch"` +} + +// CommandList lists all installed plugins in the specified format +func CommandList(format string) error { + if format != "json" { + return fmt.Errorf("Invalid output format specified, supported formats: json") + } + + plugins, err := listPlugins() + if err != nil { + return err + } + + out, err := json.Marshal(plugins) + if err != nil { + return err + } + + common.Log(string(out)) + return nil +} + +// listPlugins returns metadata for every installed plugin, enriched with git +// source information for git-based third-party plugins +func listPlugins() ([]PluginInfo, error) { + result, err := common.CallExecCommand(common.ExecCommandInput{ + Command: "plugn", + Args: []string{"list"}, + }) + if err != nil { + return nil, err + } + + coreEnabledPath := common.MustGetEnv("PLUGIN_CORE_ENABLED_PATH") + availablePath := common.MustGetEnv("PLUGIN_AVAILABLE_PATH") + + plugins := parsePlugnList(result.StdoutContents()) + + errs := new(errgroup.Group) + for i := range plugins { + i := i + errs.Go(func() error { + plugins[i].Core = common.IsSymlink(filepath.Join(coreEnabledPath, plugins[i].Name)) + enrichGitSource(&plugins[i], availablePath) + return nil + }) + } + if err := errs.Wait(); err != nil { + return nil, err + } + + return plugins, nil +} + +// parsePlugnList parses the output of `plugn list` into plugin metadata. Each +// data line is formatted as ` `, +// preceded by a `plugn: ` header line that is skipped. +func parsePlugnList(output string) []PluginInfo { + plugins := []PluginInfo{} + for _, line := range strings.Split(output, "\n") { + fields := strings.Fields(line) + if len(fields) < 3 || fields[0] == "plugn:" { + continue + } + + description := "" + if len(fields) > 3 { + description = strings.Join(fields[3:], " ") + } + + plugins = append(plugins, PluginInfo{ + Name: fields[0], + Version: fields[1], + Enabled: fields[2] == "enabled", + Description: description, + }) + } + + return plugins +} + +// enrichGitSource populates the git source fields for a git-based plugin, +// leaving them empty for core, tarball, or file-based installs +func enrichGitSource(info *PluginInfo, availablePath string) { + pluginDir := filepath.Join(availablePath, info.Name) + if _, err := os.Stat(filepath.Join(pluginDir, ".git")); err != nil { + return + } + + info.SourceURL = gitOutput(pluginDir, "remote", "get-url", "origin") + info.Committish = gitOutput(pluginDir, "rev-parse", "HEAD") + if branch := gitOutput(pluginDir, "rev-parse", "--abbrev-ref", "HEAD"); branch != "HEAD" { + info.Branch = branch + } +} + +// gitOutput runs a git command in the given directory and returns its trimmed +// stdout, or an empty string on error. The directory is marked as safe so the +// lookup works regardless of which user invokes plugin:list. +func gitOutput(dir string, args ...string) string { + gitArgs := append([]string{"-c", "safe.directory=" + dir}, args...) + result, err := common.CallExecCommand(common.ExecCommandInput{ + Command: "git", + Args: gitArgs, + WorkingDirectory: dir, + }) + if err != nil { + return "" + } + + return result.StdoutContents() +} diff --git a/plugins/plugin/plugin_test.go b/plugins/plugin/plugin_test.go new file mode 100644 index 000000000..31ce5b211 --- /dev/null +++ b/plugins/plugin/plugin_test.go @@ -0,0 +1,45 @@ +package plugin + +import ( + "testing" +) + +func TestParsePlugnList(t *testing.T) { + output := `plugn: dev + 00_dokku-standard 0.38.21 enabled dokku core standard plugin + smoke-test-plugin 0.2.0 disabled a third party plugin + bare-plugin 1.0.0 enabled` + + plugins := parsePlugnList(output) + if len(plugins) != 3 { + t.Fatalf("expected 3 plugins, got %d", len(plugins)) + } + + first := plugins[0] + if first.Name != "00_dokku-standard" { + t.Errorf("expected name 00_dokku-standard, got %q", first.Name) + } + if first.Version != "0.38.21" { + t.Errorf("expected version 0.38.21, got %q", first.Version) + } + if !first.Enabled { + t.Errorf("expected plugin to be enabled") + } + if first.Description != "dokku core standard plugin" { + t.Errorf("expected multi-word description, got %q", first.Description) + } + + if plugins[1].Enabled { + t.Errorf("expected smoke-test-plugin to be disabled") + } + if plugins[1].Description != "a third party plugin" { + t.Errorf("expected multi-word description, got %q", plugins[1].Description) + } + + if plugins[2].Name != "bare-plugin" { + t.Errorf("expected name bare-plugin, got %q", plugins[2].Name) + } + if plugins[2].Description != "" { + t.Errorf("expected empty description, got %q", plugins[2].Description) + } +} diff --git a/plugins/plugin/src/plugin-list/plugin-list.go b/plugins/plugin/src/plugin-list/plugin-list.go new file mode 100644 index 000000000..7b38564f1 --- /dev/null +++ b/plugins/plugin/src/plugin-list/plugin-list.go @@ -0,0 +1,20 @@ +package main + +import ( + "os" + + "github.com/dokku/dokku/plugins/common" + "github.com/dokku/dokku/plugins/plugin" + + flag "github.com/spf13/pflag" +) + +func main() { + args := flag.NewFlagSet("plugin:list", flag.ExitOnError) + format := args.String("format", "json", "format: [ json ]") + args.Parse(os.Args[1:]) + + if err := plugin.CommandList(*format); err != nil { + common.LogFailWithError(err) + } +} diff --git a/tests/unit/plugin.bats b/tests/unit/plugin.bats index 9323bb417..c4037754e 100644 --- a/tests/unit/plugin.bats +++ b/tests/unit/plugin.bats @@ -26,6 +26,70 @@ teardown() { assert_output "$help_output" } +@test "(plugin) plugin:list --format json" { + run /bin/bash -c "dokku plugin:list --format nonsense" + echo "output: $output" + echo "status: $status" + assert_failure + assert_output_contains "Invalid output format" + + run /bin/bash -c "dokku plugin:list --format json | jq -r '.[] | select(.name == \"apps\") | .core'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "true" + + run /bin/bash -c "dokku plugin:list --format json | jq -r '.[] | select(.name == \"apps\") | .enabled'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "true" + + run /bin/bash -c "dokku plugin:list --format json | jq -r '.[] | select(.name == \"apps\") | .source_url'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "" + + # Clone a git-based third-party plugin directly into the available path so the + # git install source is present without running the (build-dependent) install + # trigger. plugn lists it as a disabled plugin. + run /bin/bash -c "git clone $TEST_PLUGIN_GIT_REPO $PLUGIN_AVAILABLE_PATH/$TEST_PLUGIN_NAME" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku plugin:list --format json | jq -r '.[] | select(.name == \"$TEST_PLUGIN_NAME\") | .source_url'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "$TEST_PLUGIN_GIT_REPO" + + run /bin/bash -c "dokku plugin:list --format json | jq -r '.[] | select(.name == \"$TEST_PLUGIN_NAME\") | .core'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "false" + + run /bin/bash -c "dokku plugin:list --format json | jq -r '.[] | select(.name == \"$TEST_PLUGIN_NAME\") | .enabled'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "false" + + run /bin/bash -c "dokku plugin:list --format json | jq -r '.[] | select(.name == \"$TEST_PLUGIN_NAME\") | .committish | length'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "40" + + run /bin/bash -c "dokku plugin:list --format json | jq -r '.[] | select(.name == \"$TEST_PLUGIN_NAME\") | .branch'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_exists +} + @test "(plugin) plugin:install, plugin:disable, plugin:update plugin:uninstall" { run /bin/bash -c "dokku plugin:installed $TEST_PLUGIN_NAME" echo "output: $output"