mirror of
https://github.com/navidrome/navidrome.git
synced 2026-07-09 17:18:45 +00:00
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.
558 lines
17 KiB
Go
558 lines
17 KiB
Go
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)
|
|
}
|