navidrome/plugins/package.go
Deluan Quintão 5a3ac80a8a
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(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

113 lines
2.7 KiB
Go

package plugins
import (
"archive/zip"
"errors"
"fmt"
"io"
)
const (
// PackageExtension is the file extension for Navidrome plugin packages.
PackageExtension = ".ndp"
// manifestFileName is the name of the manifest file inside the package.
manifestFileName = "manifest.json"
// wasmFileName is the name of the WebAssembly module inside the package.
wasmFileName = "plugin.wasm"
)
// ndpPackage represents a loaded .ndp plugin package.
// It contains the manifest and wasm bytes read from the archive.
type ndpPackage struct {
Manifest *Manifest
WasmBytes []byte
}
// openPackage opens an .ndp file and extracts the manifest and wasm bytes.
// The caller does not need to call Close() - all resources are read into memory.
func openPackage(ndpPath string) (*ndpPackage, error) {
// Open the zip archive
zr, err := zip.OpenReader(ndpPath)
if err != nil {
return nil, fmt.Errorf("opening package: %w", err)
}
defer zr.Close()
var manifestBytes []byte
var wasmBytes []byte
for _, f := range zr.File {
switch f.Name {
case manifestFileName:
manifestBytes, err = readZipFile(f)
if err != nil {
return nil, fmt.Errorf("reading manifest: %w", err)
}
case wasmFileName:
wasmBytes, err = readZipFile(f)
if err != nil {
return nil, fmt.Errorf("reading wasm: %w", err)
}
}
}
if manifestBytes == nil {
return nil, errors.New("package missing manifest.json")
}
if wasmBytes == nil {
return nil, errors.New("package missing plugin.wasm")
}
// Parse and validate manifest
manifest, err := ParseManifest(manifestBytes)
if err != nil {
return nil, fmt.Errorf("parsing manifest: %w", err)
}
return &ndpPackage{
Manifest: manifest,
WasmBytes: wasmBytes,
}, nil
}
// 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 {
return nil, fmt.Errorf("opening package: %w", err)
}
defer zr.Close()
for _, f := range zr.File {
if f.Name == manifestFileName {
manifestBytes, err := readZipFile(f)
if err != nil {
return nil, fmt.Errorf("reading manifest: %w", err)
}
manifest, err := ParseManifest(manifestBytes)
if err != nil {
return nil, fmt.Errorf("parsing manifest: %w", err)
}
return manifest, nil
}
}
return nil, errors.New("package missing manifest.json")
}
// readZipFile reads the contents of a file from a zip archive.
func readZipFile(f *zip.File) ([]byte, error) {
rc, err := f.Open()
if err != nil {
return nil, err
}
defer rc.Close()
return io.ReadAll(rc)
}