feat: add --format json support to plugin:list

Adds a `--format json` flag to `plugin:list` whose output includes each plugin's install source - for git-based third-party plugins, the git remote URL, the checked-out commit, and the followed branch - so the set of installed plugins can be reconstructed elsewhere.

Closes #8798.
This commit is contained in:
Jose Diaz-Gonzalez
2026-07-07 05:17:40 -04:00
parent 4eabf54fd4
commit 9c819cfebc
12 changed files with 426 additions and 2 deletions

View File

@@ -9,7 +9,7 @@ plugin:enable <name> # 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 <name> # 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 <args...>. # Trigger an arbitrary plugin hook
plugin:uninstall <name> # 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.

View File

@@ -12,6 +12,7 @@ use (
./plugins/logs
./plugins/network
./plugins/nginx-vhosts
./plugins/plugin
./plugins/ports
./plugins/proxy
./plugins/ps

1
plugins/plugin/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/plugin-list

11
plugins/plugin/Makefile Normal file
View File

@@ -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

29
plugins/plugin/go.mod Normal file
View File

@@ -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

55
plugins/plugin/go.sum Normal file
View File

@@ -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=

View File

@@ -31,7 +31,7 @@ fn-help-content() {
plugin:enable <name>, 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 <args...>, Trigger an arbitrary plugin hook
plugin:uninstall <name>, 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

View File

@@ -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
}

136
plugins/plugin/plugin.go Normal file
View File

@@ -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 `<name> <version> <enabled|disabled> <description>`,
// preceded by a `plugn: <treeish>` 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()
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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"