feat(cli): add a 'navidrome plugin' CLI for managing and inspecting plugins (#5682)
* 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.
2026-06-28 13:26:32 -04:00
package cmd
import (
"context"
"encoding/csv"
"encoding/json"
"fmt"
"io"
"os"
"strconv"
"strings"
"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
2026-08-14 15:22:59 -04:00
w := newTabWriter ( & sb )
feat(cli): add a 'navidrome plugin' CLI for managing and inspecting plugins (#5682)
* 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.
2026-06-28 13:26:32 -04:00
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 )
}