mirror of
https://github.com/navidrome/navidrome.git
synced 2026-07-09 17:18:45 +00:00
feat(cli): add a 'navidrome plugin' CLI for managing and inspecting plugins (#5682)
Some checks are pending
Pipeline: Test, Lint, Build / Test Go code (push) Waiting to run
Pipeline: Test, Lint, Build / Package/Release (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Get version info (push) Waiting to run
Pipeline: Test, Lint, Build / Lint Go code (push) Waiting to run
Pipeline: Test, Lint, Build / Test Go code (Windows) (push) Waiting to run
Pipeline: Test, Lint, Build / Test JS code (push) Waiting to run
Pipeline: Test, Lint, Build / Lint i18n files (push) Waiting to run
Pipeline: Test, Lint, Build / Check Docker configuration (push) Waiting to run
Pipeline: Test, Lint, Build / Build (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-1 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-2 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-3 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-4 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-5 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-6 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-7 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-8 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-9 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-10 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Push to GHCR (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build Windows installers (push) Blocked by required conditions
Some checks are pending
Pipeline: Test, Lint, Build / Test Go code (push) Waiting to run
Pipeline: Test, Lint, Build / Package/Release (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Get version info (push) Waiting to run
Pipeline: Test, Lint, Build / Lint Go code (push) Waiting to run
Pipeline: Test, Lint, Build / Test Go code (Windows) (push) Waiting to run
Pipeline: Test, Lint, Build / Test JS code (push) Waiting to run
Pipeline: Test, Lint, Build / Lint i18n files (push) Waiting to run
Pipeline: Test, Lint, Build / Check Docker configuration (push) Waiting to run
Pipeline: Test, Lint, Build / Build (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-1 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-2 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-3 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-4 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-5 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-6 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-7 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-8 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-9 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-10 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Push to GHCR (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build Windows installers (push) Blocked by required conditions
* feat(plugins): export ReadPackageManifest and ValidatePackage for off-disk inspection * test(plugins): cover ValidatePackage cross-field validation branch * feat(cmd): add 'plugin list' command * refactor(cmd): drop premature pluginManager interface until first use * feat(cmd): add 'plugin enable' and 'plugin disable' commands * feat(cmd): add 'plugin edit' for config and permission updates * test(cmd): cover plugin edit aborting on config validation failure * feat(cmd): add 'plugin info' and 'plugin validate' (installed id or .ndp file) * test(cmd): unit-test formatManifestInfo nil-guard and json branches * feat(cmd): add 'plugin rescan' command * feat(cmd): enrich plugin info text output and re-validate config on validate * fix(cmd): omit zero-value timestamps in plugin info text output * fix(cmd): give plugin list and info separate format flag vars A shared package-global bound to both commands' --format flag caused the later init() registration (info, default "text") to clobber the earlier one (list, default "table"), so 'plugin list' with no -f failed with 'invalid output format "text"'. Split into pluginListFormat / pluginInfoFormat and add a regression test on the registered defaults. * fix(plugins): address code-review findings on plugin CLI - edit: read-modify-write merge so flipping one permission flag no longer wipes unspecified fields (matches the native API); reject non-JSON --users/--libraries values. - info: return an error on unknown --format (was silently text); include sha256 in off-disk JSON output; align the text columns. - move declaredPermissions onto plugins.Permissions.DeclaredNames() as the single source of truth; drop ValidatePackage's redundant Validate call. - readConfigFile: pass ctx to log.Fatal; copy flag globals before taking their address; trim comments that restated the code. * refactor(plugins): export ReadManifest/ComputeFileSHA256, drop thin CLI wrappers Remove the ReadPackageManifest/ComputeFileSHA256/ValidatePackage wrappers in favor of exporting the underlying readManifest->ReadManifest and computeFileSHA256->ComputeFileSHA256 directly. ValidatePackage was identical to ReadPackageManifest (readManifest already validates via ParseManifest), so the CLI's validate path now calls ReadManifest too. * test(plugins): consolidate ReadManifest specs and move ComputeFileSHA256 tests Merge the two ReadManifest Describe blocks into one, and move the ComputeFileSHA256 tests out of package_test.go into a new manager_sync_test.go (where the function now lives), deduping the overlapping hash specs. * test(plugins): use createTestPackage everywhere, drop writeNDP helper The schema-invalid and cross-field ReadManifest specs now build packages with createTestPackage (typed Manifest) instead of the raw-JSON writeNDP helper, so there is a single package-building helper. Also trims the gratuitous 1MB wasm buffer in the read-only spec to a few bytes. * test(plugins): drop duplicate missing-manifest spec from ReadManifest The openPackage block already covers the missing-manifest.json error path (shared zip-walk code); ReadManifest's identical copy added no distinct coverage. * test(plugins): fix misleading ReadManifest spec name and drop unused wasm bytes The spec was named 'should read only the manifest without loading wasm' but ReadManifest returns only (*Manifest, error) — it cannot assert whether wasm was loaded. Rename to what it actually verifies (manifest parses from a package that also contains a wasm entry) and pass nil wasm bytes, since the contents are never observed. * fix(cmd): address PR review feedback on plugin CLI - edit --users/--libraries now accept both comma-separated and JSON-array input (CSV is converted to the JSON the manager stores); help text documents both. - Permissions.DeclaredNames() derives names by reflecting over the generated struct's json tags instead of a hand-maintained list, so new permission types are picked up automatically. - isPackagePath checks only the .ndp suffix, so a mistyped package path yields a precise 'no such file' error instead of falling back to a misleading 'Plugin not found'. - Restore a ReadManifest test for the missing-manifest.json error path (it has its own branch, separate from openPackage). * docs(cmd): trim verbose comments to the why * fix(cmd): setting an explicit users/libraries list clears the all-* flag Per Codex review: with allUsers/allLibraries true, 'plugin edit --users <list>' preserved the all-* flag so the allow-list was silently ignored, and the mutually-exclusive flags meant it couldn't be corrected in one command. An explicit list now implies all-*=false.
This commit is contained in:
parent
a1412ef1c2
commit
5a3ac80a8a
11 changed files with 1046 additions and 43 deletions
558
cmd/plugin.go
Normal file
558
cmd/plugin.go
Normal file
|
|
@ -0,0 +1,558 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/plugins"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// pluginManager is the subset of *plugins.Manager the CLI needs.
|
||||
type pluginManager interface {
|
||||
EnablePlugin(ctx context.Context, id string) error
|
||||
DisablePlugin(ctx context.Context, id string) error
|
||||
ValidatePluginConfig(ctx context.Context, id, configJSON string) error
|
||||
UpdatePluginConfig(ctx context.Context, id, configJSON string) error
|
||||
UpdatePluginUsers(ctx context.Context, id, usersJSON string, allUsers bool) error
|
||||
UpdatePluginLibraries(ctx context.Context, id, librariesJSON string, allLibraries, allowWriteAccess bool) error
|
||||
RescanPlugins(ctx context.Context) error
|
||||
}
|
||||
|
||||
var (
|
||||
pluginListFormat string
|
||||
pluginInfoFormat string
|
||||
)
|
||||
|
||||
var (
|
||||
editConfig string
|
||||
editConfigFile string
|
||||
editUsers string
|
||||
editAllUsers bool
|
||||
editLibraries string
|
||||
editAllLibs bool
|
||||
editWriteAccess bool
|
||||
editNoWrite bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(pluginRoot)
|
||||
|
||||
pluginListCmd.Flags().StringVarP(&pluginListFormat, "format", "f", "table", "output format [supported values: table, csv, json]")
|
||||
pluginRoot.AddCommand(pluginListCmd)
|
||||
pluginRoot.AddCommand(pluginEnableCmd)
|
||||
pluginRoot.AddCommand(pluginDisableCmd)
|
||||
|
||||
pluginEditCmd.Flags().StringVar(&editConfig, "config", "", "plugin config as JSON")
|
||||
pluginEditCmd.Flags().StringVar(&editConfigFile, "config-file", "", "read plugin config JSON from a file ('-' for stdin)")
|
||||
pluginEditCmd.MarkFlagsMutuallyExclusive("config", "config-file")
|
||||
pluginEditCmd.Flags().StringVar(&editUsers, "users", "", `usernames the plugin may access: comma-separated (alice,bob) or a JSON array (["alice","bob"])`)
|
||||
pluginEditCmd.Flags().BoolVar(&editAllUsers, "all-users", false, "grant the plugin access to all users")
|
||||
pluginEditCmd.MarkFlagsMutuallyExclusive("users", "all-users")
|
||||
pluginEditCmd.Flags().StringVar(&editLibraries, "libraries", "", `library IDs the plugin may access: comma-separated (1,2) or a JSON array ([1,2])`)
|
||||
pluginEditCmd.Flags().BoolVar(&editAllLibs, "all-libraries", false, "grant the plugin access to all libraries")
|
||||
pluginEditCmd.MarkFlagsMutuallyExclusive("libraries", "all-libraries")
|
||||
pluginEditCmd.Flags().BoolVar(&editWriteAccess, "write-access", false, "allow the plugin write access to libraries")
|
||||
pluginEditCmd.Flags().BoolVar(&editNoWrite, "no-write-access", false, "deny the plugin write access to libraries")
|
||||
pluginEditCmd.MarkFlagsMutuallyExclusive("write-access", "no-write-access")
|
||||
pluginRoot.AddCommand(pluginEditCmd)
|
||||
|
||||
pluginInfoCmd.Flags().StringVarP(&pluginInfoFormat, "format", "f", "text", "output format [supported values: text, json]")
|
||||
pluginRoot.AddCommand(pluginInfoCmd)
|
||||
pluginRoot.AddCommand(pluginValidateCmd)
|
||||
pluginRoot.AddCommand(pluginRescanCmd)
|
||||
}
|
||||
|
||||
var (
|
||||
pluginRoot = &cobra.Command{
|
||||
Use: "plugin",
|
||||
Short: "Manage and inspect plugins",
|
||||
Long: "List, inspect, enable, disable, configure, rescan, and validate plugins",
|
||||
}
|
||||
|
||||
pluginListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List installed plugins",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runPluginList(cmd.Context())
|
||||
},
|
||||
}
|
||||
|
||||
pluginEnableCmd = &cobra.Command{
|
||||
Use: "enable <id>",
|
||||
Short: "Enable a plugin",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
requirePluginsEnabled(cmd.Context())
|
||||
_, ctx := getAdminContext(cmd.Context())
|
||||
mgr := GetPluginManager(ctx)
|
||||
if err := enablePlugin(ctx, mgr, args[0]); err != nil {
|
||||
log.Fatal(ctx, "Failed to enable plugin", "id", args[0], err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
pluginDisableCmd = &cobra.Command{
|
||||
Use: "disable <id>",
|
||||
Short: "Disable a plugin",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
requirePluginsEnabled(cmd.Context())
|
||||
_, ctx := getAdminContext(cmd.Context())
|
||||
mgr := GetPluginManager(ctx)
|
||||
if err := disablePlugin(ctx, mgr, args[0]); err != nil {
|
||||
log.Fatal(ctx, "Failed to disable plugin", "id", args[0], err)
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
pluginInfoCmd = &cobra.Command{
|
||||
Use: "info <id|file.ndp>",
|
||||
Short: "Show details for an installed plugin or a .ndp package",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runPluginInfo(cmd.Context(), args[0])
|
||||
},
|
||||
}
|
||||
|
||||
pluginValidateCmd = &cobra.Command{
|
||||
Use: "validate <id|file.ndp>",
|
||||
Short: "Validate an installed plugin or a .ndp package manifest",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runPluginValidate(cmd.Context(), args[0])
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// isPackagePath checks only the extension (not existence) so a mistyped path
|
||||
// still routes to ReadManifest, which reports a precise "no such file" error.
|
||||
func isPackagePath(arg string) bool {
|
||||
return strings.HasSuffix(arg, plugins.PackageExtension)
|
||||
}
|
||||
|
||||
func formatPluginInfo(p *model.Plugin, format string) (string, error) {
|
||||
switch format {
|
||||
case "json":
|
||||
b, err := json.MarshalIndent(p, "", " ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
case "text":
|
||||
default:
|
||||
return "", fmt.Errorf("invalid output format %q (supported: text, json)", format)
|
||||
}
|
||||
name, version := manifestSummary(*p)
|
||||
var sb strings.Builder
|
||||
fmt.Fprintf(&sb, "ID: %s\n", p.ID)
|
||||
fmt.Fprintf(&sb, "Name: %s\n", name)
|
||||
fmt.Fprintf(&sb, "Version: %s\n", version)
|
||||
fmt.Fprintf(&sb, "Enabled: %t\n", p.Enabled)
|
||||
fmt.Fprintf(&sb, "Path: %s\n", p.Path)
|
||||
fmt.Fprintf(&sb, "SHA256: %s\n", p.SHA256)
|
||||
fmt.Fprintf(&sb, "All users: %t\n", p.AllUsers)
|
||||
fmt.Fprintf(&sb, "All libs: %t\n", p.AllLibraries)
|
||||
fmt.Fprintf(&sb, "Write access: %t\n", p.AllowWriteAccess)
|
||||
if p.Users != "" {
|
||||
fmt.Fprintf(&sb, "Users: %s\n", p.Users)
|
||||
}
|
||||
if p.Libraries != "" {
|
||||
fmt.Fprintf(&sb, "Libraries: %s\n", p.Libraries)
|
||||
}
|
||||
if m, err := plugins.ParseManifest([]byte(p.Manifest)); err == nil {
|
||||
if perms := m.Permissions.DeclaredNames(); len(perms) > 0 {
|
||||
fmt.Fprintf(&sb, "Permissions: %s\n", strings.Join(perms, ", "))
|
||||
}
|
||||
}
|
||||
if !p.CreatedAt.IsZero() {
|
||||
fmt.Fprintf(&sb, "Created: %s\n", p.CreatedAt.Format(time.RFC3339))
|
||||
}
|
||||
if !p.UpdatedAt.IsZero() {
|
||||
fmt.Fprintf(&sb, "Updated: %s\n", p.UpdatedAt.Format(time.RFC3339))
|
||||
}
|
||||
if p.Config != "" {
|
||||
fmt.Fprintf(&sb, "Config: %s\n", p.Config)
|
||||
}
|
||||
if p.LastError != "" {
|
||||
fmt.Fprintf(&sb, "Last error: %s\n", p.LastError)
|
||||
}
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
func formatManifestInfo(m *plugins.Manifest, sha256, format string) (string, error) {
|
||||
switch format {
|
||||
case "json":
|
||||
b, err := json.MarshalIndent(struct {
|
||||
*plugins.Manifest
|
||||
SHA256 string `json:"sha256"`
|
||||
}{m, sha256}, "", " ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
case "text":
|
||||
default:
|
||||
return "", fmt.Errorf("invalid output format %q (supported: text, json)", format)
|
||||
}
|
||||
var sb strings.Builder
|
||||
fmt.Fprintf(&sb, "Name: %s\n", m.Name)
|
||||
fmt.Fprintf(&sb, "Version: %s\n", m.Version)
|
||||
fmt.Fprintf(&sb, "Author: %s\n", m.Author)
|
||||
if m.Description != nil {
|
||||
fmt.Fprintf(&sb, "Description: %s\n", *m.Description)
|
||||
}
|
||||
if m.Website != nil {
|
||||
fmt.Fprintf(&sb, "Website: %s\n", *m.Website)
|
||||
}
|
||||
if perms := m.Permissions.DeclaredNames(); len(perms) > 0 {
|
||||
fmt.Fprintf(&sb, "Permissions: %s\n", strings.Join(perms, ", "))
|
||||
}
|
||||
fmt.Fprintf(&sb, "SHA256: %s\n", sha256)
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
func runPluginInfo(ctx context.Context, arg string) {
|
||||
if isPackagePath(arg) {
|
||||
m, err := plugins.ReadManifest(arg)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Failed to read package", "path", arg, err)
|
||||
}
|
||||
sha, err := plugins.ComputeFileSHA256(arg)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Failed to hash package", "path", arg, err)
|
||||
}
|
||||
out, err := formatManifestInfo(m, sha, pluginInfoFormat)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Failed to format output", err)
|
||||
}
|
||||
fmt.Print(out)
|
||||
return
|
||||
}
|
||||
requirePluginsEnabled(ctx)
|
||||
ds, ctx := getAdminContext(ctx)
|
||||
p, err := ds.Plugin(ctx).Get(arg)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Plugin not found", "id", arg, err)
|
||||
}
|
||||
out, err := formatPluginInfo(p, pluginInfoFormat)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Failed to format output", err)
|
||||
}
|
||||
fmt.Print(out)
|
||||
}
|
||||
|
||||
func runPluginValidate(ctx context.Context, arg string) {
|
||||
if isPackagePath(arg) {
|
||||
if _, err := plugins.ReadManifest(arg); err != nil {
|
||||
log.Fatal(ctx, "Validation failed", "path", arg, err)
|
||||
}
|
||||
fmt.Printf("%s: OK\n", arg)
|
||||
return
|
||||
}
|
||||
requirePluginsEnabled(ctx)
|
||||
ds, ctx := getAdminContext(ctx)
|
||||
p, err := ds.Plugin(ctx).Get(arg)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Plugin not found", "id", arg, err)
|
||||
}
|
||||
if _, err := plugins.ParseManifest([]byte(p.Manifest)); err != nil {
|
||||
log.Fatal(ctx, "Validation failed", "id", arg, err)
|
||||
}
|
||||
if p.Config != "" {
|
||||
mgr := GetPluginManager(ctx)
|
||||
if err := mgr.ValidatePluginConfig(ctx, arg, p.Config); err != nil {
|
||||
log.Fatal(ctx, "Config validation failed", "id", arg, err)
|
||||
}
|
||||
}
|
||||
fmt.Printf("%s: OK\n", arg)
|
||||
}
|
||||
|
||||
// manifestSummary extracts the display name and version from a stored manifest JSON, falling
|
||||
// back to the plugin ID when the manifest can't be parsed.
|
||||
func manifestSummary(p model.Plugin) (name, version string) {
|
||||
var m struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(p.Manifest), &m); err != nil {
|
||||
return p.ID, ""
|
||||
}
|
||||
return m.Name, m.Version
|
||||
}
|
||||
|
||||
func formatPluginList(list model.Plugins, format string) (string, error) {
|
||||
switch format {
|
||||
case "json":
|
||||
b, err := json.MarshalIndent(list, "", " ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
case "csv":
|
||||
var sb strings.Builder
|
||||
w := csv.NewWriter(&sb)
|
||||
_ = w.Write([]string{"id", "name", "version", "enabled", "last error"})
|
||||
for _, p := range list {
|
||||
name, version := manifestSummary(p)
|
||||
_ = w.Write([]string{p.ID, name, version, fmt.Sprintf("%t", p.Enabled), p.LastError})
|
||||
}
|
||||
w.Flush()
|
||||
return sb.String(), w.Error()
|
||||
case "table":
|
||||
var sb strings.Builder
|
||||
w := tabwriter.NewWriter(&sb, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "ID\tNAME\tVERSION\tENABLED\tLAST ERROR")
|
||||
for _, p := range list {
|
||||
name, version := manifestSummary(p)
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%t\t%s\n", p.ID, name, version, p.Enabled, p.LastError)
|
||||
}
|
||||
w.Flush()
|
||||
return sb.String(), nil
|
||||
default:
|
||||
return "", fmt.Errorf("invalid output format %q (supported: table, csv, json)", format)
|
||||
}
|
||||
}
|
||||
|
||||
func runPluginList(ctx context.Context) {
|
||||
requirePluginsEnabled(ctx)
|
||||
ds, ctx := getAdminContext(ctx)
|
||||
list, err := ds.Plugin(ctx).GetAll()
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Failed to list plugins", err)
|
||||
}
|
||||
out, err := formatPluginList(list, pluginListFormat)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Failed to format output", err)
|
||||
}
|
||||
fmt.Print(out)
|
||||
}
|
||||
|
||||
// requirePluginsEnabled gates DB/manager-backed commands; off-disk .ndp
|
||||
// inspection deliberately skips this so it works without a configured server.
|
||||
func requirePluginsEnabled(ctx context.Context) {
|
||||
if !conf.Server.Plugins.Enabled {
|
||||
log.Fatal(ctx, "Plugin system is disabled (set Plugins.Enabled to use this command)")
|
||||
}
|
||||
}
|
||||
|
||||
func enablePlugin(ctx context.Context, mgr pluginManager, id string) error {
|
||||
return mgr.EnablePlugin(ctx, id)
|
||||
}
|
||||
|
||||
func disablePlugin(ctx context.Context, mgr pluginManager, id string) error {
|
||||
return mgr.DisablePlugin(ctx, id)
|
||||
}
|
||||
|
||||
type pluginEditOptions struct {
|
||||
config *string // nil = leave unchanged
|
||||
users *string
|
||||
allUsers *bool
|
||||
libraries *string
|
||||
allLibraries *bool
|
||||
writeAccess *bool
|
||||
}
|
||||
|
||||
var pluginEditCmd = &cobra.Command{
|
||||
Use: "edit <id>",
|
||||
Short: "Update a plugin's config and/or permissions",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
requirePluginsEnabled(cmd.Context())
|
||||
ds, ctx := getAdminContext(cmd.Context())
|
||||
cur, err := ds.Plugin(ctx).Get(args[0])
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Plugin not found", "id", args[0], err)
|
||||
}
|
||||
mgr := GetPluginManager(ctx)
|
||||
opts := buildEditOptionsFromFlags(ctx, cmd)
|
||||
if err := applyPluginEdit(ctx, mgr, cur, opts); err != nil {
|
||||
log.Fatal(ctx, "Failed to edit plugin", "id", args[0], err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func buildEditOptionsFromFlags(ctx context.Context, cmd *cobra.Command) pluginEditOptions {
|
||||
var opts pluginEditOptions
|
||||
switch {
|
||||
case cmd.Flags().Changed("config"):
|
||||
c := editConfig
|
||||
opts.config = &c
|
||||
case cmd.Flags().Changed("config-file"):
|
||||
c := readConfigFile(ctx, editConfigFile)
|
||||
opts.config = &c
|
||||
}
|
||||
if cmd.Flags().Changed("users") {
|
||||
u := editUsers
|
||||
opts.users = &u
|
||||
}
|
||||
if cmd.Flags().Changed("all-users") {
|
||||
v := editAllUsers
|
||||
opts.allUsers = &v
|
||||
}
|
||||
if cmd.Flags().Changed("libraries") {
|
||||
l := editLibraries
|
||||
opts.libraries = &l
|
||||
}
|
||||
if cmd.Flags().Changed("all-libraries") {
|
||||
v := editAllLibs
|
||||
opts.allLibraries = &v
|
||||
}
|
||||
if cmd.Flags().Changed("write-access") || cmd.Flags().Changed("no-write-access") {
|
||||
// write-access is part of the library-permission group, so it is updated
|
||||
// alongside the (preserved) library list rather than on its own.
|
||||
wa := editWriteAccess && !editNoWrite
|
||||
opts.writeAccess = &wa
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
func readConfigFile(ctx context.Context, path string) string {
|
||||
var data []byte
|
||||
var err error
|
||||
if path == "-" {
|
||||
data, err = io.ReadAll(os.Stdin)
|
||||
} else {
|
||||
data, err = os.ReadFile(path)
|
||||
}
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Failed to read config file", "path", path, err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// applyPluginEdit applies the requested changes on top of the plugin's current
|
||||
// state. Like the native API, it reads the existing users/libraries before
|
||||
// updating so that flipping one flag (e.g. --write-access) does not wipe
|
||||
// unspecified fields, and rejects non-JSON users/libraries values.
|
||||
func applyPluginEdit(ctx context.Context, mgr pluginManager, cur *model.Plugin, opts pluginEditOptions) error {
|
||||
if opts.config == nil && opts.users == nil && opts.allUsers == nil &&
|
||||
opts.libraries == nil && opts.allLibraries == nil && opts.writeAccess == nil {
|
||||
return fmt.Errorf("nothing to update: provide at least one of --config/--users/--libraries/--write-access")
|
||||
}
|
||||
id := cur.ID
|
||||
if opts.config != nil {
|
||||
if err := mgr.ValidatePluginConfig(ctx, id, *opts.config); err != nil {
|
||||
return fmt.Errorf("invalid config: %w", err)
|
||||
}
|
||||
if err := mgr.UpdatePluginConfig(ctx, id, *opts.config); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if opts.users != nil || opts.allUsers != nil {
|
||||
users, allUsers := cur.Users, cur.AllUsers
|
||||
if opts.users != nil {
|
||||
parsed, err := usersToJSON(*opts.users)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
users = parsed
|
||||
allUsers = false // an explicit list means "restrict to these users"
|
||||
}
|
||||
if opts.allUsers != nil {
|
||||
allUsers = *opts.allUsers
|
||||
}
|
||||
if err := mgr.UpdatePluginUsers(ctx, id, users, allUsers); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if opts.libraries != nil || opts.allLibraries != nil || opts.writeAccess != nil {
|
||||
libs, allLibs, writeAccess := cur.Libraries, cur.AllLibraries, cur.AllowWriteAccess
|
||||
if opts.libraries != nil {
|
||||
parsed, err := librariesToJSON(*opts.libraries)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
libs = parsed
|
||||
allLibs = false // an explicit list means "restrict to these libraries"
|
||||
}
|
||||
if opts.allLibraries != nil {
|
||||
allLibs = *opts.allLibraries
|
||||
}
|
||||
if opts.writeAccess != nil {
|
||||
writeAccess = *opts.writeAccess
|
||||
}
|
||||
if err := mgr.UpdatePluginLibraries(ctx, id, libs, allLibs, writeAccess); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// usersToJSON accepts either a JSON array (starts with '[') or a comma-separated
|
||||
// list and returns the JSON-array form the manager stores.
|
||||
func usersToJSON(value string) (string, error) {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return "[]", nil
|
||||
}
|
||||
if strings.HasPrefix(strings.TrimSpace(value), "[") {
|
||||
if !json.Valid([]byte(value)) {
|
||||
return "", fmt.Errorf("invalid JSON in --users")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
var names []string
|
||||
for _, u := range strings.Split(value, ",") {
|
||||
if u = strings.TrimSpace(u); u != "" {
|
||||
names = append(names, u)
|
||||
}
|
||||
}
|
||||
b, _ := json.Marshal(names)
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// librariesToJSON accepts either a JSON array (starts with '[') or a
|
||||
// comma-separated list of integer IDs and returns the JSON-array form stored.
|
||||
func librariesToJSON(value string) (string, error) {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return "[]", nil
|
||||
}
|
||||
if strings.HasPrefix(strings.TrimSpace(value), "[") {
|
||||
if !json.Valid([]byte(value)) {
|
||||
return "", fmt.Errorf("invalid JSON in --libraries")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
ids := []int{}
|
||||
for _, l := range strings.Split(value, ",") {
|
||||
if l = strings.TrimSpace(l); l != "" {
|
||||
id, err := strconv.Atoi(l)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid library ID %q: must be an integer", l)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
b, _ := json.Marshal(ids)
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
var pluginRescanCmd = &cobra.Command{
|
||||
Use: "rescan",
|
||||
Short: "Re-discover plugins in the plugins folder",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
requirePluginsEnabled(cmd.Context())
|
||||
_, ctx := getAdminContext(cmd.Context())
|
||||
mgr := GetPluginManager(ctx)
|
||||
if err := rescanPlugins(ctx, mgr); err != nil {
|
||||
log.Fatal(ctx, "Failed to rescan plugins", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func rescanPlugins(ctx context.Context, mgr pluginManager) error {
|
||||
return mgr.RescanPlugins(ctx)
|
||||
}
|
||||
360
cmd/plugin_test.go
Normal file
360
cmd/plugin_test.go
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/plugins"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var samplePlugins = model.Plugins{
|
||||
{ID: "alpha", Manifest: `{"name":"Alpha","version":"1.0.0","author":"me"}`, Enabled: true},
|
||||
{ID: "beta", Manifest: `{"name":"Beta","version":"2.1.0","author":"me"}`, Enabled: false, LastError: "boom"},
|
||||
}
|
||||
|
||||
var _ = Describe("plugin command format flags", func() {
|
||||
// Regression: list and info must not share a format variable. Binding the
|
||||
// same var on both commands makes the last init() registration clobber the
|
||||
// other's default, breaking `plugin list` with no -f flag.
|
||||
It("defaults `list -f` to table", func() {
|
||||
Expect(pluginListCmd.Flags().Lookup("format").DefValue).To(Equal("table"))
|
||||
})
|
||||
|
||||
It("defaults `info -f` to text", func() {
|
||||
Expect(pluginInfoCmd.Flags().Lookup("format").DefValue).To(Equal("text"))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("formatPluginList", func() {
|
||||
It("renders csv with a header and one row per plugin", func() {
|
||||
out, err := formatPluginList(samplePlugins, "csv")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
lines := strings.Split(strings.TrimSpace(out), "\n")
|
||||
Expect(lines).To(HaveLen(3)) // header + 2 rows
|
||||
Expect(lines[0]).To(ContainSubstring("id"))
|
||||
Expect(out).To(ContainSubstring("alpha"))
|
||||
Expect(out).To(ContainSubstring("beta"))
|
||||
})
|
||||
|
||||
It("renders valid json", func() {
|
||||
out, err := formatPluginList(samplePlugins, "json")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
var got []map[string]any
|
||||
Expect(json.Unmarshal([]byte(out), &got)).To(Succeed())
|
||||
Expect(got).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("renders a human table by default", func() {
|
||||
out, err := formatPluginList(samplePlugins, "table")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(out).To(ContainSubstring("Alpha"))
|
||||
Expect(out).To(ContainSubstring("1.0.0"))
|
||||
})
|
||||
|
||||
It("errors on an unknown format", func() {
|
||||
_, err := formatPluginList(samplePlugins, "yaml")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("enable/disable plugin", func() {
|
||||
It("calls EnablePlugin on the manager", func() {
|
||||
mgr := &tests.MockPluginManager{}
|
||||
err := enablePlugin(context.Background(), mgr, "alpha")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mgr.EnablePluginCalls).To(Equal([]string{"alpha"}))
|
||||
})
|
||||
|
||||
It("calls DisablePlugin on the manager", func() {
|
||||
mgr := &tests.MockPluginManager{}
|
||||
err := disablePlugin(context.Background(), mgr, "beta")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mgr.DisablePluginCalls).To(Equal([]string{"beta"}))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("applyPluginEdit", func() {
|
||||
var cur *model.Plugin
|
||||
BeforeEach(func() {
|
||||
cur = &model.Plugin{ID: "alpha", Users: `["bob"]`, Libraries: `[1,2]`, AllowWriteAccess: true}
|
||||
})
|
||||
|
||||
It("validates then updates config when config is provided", func() {
|
||||
mgr := &tests.MockPluginManager{}
|
||||
cfg := `{"key":"val"}`
|
||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{config: &cfg})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mgr.ValidatePluginConfigCalls).To(HaveLen(1))
|
||||
Expect(mgr.ValidatePluginConfigCalls[0].ConfigJSON).To(Equal(cfg))
|
||||
Expect(mgr.UpdatePluginConfigCalls).To(HaveLen(1))
|
||||
Expect(mgr.UpdatePluginConfigCalls[0].ConfigJSON).To(Equal(cfg))
|
||||
})
|
||||
|
||||
It("updates users with allUsers flag", func() {
|
||||
mgr := &tests.MockPluginManager{}
|
||||
all := true
|
||||
err := applyPluginEdit(context.Background(), mgr, cur,
|
||||
pluginEditOptions{allUsers: &all})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mgr.UpdatePluginUsersCalls).To(HaveLen(1))
|
||||
Expect(mgr.UpdatePluginUsersCalls[0].AllUsers).To(BeTrue())
|
||||
})
|
||||
|
||||
It("updates libraries with allLibraries and write access", func() {
|
||||
mgr := &tests.MockPluginManager{}
|
||||
all := true
|
||||
wr := true
|
||||
err := applyPluginEdit(context.Background(), mgr, cur,
|
||||
pluginEditOptions{allLibraries: &all, writeAccess: &wr})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mgr.UpdatePluginLibrariesCalls).To(HaveLen(1))
|
||||
Expect(mgr.UpdatePluginLibrariesCalls[0].AllLibraries).To(BeTrue())
|
||||
Expect(mgr.UpdatePluginLibrariesCalls[0].AllowWriteAccess).To(BeTrue())
|
||||
})
|
||||
|
||||
It("preserves existing fields when only the write-access flag changes", func() {
|
||||
mgr := &tests.MockPluginManager{}
|
||||
no := false
|
||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{writeAccess: &no})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mgr.UpdatePluginLibrariesCalls).To(HaveLen(1))
|
||||
Expect(mgr.UpdatePluginLibrariesCalls[0].LibrariesJSON).To(Equal(`[1,2]`)) // not wiped
|
||||
Expect(mgr.UpdatePluginLibrariesCalls[0].AllowWriteAccess).To(BeFalse())
|
||||
})
|
||||
|
||||
It("preserves existing users when only the all-users flag changes", func() {
|
||||
mgr := &tests.MockPluginManager{}
|
||||
all := true
|
||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{allUsers: &all})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mgr.UpdatePluginUsersCalls[0].UsersJSON).To(Equal(`["bob"]`)) // not wiped
|
||||
})
|
||||
|
||||
It("parses a comma-separated users value into a JSON array", func() {
|
||||
mgr := &tests.MockPluginManager{}
|
||||
users := "alice, bob"
|
||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{users: &users})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mgr.UpdatePluginUsersCalls[0].UsersJSON).To(Equal(`["alice","bob"]`))
|
||||
})
|
||||
|
||||
It("passes a JSON-array users value through unchanged", func() {
|
||||
mgr := &tests.MockPluginManager{}
|
||||
users := `["alice","bob"]`
|
||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{users: &users})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mgr.UpdatePluginUsersCalls[0].UsersJSON).To(Equal(`["alice","bob"]`))
|
||||
})
|
||||
|
||||
It("rejects a malformed JSON-array users value", func() {
|
||||
mgr := &tests.MockPluginManager{}
|
||||
users := `["alice"`
|
||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{users: &users})
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(mgr.UpdatePluginUsersCalls).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("parses a comma-separated libraries value into a JSON array of ints", func() {
|
||||
mgr := &tests.MockPluginManager{}
|
||||
libs := "1, 2"
|
||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{libraries: &libs})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mgr.UpdatePluginLibrariesCalls[0].LibrariesJSON).To(Equal(`[1,2]`))
|
||||
})
|
||||
|
||||
It("rejects a non-integer library ID", func() {
|
||||
mgr := &tests.MockPluginManager{}
|
||||
libs := "1,abc"
|
||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{libraries: &libs})
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(mgr.UpdatePluginLibrariesCalls).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("clears allUsers when an explicit users list is set", func() {
|
||||
mgr := &tests.MockPluginManager{}
|
||||
cur.AllUsers = true
|
||||
users := "alice"
|
||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{users: &users})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mgr.UpdatePluginUsersCalls[0].UsersJSON).To(Equal(`["alice"]`))
|
||||
Expect(mgr.UpdatePluginUsersCalls[0].AllUsers).To(BeFalse())
|
||||
})
|
||||
|
||||
It("clears allLibraries when an explicit libraries list is set", func() {
|
||||
mgr := &tests.MockPluginManager{}
|
||||
cur.AllLibraries = true
|
||||
libs := "1"
|
||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{libraries: &libs})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mgr.UpdatePluginLibrariesCalls[0].LibrariesJSON).To(Equal(`[1]`))
|
||||
Expect(mgr.UpdatePluginLibrariesCalls[0].AllLibraries).To(BeFalse())
|
||||
})
|
||||
|
||||
It("does nothing and errors when no fields are set", func() {
|
||||
mgr := &tests.MockPluginManager{}
|
||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{})
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("aborts the config update when validation fails", func() {
|
||||
mgr := &tests.MockPluginManager{ValidateError: errors.New("bad config")}
|
||||
cfg := `{"key":"val"}`
|
||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{config: &cfg})
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(mgr.ValidatePluginConfigCalls).To(HaveLen(1))
|
||||
Expect(mgr.UpdatePluginConfigCalls).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("isPackagePath", func() {
|
||||
It("is true for any .ndp path", func() {
|
||||
Expect(isPackagePath("/some/dir/x.ndp")).To(BeTrue())
|
||||
})
|
||||
It("is true for a non-existent .ndp path (so ReadManifest reports the error)", func() {
|
||||
Expect(isPackagePath("/nope/x.ndp")).To(BeTrue())
|
||||
})
|
||||
It("is false for a bare plugin id", func() {
|
||||
Expect(isPackagePath("my-plugin")).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("formatPluginInfo", func() {
|
||||
It("renders installed plugin details as text", func() {
|
||||
p := &model.Plugin{ID: "alpha", Manifest: `{"name":"Alpha","version":"1.0.0","author":"me"}`, Enabled: true}
|
||||
out, err := formatPluginInfo(p, "text")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(out).To(ContainSubstring("alpha"))
|
||||
Expect(out).To(ContainSubstring("Alpha"))
|
||||
})
|
||||
It("renders json", func() {
|
||||
p := &model.Plugin{ID: "alpha", Manifest: `{}`}
|
||||
out, err := formatPluginInfo(p, "json")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(out).To(ContainSubstring("alpha"))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("formatManifestInfo", func() {
|
||||
It("renders text with name, version, author", func() {
|
||||
m := &plugins.Manifest{Name: "My Plugin", Version: "2.0.0", Author: "me"}
|
||||
out, err := formatManifestInfo(m, "abc123", "text")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(out).To(ContainSubstring("My Plugin"))
|
||||
Expect(out).To(ContainSubstring("2.0.0"))
|
||||
Expect(out).To(ContainSubstring("me"))
|
||||
})
|
||||
|
||||
It("omits Description and Website when nil", func() {
|
||||
m := &plugins.Manifest{Name: "X", Version: "1.0.0", Author: "a"}
|
||||
out, err := formatManifestInfo(m, "abc123", "text")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(out).ToNot(ContainSubstring("Description:"))
|
||||
Expect(out).ToNot(ContainSubstring("Website:"))
|
||||
})
|
||||
|
||||
It("includes Description and Website when set", func() {
|
||||
desc := "a cool plugin"
|
||||
site := "https://example.com"
|
||||
m := &plugins.Manifest{Name: "X", Version: "1.0.0", Author: "a", Description: &desc, Website: &site}
|
||||
out, err := formatManifestInfo(m, "abc123", "text")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(out).To(ContainSubstring("a cool plugin"))
|
||||
Expect(out).To(ContainSubstring("https://example.com"))
|
||||
})
|
||||
|
||||
It("renders valid json", func() {
|
||||
m := &plugins.Manifest{Name: "X", Version: "1.0.0", Author: "a"}
|
||||
out, err := formatManifestInfo(m, "abc123", "json")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(out).To(ContainSubstring("\"name\""))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("formatPluginInfo enriched text", func() {
|
||||
var fixedTime time.Time
|
||||
|
||||
BeforeEach(func() {
|
||||
fixedTime = time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC)
|
||||
})
|
||||
|
||||
It("includes Users, Libraries, Permissions, Created, Updated in text output", func() {
|
||||
p := &model.Plugin{
|
||||
ID: "myplugin",
|
||||
Manifest: `{"name":"My Plugin","version":"1.0.0","author":"me","permissions":{"users":{},"subsonicapi":{}}}`,
|
||||
Enabled: true,
|
||||
Users: "alice,bob",
|
||||
Libraries: "1,2",
|
||||
CreatedAt: fixedTime,
|
||||
UpdatedAt: fixedTime.Add(time.Hour),
|
||||
}
|
||||
out, err := formatPluginInfo(p, "text")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(out).To(ContainSubstring("alice,bob"))
|
||||
Expect(out).To(ContainSubstring("1,2"))
|
||||
Expect(out).To(ContainSubstring("subsonicapi"))
|
||||
Expect(out).To(ContainSubstring("users"))
|
||||
Expect(out).To(ContainSubstring("2025-01-15T12:00:00Z"))
|
||||
})
|
||||
|
||||
It("omits Users and Libraries lines when empty", func() {
|
||||
p := &model.Plugin{
|
||||
ID: "myplugin",
|
||||
Manifest: `{"name":"X","version":"1.0.0","author":"me"}`,
|
||||
CreatedAt: fixedTime,
|
||||
UpdatedAt: fixedTime,
|
||||
}
|
||||
out, err := formatPluginInfo(p, "text")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(out).ToNot(ContainSubstring("Users:"))
|
||||
Expect(out).ToNot(ContainSubstring("Libraries:"))
|
||||
})
|
||||
|
||||
It("does not alter json output", func() {
|
||||
p := &model.Plugin{ID: "x", Manifest: `{}`, Users: "alice"}
|
||||
out, err := formatPluginInfo(p, "json")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(out).To(ContainSubstring(`"x"`))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("formatManifestInfo enriched text", func() {
|
||||
It("includes Permissions when declared", func() {
|
||||
p := &plugins.Permissions{Http: &plugins.HTTPPermission{}}
|
||||
m := &plugins.Manifest{Name: "P", Version: "1.0.0", Author: "a", Permissions: p}
|
||||
out, err := formatManifestInfo(m, "abc123", "text")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(out).To(ContainSubstring("http"))
|
||||
Expect(out).To(ContainSubstring("Permissions:"))
|
||||
})
|
||||
|
||||
It("omits Permissions line when no permissions declared", func() {
|
||||
m := &plugins.Manifest{Name: "P", Version: "1.0.0", Author: "a"}
|
||||
out, err := formatManifestInfo(m, "abc123", "text")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(out).ToNot(ContainSubstring("Permissions:"))
|
||||
})
|
||||
|
||||
It("does not alter json output", func() {
|
||||
p := &plugins.Permissions{Http: &plugins.HTTPPermission{}}
|
||||
m := &plugins.Manifest{Name: "P", Version: "1.0.0", Author: "a", Permissions: p}
|
||||
out, err := formatManifestInfo(m, "abc123", "json")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(out).To(ContainSubstring(`"name"`))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("rescanPlugins", func() {
|
||||
It("calls RescanPlugins on the manager", func() {
|
||||
mgr := &tests.MockPluginManager{}
|
||||
err := rescanPlugins(context.Background(), mgr)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mgr.RescanPluginsCalls).To(Equal(1))
|
||||
})
|
||||
})
|
||||
|
|
@ -382,7 +382,7 @@ func (m *Manager) ValidatePluginConfig(ctx context.Context, id, configJSON strin
|
|||
return fmt.Errorf("getting plugin from DB: %w", err)
|
||||
}
|
||||
|
||||
manifest, err := readManifest(plugin.Path)
|
||||
manifest, err := ReadManifest(plugin.Path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading manifest: %w", err)
|
||||
}
|
||||
|
|
@ -460,7 +460,7 @@ func (m *Manager) updatePluginSettings(ctx context.Context, id string, updateFn
|
|||
shouldDisable := false
|
||||
disableReason := ""
|
||||
if wasEnabled {
|
||||
manifest, err := readManifest(plugin.Path)
|
||||
manifest, err := ReadManifest(plugin.Path)
|
||||
if err == nil && manifest.Permissions != nil {
|
||||
if manifest.Permissions.Users != nil && !hasValidUsersConfig(plugin.Users, plugin.AllUsers) {
|
||||
shouldDisable = true
|
||||
|
|
@ -591,7 +591,7 @@ func (m *Manager) UnloadDisabledPlugins(ctx context.Context) {
|
|||
// before a plugin can be enabled. Returns an error if any gate condition fails.
|
||||
func (m *Manager) checkPermissionGates(p *model.Plugin) error {
|
||||
// Parse manifest to check permissions
|
||||
manifest, err := readManifest(p.Path)
|
||||
manifest, err := ReadManifest(p.Path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading manifest: %w", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -155,12 +155,12 @@ func (m *Manager) extractManifest(ndpPath string) (*PluginMetadata, error) {
|
|||
return nil, fmt.Errorf("manager is stopped")
|
||||
}
|
||||
|
||||
manifest, err := readManifest(ndpPath)
|
||||
manifest, err := ReadManifest(ndpPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sha256Hash, err := computeFileSHA256(ndpPath)
|
||||
sha256Hash, err := ComputeFileSHA256(ndpPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("computing hash: %w", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,9 +36,9 @@ func marshalManifest(m *Manifest) string {
|
|||
return string(b)
|
||||
}
|
||||
|
||||
// computeFileSHA256 computes the SHA-256 hash of a file without loading it into memory.
|
||||
// ComputeFileSHA256 computes the SHA-256 hash of a file without loading it into memory.
|
||||
// This is used for quick change detection before full plugin compilation.
|
||||
func computeFileSHA256(path string) (string, error) {
|
||||
func ComputeFileSHA256(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
|
@ -165,7 +165,7 @@ func (m *Manager) syncPlugins(ctx context.Context, folder string) error {
|
|||
dbPlugin, exists := pluginsInDB[name]
|
||||
|
||||
// Compute SHA256 first (lightweight operation) to check if plugin changed
|
||||
sha256Hash, err := computeFileSHA256(path)
|
||||
sha256Hash, err := ComputeFileSHA256(path)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Failed to compute SHA256 for plugin", "plugin", name, "path", path, err)
|
||||
continue
|
||||
|
|
|
|||
30
plugins/manager_sync_test.go
Normal file
30
plugins/manager_sync_test.go
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
package plugins
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("ComputeFileSHA256", func() {
|
||||
It("returns a consistent 64-char lowercase hex hash for the same file", func() {
|
||||
dir := GinkgoT().TempDir()
|
||||
ndpPath := filepath.Join(dir, "test.ndp")
|
||||
err := createTestPackage(ndpPath, &Manifest{Name: "S", Author: "a", Version: "1.0.0"}, []byte{0x00, 0x61, 0x73, 0x6d})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
hash1, err := ComputeFileSHA256(ndpPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
hash2, err := ComputeFileSHA256(ndpPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(hash1).To(Equal(hash2))
|
||||
Expect(hash1).To(MatchRegexp(`^[0-9a-f]{64}$`))
|
||||
})
|
||||
|
||||
It("returns an error for a non-existent path", func() {
|
||||
_, err := ComputeFileSHA256(filepath.Join(GinkgoT().TempDir(), "does-not-exist.ndp"))
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
|
@ -158,7 +158,7 @@ func (m *Manager) processPluginEvent(pluginName string) {
|
|||
switch action {
|
||||
case actionUpdate:
|
||||
// File changed - check SHA256 first, then extract manifest if needed
|
||||
sha256Hash, err := computeFileSHA256(ndpPath)
|
||||
sha256Hash, err := ComputeFileSHA256(ndpPath)
|
||||
if err != nil {
|
||||
log.Error(m.ctx, "Failed to compute SHA256 for changed plugin", "plugin", pluginName, err)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -3,10 +3,37 @@ package plugins
|
|||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/santhosh-tekuri/jsonschema/v6"
|
||||
)
|
||||
|
||||
// DeclaredNames returns the sorted names of the non-nil permission fields. It
|
||||
// reflects over the generated json tags so new permission types are picked up
|
||||
// automatically rather than via a hand-maintained list.
|
||||
func (p *Permissions) DeclaredNames() []string {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
var names []string
|
||||
v := reflect.ValueOf(*p)
|
||||
t := v.Type()
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
f := v.Field(i)
|
||||
if f.Kind() != reflect.Pointer || f.IsNil() {
|
||||
continue
|
||||
}
|
||||
tag := t.Field(i).Tag.Get("json")
|
||||
if name, _, _ := strings.Cut(tag, ","); name != "" && name != "-" {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
//go:generate go tool go-jsonschema -p plugins --struct-name-from-title -o manifest_gen.go manifest-schema.json
|
||||
|
||||
// ParseManifest unmarshals manifest JSON and performs cross-field validation.
|
||||
|
|
|
|||
|
|
@ -464,3 +464,27 @@ var _ = Describe("Manifest", func() {
|
|||
})
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("Permissions.DeclaredNames", func() {
|
||||
It("returns nil for a nil receiver", func() {
|
||||
var p *Permissions
|
||||
Expect(p.DeclaredNames()).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns declared names sorted", func() {
|
||||
p := &Permissions{
|
||||
Subsonicapi: &SubsonicAPIPermission{},
|
||||
Users: &UsersPermission{},
|
||||
}
|
||||
Expect(p.DeclaredNames()).To(Equal([]string{"subsonicapi", "users"}))
|
||||
})
|
||||
|
||||
It("returns all declared names sorted regardless of field order", func() {
|
||||
p := &Permissions{
|
||||
Http: &HTTPPermission{},
|
||||
Artwork: &ArtworkPermission{},
|
||||
Cache: &CachePermission{},
|
||||
}
|
||||
Expect(p.DeclaredNames()).To(Equal([]string{"artwork", "cache", "http"}))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -72,9 +72,10 @@ func openPackage(ndpPath string) (*ndpPackage, error) {
|
|||
}, nil
|
||||
}
|
||||
|
||||
// readManifest reads only the manifest from an .ndp file without loading the wasm bytes.
|
||||
// This is useful for quick plugin discovery.
|
||||
func readManifest(ndpPath string) (*Manifest, error) {
|
||||
// ReadManifest reads and validates the manifest from a .ndp file without loading
|
||||
// the wasm bytes (it runs ParseManifest, so JSON-schema and cross-field
|
||||
// validation are applied). Useful for quick plugin discovery and validation.
|
||||
func ReadManifest(ndpPath string) (*Manifest, error) {
|
||||
// Open the zip archive
|
||||
zr, err := zip.OpenReader(ndpPath)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -132,8 +132,8 @@ var _ = Describe("ndpPackage", func() {
|
|||
})
|
||||
})
|
||||
|
||||
Describe("readManifest", func() {
|
||||
It("should read only the manifest without loading wasm", func() {
|
||||
Describe("ReadManifest", func() {
|
||||
It("parses the manifest from a package that also contains wasm", func() {
|
||||
ndpPath := filepath.Join(tmpDir, "test.ndp")
|
||||
manifest := &Manifest{
|
||||
Name: "Test Plugin",
|
||||
|
|
@ -141,57 +141,60 @@ var _ = Describe("ndpPackage", func() {
|
|||
Version: "1.0.0",
|
||||
Description: new("A test plugin"),
|
||||
}
|
||||
wasmBytes := make([]byte, 1024*1024) // 1MB of zeros
|
||||
|
||||
err := createTestPackage(ndpPath, manifest, wasmBytes)
|
||||
err := createTestPackage(ndpPath, manifest, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
m, err := readManifest(ndpPath)
|
||||
m, err := ReadManifest(ndpPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(m.Name).To(Equal("Test Plugin"))
|
||||
Expect(*m.Description).To(Equal("A test plugin"))
|
||||
})
|
||||
|
||||
It("should return error for missing manifest", func() {
|
||||
ndpPath := filepath.Join(tmpDir, "no-manifest.ndp")
|
||||
It("returns an error for a non-existent file", func() {
|
||||
_, err := ReadManifest(filepath.Join(tmpDir, "does-not-exist.ndp"))
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("returns a specific error for a package missing manifest.json", func() {
|
||||
ndpPath := filepath.Join(tmpDir, "no-manifest.ndp")
|
||||
f, err := os.Create(ndpPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer f.Close()
|
||||
|
||||
zw := newTestZipWriter(f)
|
||||
err = zw.addFile("plugin.wasm", []byte{0x00})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
err = zw.close()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(zw.addFile("plugin.wasm", []byte{0x00})).To(Succeed())
|
||||
Expect(zw.close()).To(Succeed())
|
||||
|
||||
_, err = readManifest(ndpPath)
|
||||
_, err = ReadManifest(ndpPath)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("missing manifest.json"))
|
||||
})
|
||||
|
||||
It("fails for a package with a schema-invalid manifest", func() {
|
||||
ndp := filepath.Join(tmpDir, "bad.ndp")
|
||||
// empty required fields violate the manifest JSON schema
|
||||
err := createTestPackage(ndp, &Manifest{}, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, err = ReadManifest(ndp)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
Describe("ComputePackageSHA256", func() {
|
||||
It("should compute consistent hash for same file", func() {
|
||||
ndpPath := filepath.Join(tmpDir, "test.ndp")
|
||||
It("enforces cross-field validation", func() {
|
||||
ndp := filepath.Join(tmpDir, "crossfield.ndp")
|
||||
// subsonicapi permission without users: violates cross-field rule
|
||||
manifest := &Manifest{
|
||||
Name: "Test Plugin",
|
||||
Author: "Test Author",
|
||||
Name: "X",
|
||||
Author: "me",
|
||||
Version: "1.0.0",
|
||||
Permissions: &Permissions{Subsonicapi: &SubsonicAPIPermission{}},
|
||||
}
|
||||
wasmBytes := []byte{0x00, 0x61, 0x73, 0x6d}
|
||||
|
||||
err := createTestPackage(ndpPath, manifest, wasmBytes)
|
||||
err := createTestPackage(ndp, manifest, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
hash1, err := computeFileSHA256(ndpPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
hash2, err := computeFileSHA256(ndpPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(hash1).To(Equal(hash2))
|
||||
Expect(hash1).To(HaveLen(64)) // SHA-256 produces 64 hex characters
|
||||
_, err = ReadManifest(ndp)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("subsonicapi"))
|
||||
Expect(err.Error()).To(ContainSubstring("users"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue