feat(plugins): add approved community catalog hub (#355)
This commit is contained in:
@@ -0,0 +1,477 @@
|
||||
# Plugin Hub and Approved Community Catalog Implementation Plan
|
||||
|
||||
> **For implementation:** Use the `executing-plans` skill and complete the
|
||||
> phases in dependency order. Commands assume the repository root is the cwd.
|
||||
|
||||
**Goal:** Replace the current raw plugin cards with an operator-focused Plugin
|
||||
Hub that explains what each plugin does, shows setup and release information,
|
||||
links to its source, and lets a server administrator opt into Silo-approved
|
||||
community plugins without manually managing repository URLs.
|
||||
|
||||
**Audience:** Server administrators and casual homelab operators. This is not an
|
||||
end-user plugin surface. Developer identifiers and repository details remain
|
||||
available, but they are secondary to plain-language purpose, setup, health, and
|
||||
update information.
|
||||
|
||||
**Approval meaning:** An **Approved community** plugin has been reviewed by Silo
|
||||
maintainers, validated to install and work as described, and considered safe for
|
||||
its documented use at the time of approval. It remains community-maintained;
|
||||
approval does not transfer maintenance or support ownership to the Silo core
|
||||
team and is not a permanent guarantee against future vulnerabilities.
|
||||
|
||||
**Architecture:** Add typed presentation fields to `PluginManifest`; keep
|
||||
version-specific GitHub release notes in catalog packages; create a separately
|
||||
owned `silo-community/silo-plugins` catalog with an allowlisted approval gate;
|
||||
and teach Silo to manage official, approved-community, and custom sources as
|
||||
different provenance classes. The admin UI consumes additive `/api/v1` fields,
|
||||
uses a default-off community-channel setting, and opens URL-addressable plugin
|
||||
details from the Installed and Catalog views.
|
||||
|
||||
**Delivery shape:** This is one feature with coordinated PRs across
|
||||
`silo-plugin-sdk`, `silo-plugins`, the new community catalog, `silo-server`, and
|
||||
the affected plugin repositories. Do not combine all repositories into one
|
||||
commit or PR.
|
||||
|
||||
---
|
||||
|
||||
## Product Decisions and Defaults
|
||||
|
||||
- The primary tabs remain **Installed** and **Catalog**.
|
||||
- The catalog contains Silo-maintained plugins by default.
|
||||
- **Include approved community plugins** is a server-wide setting and defaults
|
||||
to `false` for both fresh installs and upgrades.
|
||||
- Enabling the setting manages approved community catalog sources on the
|
||||
administrator's behalf; it does not ask for a GitHub URL.
|
||||
- Disabling the setting hides community catalog entries and pauses update
|
||||
discovery for community installations. It never disables or uninstalls an
|
||||
installed plugin.
|
||||
- When community installations exist, disabling requires confirmation and
|
||||
states how many plugins will stop receiving update discovery.
|
||||
- Manual catalog repositories and archive uploads remain available under
|
||||
**Manage sources → Advanced**.
|
||||
- Cards use **Silo maintained**, **Approved community**, and **External source**
|
||||
as the provenance labels. Do not use one ambiguous `Verified` badge.
|
||||
- The approval badge is catalog/host-derived. A plugin manifest cannot declare
|
||||
itself approved.
|
||||
- In-app release content is labeled **What's new**. The first implementation
|
||||
stores the latest release notes and links to the complete external changelog;
|
||||
it does not retain a full in-app version history.
|
||||
- The Plugin Hub is web-admin-only. No Android or Apple client changes are
|
||||
required.
|
||||
|
||||
## Explicitly Out of Scope
|
||||
|
||||
- An end-user plugin marketplace or plugin controls outside the admin UI.
|
||||
- Selecting and transferring the first batch of repositories into
|
||||
`silo-community`; that migration should follow this infrastructure plan with
|
||||
an explicit repository list.
|
||||
- Plugin rollback or installation of arbitrary historical versions.
|
||||
- Cryptographic artifact signing or reproducible-build attestation beyond the
|
||||
existing release checksum contract.
|
||||
- A remote kill switch. Removing a plugin from the approved catalog stops new
|
||||
installs and updates, but does not remotely stop already-installed code.
|
||||
- Automated proof that a plugin is safe. Approval includes human review and
|
||||
runtime validation.
|
||||
|
||||
---
|
||||
|
||||
## Verified Baseline
|
||||
|
||||
- `PluginManifest` has `metadata` and `category` but no plugin-level display
|
||||
name, summary, description, setup guide, publisher, or source links.
|
||||
`CapabilityDescriptor.description` exists but describes individual
|
||||
capabilities rather than the plugin as a whole.
|
||||
- `silo-plugins` already writes `repo_url` into each catalog package, but
|
||||
`silo-server/internal/plugins/catalog_service.go` does not decode or return
|
||||
it.
|
||||
- The catalog updater fetches the GitHub release tag and assets but does not
|
||||
preserve the release page URL, publication time, or release body.
|
||||
- The server seeds one official repository only when *no* repository rows
|
||||
exist. This is not sufficient for multiple managed channels or for adopting
|
||||
an existing installation that already has custom repositories.
|
||||
- Catalog fetches are live and unbounded, and a failing source is skipped. No
|
||||
last-known-good catalog payload is available to keep browsing and release
|
||||
notes usable during an outage.
|
||||
- Auto-update selection currently chooses the highest version for a plugin ID
|
||||
across all enabled repositories. Updates must instead stay pinned to the
|
||||
installation's repository so a custom source cannot shadow an official or
|
||||
approved plugin.
|
||||
- `AdminPlugins.tsx` is a single large page. Installed and available cards show
|
||||
raw plugin IDs, versions, capability chips, and operational controls but no
|
||||
plugin-level description, release notes, or source provenance.
|
||||
|
||||
---
|
||||
|
||||
## Public Contracts
|
||||
|
||||
### SDK manifest presentation
|
||||
|
||||
In `silo-plugin-sdk/proto/silo/plugin/v1/common.proto`, add a new additive
|
||||
message and field:
|
||||
|
||||
```text
|
||||
PluginPresentation presentation = 13;
|
||||
|
||||
PluginPresentation:
|
||||
display_name
|
||||
summary
|
||||
description_markdown
|
||||
setup_markdown
|
||||
homepage_url
|
||||
source_url
|
||||
support_url
|
||||
changelog_url
|
||||
publisher_name
|
||||
publisher_url
|
||||
license_spdx
|
||||
```
|
||||
|
||||
Contract rules:
|
||||
|
||||
- Existing manifests without `presentation` remain valid.
|
||||
- When `presentation` is present, validate lengths and accept only absolute
|
||||
`http` or `https` links. Reject control characters and unsafe schemes.
|
||||
- Recommended limits: `display_name` 120 characters, `summary` 240 characters,
|
||||
and each Markdown field 32 KiB.
|
||||
- SDK validation does not require presentation fields globally during the
|
||||
compatibility window. Official and approved-community catalog CI applies the
|
||||
stricter publishing requirement.
|
||||
- Markdown is CommonMark-style text. Raw HTML is not part of the contract.
|
||||
- `publisher_*` and source links are self-declared identity information; they
|
||||
never determine Silo approval.
|
||||
|
||||
Regenerate the Go protobuf output, update manifest fixtures and documentation,
|
||||
and publish a new additive SDK minor release before downstream repositories
|
||||
consume these fields.
|
||||
|
||||
### Catalog release and approval metadata
|
||||
|
||||
Extend the catalog package JSON shared by the official and community catalogs:
|
||||
|
||||
```text
|
||||
repo_url
|
||||
release:
|
||||
url
|
||||
published_at
|
||||
notes_markdown
|
||||
approval: # community catalog only
|
||||
approved_at
|
||||
review_url
|
||||
```
|
||||
|
||||
- `repo_url` remains the canonical source-code link generated from the GitHub
|
||||
repository that produced the release.
|
||||
- `release` is generated from the GitHub Releases API. Bound
|
||||
`notes_markdown` to 64 KiB before writing the catalog.
|
||||
- `approval` comes from the community catalog's reviewed allowlist, never from
|
||||
the plugin's manifest or release payload.
|
||||
- `changelog_url` remains in `PluginPresentation`; when absent, the web UI may
|
||||
link to the repository's Releases page derived from `repo_url`.
|
||||
- Preserve the complete protobuf manifest during catalog generation. Do not
|
||||
reduce it to a hand-built subset.
|
||||
|
||||
### Additive server API fields
|
||||
|
||||
Add `GET` and `PUT /api/v1/admin/plugins/catalog-settings`:
|
||||
|
||||
```text
|
||||
include_approved_community_plugins: boolean
|
||||
approved_community_plugin_count: number
|
||||
installed_community_plugin_count: number
|
||||
community_updates_paused: boolean
|
||||
```
|
||||
|
||||
The `PUT` body accepts only
|
||||
`include_approved_community_plugins`. It persists the preference, reconciles
|
||||
managed repository rows, and returns the new state. A source refresh failure
|
||||
does not roll the preference back; the repository status reports the failure
|
||||
and the catalog falls back to cached data when available.
|
||||
|
||||
Add `GET /api/v1/admin/plugins/capabilities` for feature detection. It reports
|
||||
support for typed presentation metadata, release notes, approved community
|
||||
catalogs, and last-known catalog caching.
|
||||
|
||||
Extend existing responses additively:
|
||||
|
||||
- `PluginRepository`: `source_kind`, `managed`, `last_fetch_error`,
|
||||
`last_fetch_error_at`.
|
||||
- `PluginCatalogEntry`: typed `presentation`, `source_kind`, repository display
|
||||
name, `repo_url`, `release`, optional `approval`, and `stale`.
|
||||
- `PluginInstallation`: typed `presentation`, source/repository provenance,
|
||||
the matching current or available release metadata when known, and
|
||||
`updates_paused`.
|
||||
|
||||
Keep existing `metadata`, capability, route, asset, and configuration fields
|
||||
unchanged for `/api/v1` compatibility.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — SDK Presentation Contract
|
||||
|
||||
Repository: `silo-plugin-sdk`
|
||||
|
||||
- [ ] Add `PluginPresentation` and field 13 to the protobuf contract; regenerate
|
||||
`pkg/pluginproto` with `make proto`.
|
||||
- [ ] Add reusable URL and length validation in
|
||||
`pkg/pluginsdk/manifest/manifest.go` without making presentation mandatory for
|
||||
older plugins.
|
||||
- [ ] Add decode/round-trip/validation tests for complete, partial, absent, and
|
||||
unsafe presentation blocks.
|
||||
- [ ] Document every presentation field and provide one complete example
|
||||
manifest for plugin authors.
|
||||
- [ ] Publish an additive SDK minor release and verify the tag is consumable
|
||||
from a clean downstream module.
|
||||
|
||||
Verification: `GOWORK=off go test ./...`, `make proto`, and a clean-tree check
|
||||
after regeneration.
|
||||
|
||||
## Phase 2 — Catalog Tooling and Community Approval Gate
|
||||
|
||||
Repositories: `Silo-Server/silo-plugins` and new
|
||||
`silo-community/silo-plugins`
|
||||
|
||||
- [ ] Extend the shared catalog generator's GitHub `Release` DTO and
|
||||
`CatalogPackage` to preserve source, publication, release-note, and approval
|
||||
fields.
|
||||
- [ ] Keep the update workflow's shared concurrency group so simultaneous
|
||||
plugin releases cannot race on `manifest.json`.
|
||||
- [ ] Add tests proving the generator preserves the full manifest, bounds
|
||||
release notes, derives safe URLs, and produces deterministic JSON.
|
||||
- [ ] Create `silo-community/silo-plugins` with the same catalog package and
|
||||
release-asset contract as the official catalog.
|
||||
- [ ] Add `approved-plugins.json` to the community catalog. Each active entry
|
||||
contains `plugin_id`, exact GitHub repository, `approved_at`, and a URL to the
|
||||
approval review/evidence.
|
||||
- [ ] Make community catalog updates reject releases whose repository or
|
||||
plugin ID is not active in `approved-plugins.json`.
|
||||
- [ ] Protect the community catalog's main branch and approval registry with
|
||||
CODEOWNERS/required review. Release dispatch alone must not grant approval.
|
||||
- [ ] Add a rebuild/check command that removes packages no longer present in
|
||||
the active allowlist so catalog membership cannot remain stale indefinitely.
|
||||
- [ ] Publish an approval policy documenting the minimum review: successful
|
||||
builds/tests, checksum-bearing release artifacts, manifest/setup accuracy,
|
||||
runtime validation on a supported Silo version, source/release-workflow
|
||||
review, license, support path, and no known unsafe behavior.
|
||||
|
||||
Verification: `GOWORK=off go test ./...`; run the updater against fixtures for
|
||||
an approved and rejected repository; run the catalog rebuild/check twice to
|
||||
prove idempotence.
|
||||
|
||||
## Phase 3 — Server Repository Provenance, Preference, and Cache
|
||||
|
||||
Repository: `silo-server`
|
||||
|
||||
- [ ] Create a timestamped Goose migration with
|
||||
`make migrate-create NAME=plugin_catalog_provenance`.
|
||||
- [ ] Extend `plugin_repositories` with a nullable unique `managed_key`, a
|
||||
constrained `source_kind` (`silo`, `approved_community`, `external`), and
|
||||
last-fetch error fields. Existing rows default to `external` and the known
|
||||
official URL is adopted as `silo` during reconciliation.
|
||||
- [ ] Add a `plugin_catalog_cache` table keyed by repository, plugin ID, and
|
||||
version, storing the normalized catalog package JSON plus fetch time. Delete
|
||||
cache rows with the repository.
|
||||
- [ ] Add a plugin-owned setting key
|
||||
`plugins.include_approved_community_plugins`; missing/invalid values resolve
|
||||
to `false`.
|
||||
- [ ] Replace `seedDefaultRepository` with an idempotent managed-repository
|
||||
reconciler. It always adopts/upserts the official catalog and upserts
|
||||
`https://raw.githubusercontent.com/silo-community/silo-plugins/main/manifest.json`
|
||||
enabled according to the community setting. Structure the registry as a list
|
||||
so another approved community catalog can be added without a second toggle.
|
||||
- [ ] Managed repository URLs, names, keys, and provenance are read-only through
|
||||
manual repository CRUD. Custom repositories retain existing create/edit/delete
|
||||
behavior.
|
||||
- [ ] Bound repository-index downloads before JSON decoding. Validate and cache
|
||||
only accepted packages; on a successful refresh transactionally replace that
|
||||
repository's cache and clear its error state.
|
||||
- [ ] On a fetch failure, record a concise error and serve last-known cached
|
||||
entries with `stale=true`. An enabled source with no cache returns no entries
|
||||
but remains enabled for retry.
|
||||
- [ ] Resolve catalog duplicates deterministically by source precedence
|
||||
(`silo` → `approved_community` → `external`) before comparing versions within
|
||||
a source. A higher-version custom package must never shadow the same official
|
||||
or approved plugin ID.
|
||||
- [ ] Make update discovery repository-pinned: installations with a
|
||||
`repository_id` compare only against that repository's entries. Direct uploads
|
||||
without a repository do not gain catalog updates accidentally.
|
||||
- [ ] When a source is disabled, retain `available_version` but return
|
||||
`updates_paused=true`; applying the update is blocked until the source is
|
||||
enabled again.
|
||||
- [ ] Enrich installed responses from the on-disk manifest first and matching
|
||||
cached catalog metadata second, so descriptions and source links survive a
|
||||
network outage and release notes remain available after the community channel
|
||||
is disabled.
|
||||
|
||||
Tests:
|
||||
|
||||
- managed-source reconciliation is idempotent and adopts an existing official
|
||||
row without duplication;
|
||||
- the community preference defaults off and toggles all managed community
|
||||
sources without touching custom rows;
|
||||
- stale cache is served after fetch failure and replaced after recovery;
|
||||
- official/community/external duplicate precedence is deterministic;
|
||||
- update discovery cannot switch an installation to another repository;
|
||||
- disabling the community channel leaves installations enabled and marks their
|
||||
updates paused;
|
||||
- migration Up/Down and repository constraints behave correctly against the
|
||||
test database.
|
||||
|
||||
## Phase 4 — Server HTTP API
|
||||
|
||||
Repository: `silo-server`
|
||||
|
||||
- [ ] Add typed catalog-settings and capability handlers to
|
||||
`internal/api/handlers/plugins.go` or focused files in the same handler
|
||||
package; mount them under the existing acting-admin plugin route group.
|
||||
- [ ] Extend repository, catalog, and installation serializers with the
|
||||
additive presentation, release, provenance, approval, stale, and paused
|
||||
fields.
|
||||
- [ ] Compute `installed_community_plugin_count` by repository provenance, not
|
||||
by parsing plugin IDs or GitHub URLs.
|
||||
- [ ] Validate the `PUT` body strictly and make preference persistence plus
|
||||
managed-row reconciliation atomic from the caller's perspective.
|
||||
- [ ] Keep catalog source failures per-repository: one broken community or
|
||||
custom source must not fail the entire catalog response.
|
||||
- [ ] Add handler tests for default-off state, enable/disable, malformed input,
|
||||
community installation counts, additive response fields, stale-source
|
||||
reporting, and acting-admin authorization.
|
||||
|
||||
Verification:
|
||||
`GOWORK=off go test ./internal/plugins/... ./internal/api/handlers/...`.
|
||||
|
||||
## Phase 5 — Plugin Hub Web UI
|
||||
|
||||
Repository: `silo-server`
|
||||
|
||||
- [ ] Split `AdminPlugins.tsx` into focused components under
|
||||
`web/src/components/admin/plugins/` or `web/src/pages/admin-plugins/`: page
|
||||
shell, Installed list, Catalog grid, catalog controls, detail sheet,
|
||||
configuration content, Markdown renderer, and advanced source manager.
|
||||
- [ ] Add typed DTOs and TanStack Query hooks for plugin capabilities and
|
||||
catalog settings. Mutations invalidate settings, repositories, catalog, and
|
||||
installations together.
|
||||
- [ ] Keep **Installed** as the default tab and rename **Available** to
|
||||
**Catalog**.
|
||||
- [ ] Place **Include approved community plugins** in the Catalog header with
|
||||
this helper meaning: these plugins are validated by Silo to work as described
|
||||
and are considered safe, but are maintained and supported by community
|
||||
contributors.
|
||||
- [ ] Enabling shows a refresh state and then community entries. Disabling with
|
||||
installed community plugins opens a confirmation that reports the count and
|
||||
explains that plugins keep running while update discovery pauses.
|
||||
- [ ] Add search plus provenance/capability filters. Preserve the active tab,
|
||||
search, and filters in URL search parameters.
|
||||
- [ ] Catalog cards show display name, publisher/provenance, two-line summary,
|
||||
capabilities, version, configuration requirement, and Install. Installed
|
||||
rows show operational status, summary, version, Configure/Open, and
|
||||
Review update; move update policy, disable, and uninstall into secondary
|
||||
controls.
|
||||
- [ ] Use URL search parameters for addressable details:
|
||||
`installation=<id>` for installed plugins and
|
||||
`repository=<id>&plugin=<plugin_id>` for catalog entries. Browser Back closes
|
||||
the sheet and focus returns to the originating card. Use a full-width sheet
|
||||
on small screens.
|
||||
- [ ] Detail sections are **About**, **Setup**, **What's new**, and
|
||||
**Technical details**. Technical details include plugin ID, API/platform
|
||||
compatibility, publisher, license, source catalog, source repository, support,
|
||||
and changelog links.
|
||||
- [ ] Change update behavior from immediate `Update` to `Review update`; show
|
||||
the target release notes before the administrator confirms the update.
|
||||
- [ ] Add a constrained Markdown renderer. Raw HTML and images are disabled;
|
||||
only safe `http`/`https` links are rendered, and external links use
|
||||
`rel="noopener noreferrer"`.
|
||||
- [ ] Move repository CRUD and archive upload beneath **Manage sources →
|
||||
Advanced**. Managed official/community sources show status and fetch errors
|
||||
but cannot have their system URL edited or be deleted.
|
||||
- [ ] Backward-compatible UI fallbacks: humanize `plugin_id` when display name
|
||||
is absent; use the first capability description when summary is absent; show
|
||||
“No setup guide provided” or “No release notes were published” rather than an
|
||||
empty panel.
|
||||
- [ ] Add accessible labels, keyboard navigation, focus management, loading
|
||||
skeletons, empty states, and per-source stale/error messaging.
|
||||
|
||||
Frontend tests:
|
||||
|
||||
- default-off toggle and opt-in catalog refresh;
|
||||
- disable confirmation with installed community plugins;
|
||||
- provenance filters and badges;
|
||||
- legacy-manifest fallback rendering;
|
||||
- detail deep link and Back behavior;
|
||||
- safe Markdown links with raw HTML/images suppressed;
|
||||
- Review update requires confirmation and displays release notes;
|
||||
- stale catalog and missing release-note states.
|
||||
|
||||
Verification: `cd web && pnpm run lint`, `cd web && pnpm run format:check`,
|
||||
`cd web && pnpm test`, and `cd web && pnpm run build`. Capture desktop and
|
||||
mobile screenshots for the UI PR.
|
||||
|
||||
## Phase 6 — Manifest Backfill and Community Migration Readiness
|
||||
|
||||
Repositories: current first-party plugins and future community plugin repos
|
||||
|
||||
- [ ] Update every cataloged plugin to the released SDK version and add complete
|
||||
presentation metadata. Use plain language for homelab operators; capability
|
||||
descriptions remain technical and specific.
|
||||
- [ ] Require each repository to publish meaningful GitHub release notes so
|
||||
What's new is not an empty surface.
|
||||
- [ ] Standardize README setup instructions, license, security/support policy,
|
||||
CODEOWNERS or named maintainers, and the existing checksum-bearing release
|
||||
workflow.
|
||||
- [ ] Before transferring a selected plugin to `silo-community`, update its
|
||||
manifest publisher/source links and release dispatch target, add it to the
|
||||
community approval registry through review, and remove it from the official
|
||||
catalog in the same rollout window.
|
||||
- [ ] Verify a transferred plugin appears only once, installs from the community
|
||||
repository, remains repository-pinned for updates, and is hidden for admins
|
||||
who have not enabled the community channel.
|
||||
|
||||
---
|
||||
|
||||
## Rollout Order
|
||||
|
||||
1. Merge and release the SDK contract.
|
||||
2. Upgrade official catalog tooling and create the approved community catalog
|
||||
plus approval policy/registry.
|
||||
3. Land the server migration, managed-source reconciliation, cache, update
|
||||
pinning, and additive APIs.
|
||||
4. Land the Plugin Hub UI and keep the community switch default-off.
|
||||
5. Backfill presentation data and release notes in existing official plugins.
|
||||
6. Transfer selected plugins one at a time only after both catalogs and the
|
||||
deployed server understand provenance.
|
||||
|
||||
Older servers ignore new catalog/manifest fields; newer servers retain fallbacks
|
||||
for older manifests. Do not transfer a plugin out of the official catalog before
|
||||
the community catalog and server opt-in path are deployed.
|
||||
|
||||
## Final Acceptance Scenarios
|
||||
|
||||
- A fresh server shows Silo-maintained catalog entries with friendly names,
|
||||
descriptions, setup instructions, source links, and release notes; approved
|
||||
community plugins are absent.
|
||||
- An admin enables **Include approved community plugins**, sees the approved
|
||||
entries without entering a URL, and can identify their community maintenance
|
||||
and Silo approval clearly.
|
||||
- A community catalog outage leaves cached entries visible with a stale warning
|
||||
and does not break the official catalog.
|
||||
- Disabling the community channel with installed community plugins requires
|
||||
confirmation, leaves those plugins running, and clearly reports paused update
|
||||
discovery.
|
||||
- A custom repository publishing the same plugin ID or a higher version cannot
|
||||
replace or update an official/approved installation.
|
||||
- Review update presents the exact target release notes before installation.
|
||||
- An old plugin without presentation fields remains manageable with readable
|
||||
fallbacks.
|
||||
- Removing a plugin from the approved catalog stops new discovery and updates
|
||||
without remotely disabling existing installations.
|
||||
|
||||
## Cross-Repository Verification Gate
|
||||
|
||||
- `silo-plugin-sdk`: `GOWORK=off go test ./...` and `make proto`.
|
||||
- Official and community catalogs: `GOWORK=off go test ./...` plus catalog
|
||||
rebuild/check idempotence.
|
||||
- `silo-server`: focused plugin/API Go tests, web lint/format/test/build,
|
||||
`make lint`, and `make verify-local-paths`.
|
||||
- End-to-end dev smoke: toggle community off/on, install one approved plugin,
|
||||
configure it, simulate a catalog outage, review an update, disable the
|
||||
community channel, and confirm the installed plugin continues running while
|
||||
updates are paused.
|
||||
@@ -108,7 +108,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.9.0
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.10.0
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
|
||||
|
||||
@@ -4,8 +4,8 @@ github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0g
|
||||
github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.9.0 h1:fo4vUz3AcHO9tr5MYHdk+O3kLxIckf7rIBqv9Z5Ys+0=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.9.0/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.10.0 h1:OU2PfQwnUQAnf8FEZSTfWI+3x0OBiVfjKHHaVWhD/E0=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.10.0/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
|
||||
github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4=
|
||||
github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc=
|
||||
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
|
||||
|
||||
@@ -77,6 +77,10 @@ type pluginRepositoryRequest struct {
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
}
|
||||
|
||||
type pluginCatalogSettingsRequest struct {
|
||||
IncludeApprovedCommunityPlugins *bool `json:"include_approved_community_plugins"`
|
||||
}
|
||||
|
||||
type pluginInstallationCreateRequest struct {
|
||||
RepositoryID *int `json:"repository_id,omitempty"`
|
||||
PluginID string `json:"plugin_id,omitempty"`
|
||||
@@ -122,6 +126,8 @@ type pluginRepositoryResponse struct {
|
||||
URL string `json:"url"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
SourceKind string `json:"source_kind"`
|
||||
Managed bool `json:"managed"`
|
||||
LastFetchedAt *time.Time `json:"last_fetched_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
@@ -132,6 +138,10 @@ type pluginCatalogResponse struct {
|
||||
PluginID string `json:"plugin_id"`
|
||||
Version string `json:"version"`
|
||||
ArchiveURL string `json:"archive_url"`
|
||||
SourceKind string `json:"source_kind"`
|
||||
RepositoryName string `json:"repository_name"`
|
||||
RepoURL string `json:"repo_url,omitempty"`
|
||||
Presentation *pluginPresentationJSON `json:"presentation,omitempty"`
|
||||
Capabilities []pluginCapabilityJSON `json:"capabilities"`
|
||||
GlobalConfigSchema []pluginConfigSchemaJSON `json:"global_config_schema"`
|
||||
UserConfigSchema []pluginConfigSchemaJSON `json:"user_config_schema"`
|
||||
@@ -149,6 +159,11 @@ type pluginInstallationResponse struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
UpdatePolicy string `json:"update_policy"`
|
||||
AvailableVersion *string `json:"available_version,omitempty"`
|
||||
SourceKind string `json:"source_kind"`
|
||||
RepositoryName string `json:"repository_name,omitempty"`
|
||||
RepoURL string `json:"repo_url,omitempty"`
|
||||
Presentation *pluginPresentationJSON `json:"presentation,omitempty"`
|
||||
UpdatesPaused bool `json:"updates_paused"`
|
||||
Capabilities []pluginCapabilityJSON `json:"capabilities"`
|
||||
GlobalConfigSchema []pluginConfigSchemaJSON `json:"global_config_schema"`
|
||||
UserConfigSchema []pluginConfigSchemaJSON `json:"user_config_schema"`
|
||||
@@ -162,6 +177,28 @@ type pluginInstallationResponse struct {
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type pluginCatalogSettingsResponse struct {
|
||||
IncludeApprovedCommunityPlugins bool `json:"include_approved_community_plugins"`
|
||||
ApprovedCommunityPluginCount int `json:"approved_community_plugin_count"`
|
||||
InstalledCommunityPluginCount int `json:"installed_community_plugin_count"`
|
||||
MigratedPluginCount int `json:"migrated_plugin_count"`
|
||||
CommunityUpdatesPaused bool `json:"community_updates_paused"`
|
||||
}
|
||||
|
||||
type pluginPresentationJSON struct {
|
||||
DisplayName string `json:"display_name"`
|
||||
Summary string `json:"summary"`
|
||||
DescriptionMarkdown string `json:"description_markdown"`
|
||||
SetupMarkdown string `json:"setup_markdown"`
|
||||
HomepageURL string `json:"homepage_url"`
|
||||
SourceURL string `json:"source_url"`
|
||||
SupportURL string `json:"support_url"`
|
||||
ChangelogURL string `json:"changelog_url"`
|
||||
PublisherName string `json:"publisher_name"`
|
||||
PublisherURL string `json:"publisher_url"`
|
||||
LicenseSPDX string `json:"license_spdx"`
|
||||
}
|
||||
|
||||
type pluginConfigSchemaJSON struct {
|
||||
Key string `json:"key"`
|
||||
Title string `json:"title"`
|
||||
@@ -335,6 +372,10 @@ func (h *PluginHandler) HandleCreateRepository(w http.ResponseWriter, r *http.Re
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "url and display_name are required")
|
||||
return
|
||||
}
|
||||
if req.URL == plugins.DefaultRepositoryURL || req.URL == plugins.ApprovedCommunityRepositoryURL {
|
||||
writeError(w, http.StatusBadRequest, "managed_repository", "Use catalog settings to manage built-in plugin repositories")
|
||||
return
|
||||
}
|
||||
|
||||
repository, err := h.repositories.Create(r.Context(), plugins.CreateRepositoryInput{
|
||||
URL: req.URL,
|
||||
@@ -378,6 +419,10 @@ func (h *PluginHandler) HandleUpdateRepository(w http.ResponseWriter, r *http.Re
|
||||
writeError(w, http.StatusNotFound, "not_found", "Plugin repository not found")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, plugins.ErrManagedRepositoryReadOnly) {
|
||||
writeError(w, http.StatusConflict, "managed_repository", "Managed plugin repositories are controlled by catalog settings")
|
||||
return
|
||||
}
|
||||
slog.ErrorContext(r.Context(), "updating plugin repository", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update plugin repository")
|
||||
return
|
||||
@@ -405,6 +450,10 @@ func (h *PluginHandler) HandleDeleteRepository(w http.ResponseWriter, r *http.Re
|
||||
writeError(w, http.StatusNotFound, "not_found", "Plugin repository not found")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, plugins.ErrManagedRepositoryReadOnly) {
|
||||
writeError(w, http.StatusConflict, "managed_repository", "Managed plugin repositories cannot be deleted")
|
||||
return
|
||||
}
|
||||
slog.ErrorContext(r.Context(), "deleting plugin repository", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete plugin repository")
|
||||
return
|
||||
@@ -413,6 +462,42 @@ func (h *PluginHandler) HandleDeleteRepository(w http.ResponseWriter, r *http.Re
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *PluginHandler) HandleGetCatalogSettings(w http.ResponseWriter, r *http.Request) {
|
||||
settings, err := h.repositories.GetCatalogSettings(r.Context())
|
||||
if err != nil {
|
||||
slog.ErrorContext(r.Context(), "loading plugin catalog settings", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load plugin catalog settings")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toPluginCatalogSettingsResponse(settings))
|
||||
}
|
||||
|
||||
func (h *PluginHandler) HandlePutCatalogSettings(w http.ResponseWriter, r *http.Request) {
|
||||
var req pluginCatalogSettingsRequest
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Request body must contain one JSON object")
|
||||
return
|
||||
}
|
||||
if req.IncludeApprovedCommunityPlugins == nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "include_approved_community_plugins is required")
|
||||
return
|
||||
}
|
||||
|
||||
settings, err := h.repositories.SetIncludeApprovedCommunity(r.Context(), *req.IncludeApprovedCommunityPlugins)
|
||||
if err != nil {
|
||||
slog.ErrorContext(r.Context(), "updating plugin catalog settings", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update plugin catalog settings")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toPluginCatalogSettingsResponse(settings))
|
||||
}
|
||||
|
||||
func (h *PluginHandler) HandleCatalog(w http.ResponseWriter, r *http.Request) {
|
||||
entries, err := h.service.FetchCatalog(r.Context())
|
||||
if err != nil {
|
||||
@@ -423,11 +508,20 @@ func (h *PluginHandler) HandleCatalog(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
response := make([]pluginCatalogResponse, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
presentation := toPluginPresentationJSON(entry.Manifest.GetPresentation())
|
||||
repoURL := entry.RepoURL
|
||||
if repoURL == "" && presentation != nil {
|
||||
repoURL = presentation.SourceURL
|
||||
}
|
||||
response = append(response, pluginCatalogResponse{
|
||||
RepositoryID: entry.RepositoryID,
|
||||
PluginID: entry.Manifest.GetPluginId(),
|
||||
Version: entry.Manifest.GetVersion(),
|
||||
ArchiveURL: entry.ArchiveURL,
|
||||
SourceKind: entry.SourceKind,
|
||||
RepositoryName: entry.RepositoryDisplayName,
|
||||
RepoURL: repoURL,
|
||||
Presentation: presentation,
|
||||
Capabilities: capabilitiesToJSON(entry.Manifest.GetCapabilities()),
|
||||
GlobalConfigSchema: configSchemasToJSON(entry.Manifest.GetGlobalConfigSchema()),
|
||||
UserConfigSchema: configSchemasToJSON(entry.Manifest.GetUserConfigSchema()),
|
||||
@@ -1162,6 +1256,17 @@ func (h *PluginHandler) buildInstallationResponses(
|
||||
ctx context.Context,
|
||||
installations []*plugins.Installation,
|
||||
) ([]pluginInstallationResponse, error) {
|
||||
repositories, err := h.repositories.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
repositoriesByID := make(map[int]*plugins.Repository, len(repositories))
|
||||
for _, repository := range repositories {
|
||||
if repository != nil {
|
||||
repositoriesByID[repository.ID] = repository
|
||||
}
|
||||
}
|
||||
|
||||
authBindings, err := h.configs.ListAuthBindings(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1178,6 +1283,7 @@ func (h *PluginHandler) buildInstallationResponses(
|
||||
nil,
|
||||
authBindings,
|
||||
taskBindings,
|
||||
repositoriesByID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1192,6 +1298,17 @@ func (h *PluginHandler) buildInstallationResponse(
|
||||
installation *plugins.Installation,
|
||||
manifest *pluginv1.PluginManifest,
|
||||
) (pluginInstallationResponse, error) {
|
||||
repositoriesByID := make(map[int]*plugins.Repository, 1)
|
||||
if installation.RepositoryID != nil {
|
||||
repository, err := h.repositories.GetByID(ctx, *installation.RepositoryID)
|
||||
if err != nil && !errors.Is(err, plugins.ErrRepositoryNotFound) {
|
||||
return pluginInstallationResponse{}, err
|
||||
}
|
||||
if repository != nil {
|
||||
repositoriesByID[repository.ID] = repository
|
||||
}
|
||||
}
|
||||
|
||||
authBindings, err := h.configs.ListAuthBindings(ctx)
|
||||
if err != nil {
|
||||
return pluginInstallationResponse{}, err
|
||||
@@ -1206,6 +1323,7 @@ func (h *PluginHandler) buildInstallationResponse(
|
||||
manifest,
|
||||
authBindings,
|
||||
taskBindings,
|
||||
repositoriesByID,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1215,6 +1333,7 @@ func (h *PluginHandler) buildInstallationResponseWithBindings(
|
||||
manifest *pluginv1.PluginManifest,
|
||||
authBindings []*plugins.AuthBinding,
|
||||
taskBindings []*plugins.TaskBinding,
|
||||
repositoriesByID map[int]*plugins.Repository,
|
||||
) (pluginInstallationResponse, error) {
|
||||
if manifest == nil {
|
||||
var err error
|
||||
@@ -1239,7 +1358,18 @@ func (h *PluginHandler) buildInstallationResponseWithBindings(
|
||||
routes []pluginRouteJSON
|
||||
assets []pluginAssetJSON
|
||||
metadata map[string]any
|
||||
sourceKind = plugins.RepositorySourceExternal
|
||||
repositoryName string
|
||||
updatesPaused bool
|
||||
)
|
||||
if installation.RepositoryID != nil {
|
||||
repository := repositoriesByID[*installation.RepositoryID]
|
||||
if repository != nil {
|
||||
sourceKind = repository.SourceKind
|
||||
repositoryName = repository.DisplayName
|
||||
updatesPaused = repository.SourceKind == plugins.RepositorySourceApprovedCommunity && !repository.Enabled
|
||||
}
|
||||
}
|
||||
if manifest != nil {
|
||||
globalConfigSchema = configSchemasToJSON(manifest.GetGlobalConfigSchema())
|
||||
userConfigSchema = configSchemasToJSON(manifest.GetUserConfigSchema())
|
||||
@@ -1247,6 +1377,11 @@ func (h *PluginHandler) buildInstallationResponseWithBindings(
|
||||
assets = assetsToJSON(manifest.GetAssets())
|
||||
metadata = structToMap(manifest.GetMetadata())
|
||||
}
|
||||
presentation := toPluginPresentationJSON(manifest.GetPresentation())
|
||||
repoURL := ""
|
||||
if presentation != nil {
|
||||
repoURL = presentation.SourceURL
|
||||
}
|
||||
|
||||
return pluginInstallationResponse{
|
||||
ID: installation.ID,
|
||||
@@ -1257,6 +1392,11 @@ func (h *PluginHandler) buildInstallationResponseWithBindings(
|
||||
Enabled: installation.Enabled,
|
||||
UpdatePolicy: installation.UpdatePolicy,
|
||||
AvailableVersion: installation.AvailableVersion,
|
||||
SourceKind: sourceKind,
|
||||
RepositoryName: repositoryName,
|
||||
RepoURL: repoURL,
|
||||
Presentation: presentation,
|
||||
UpdatesPaused: updatesPaused,
|
||||
Capabilities: capabilities,
|
||||
GlobalConfigSchema: globalConfigSchema,
|
||||
UserConfigSchema: userConfigSchema,
|
||||
@@ -1271,18 +1411,49 @@ func (h *PluginHandler) buildInstallationResponseWithBindings(
|
||||
}, nil
|
||||
}
|
||||
|
||||
func toPluginPresentationJSON(presentation *pluginv1.PluginPresentation) *pluginPresentationJSON {
|
||||
if presentation == nil {
|
||||
return nil
|
||||
}
|
||||
return &pluginPresentationJSON{
|
||||
DisplayName: presentation.GetDisplayName(),
|
||||
Summary: presentation.GetSummary(),
|
||||
DescriptionMarkdown: presentation.GetDescriptionMarkdown(),
|
||||
SetupMarkdown: presentation.GetSetupMarkdown(),
|
||||
HomepageURL: presentation.GetHomepageUrl(),
|
||||
SourceURL: presentation.GetSourceUrl(),
|
||||
SupportURL: presentation.GetSupportUrl(),
|
||||
ChangelogURL: presentation.GetChangelogUrl(),
|
||||
PublisherName: presentation.GetPublisherName(),
|
||||
PublisherURL: presentation.GetPublisherUrl(),
|
||||
LicenseSPDX: presentation.GetLicenseSpdx(),
|
||||
}
|
||||
}
|
||||
|
||||
func toPluginRepositoryResponse(repository *plugins.Repository) pluginRepositoryResponse {
|
||||
return pluginRepositoryResponse{
|
||||
ID: repository.ID,
|
||||
URL: repository.URL,
|
||||
DisplayName: repository.DisplayName,
|
||||
Enabled: repository.Enabled,
|
||||
SourceKind: repository.SourceKind,
|
||||
Managed: repository.ManagedKey != nil,
|
||||
LastFetchedAt: repository.LastFetchedAt,
|
||||
CreatedAt: repository.CreatedAt,
|
||||
UpdatedAt: repository.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func toPluginCatalogSettingsResponse(settings plugins.CatalogSettings) pluginCatalogSettingsResponse {
|
||||
return pluginCatalogSettingsResponse{
|
||||
IncludeApprovedCommunityPlugins: settings.IncludeApprovedCommunityPlugins,
|
||||
ApprovedCommunityPluginCount: settings.ApprovedCommunityPluginCount,
|
||||
InstalledCommunityPluginCount: settings.InstalledCommunityPluginCount,
|
||||
MigratedPluginCount: settings.MigratedPluginCount,
|
||||
CommunityUpdatesPaused: settings.CommunityUpdatesPaused,
|
||||
}
|
||||
}
|
||||
|
||||
func toUserPluginSettingsSummary(
|
||||
installation *plugins.Installation,
|
||||
manifest *pluginv1.PluginManifest,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
|
||||
)
|
||||
|
||||
func TestToPluginPresentationJSONPreservesOperatorMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := toPluginPresentationJSON(&pluginv1.PluginPresentation{
|
||||
DisplayName: "Example Plugin",
|
||||
Summary: "Explains the example.",
|
||||
DescriptionMarkdown: "Longer description.",
|
||||
SetupMarkdown: "Configure the example.",
|
||||
HomepageUrl: "https://example.com",
|
||||
SourceUrl: "https://github.com/Silo-Server/example-plugin",
|
||||
SupportUrl: "https://github.com/Silo-Server/example-plugin/issues",
|
||||
ChangelogUrl: "https://github.com/Silo-Server/example-plugin/releases",
|
||||
PublisherName: "Silo",
|
||||
PublisherUrl: "https://github.com/Silo-Server",
|
||||
LicenseSpdx: "AGPL-3.0-or-later",
|
||||
})
|
||||
|
||||
if got == nil {
|
||||
t.Fatal("toPluginPresentationJSON() = nil")
|
||||
}
|
||||
if got.DisplayName != "Example Plugin" || got.Summary != "Explains the example." {
|
||||
t.Fatalf("identity fields = %#v", got)
|
||||
}
|
||||
if got.SourceURL != "https://github.com/Silo-Server/example-plugin" {
|
||||
t.Fatalf("source_url = %q", got.SourceURL)
|
||||
}
|
||||
if got.ChangelogURL != "https://github.com/Silo-Server/example-plugin/releases" {
|
||||
t.Fatalf("changelog_url = %q", got.ChangelogURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToPluginPresentationJSONKeepsLegacyManifestOptional(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := toPluginPresentationJSON(nil); got != nil {
|
||||
t.Fatalf("toPluginPresentationJSON(nil) = %#v, want nil", got)
|
||||
}
|
||||
}
|
||||
@@ -2598,6 +2598,8 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
restartStatus,
|
||||
)
|
||||
r.Route("/plugins", func(r chi.Router) {
|
||||
r.Get("/catalog-settings", pluginHandler.HandleGetCatalogSettings)
|
||||
r.Put("/catalog-settings", pluginHandler.HandlePutCatalogSettings)
|
||||
r.Get("/repositories", pluginHandler.HandleListRepositories)
|
||||
r.Post("/repositories", pluginHandler.HandleCreateRepository)
|
||||
r.Put("/repositories/{id}", pluginHandler.HandleUpdateRepository)
|
||||
|
||||
@@ -55,11 +55,6 @@ func compareVersions(a, b string) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
const (
|
||||
DefaultRepositoryURL = "https://raw.githubusercontent.com/Silo-Server/silo-plugins/main/manifest.json"
|
||||
DefaultRepositoryName = "Silo Official Plugins"
|
||||
)
|
||||
|
||||
var defaultPluginIDs = []string{"silo.tmdb", "silo.tvdb"}
|
||||
|
||||
type autoUpdateRepositoryStore interface {
|
||||
@@ -67,6 +62,10 @@ type autoUpdateRepositoryStore interface {
|
||||
Create(ctx context.Context, input CreateRepositoryInput) (*Repository, error)
|
||||
}
|
||||
|
||||
type managedRepositoryReconciler interface {
|
||||
ReconcileManaged(ctx context.Context) (ManagedRepositoryReconcileResult, error)
|
||||
}
|
||||
|
||||
type autoUpdateInstallationStore interface {
|
||||
List(ctx context.Context) ([]*Installation, error)
|
||||
Update(ctx context.Context, id int, input UpdateInstallationInput) error
|
||||
@@ -105,8 +104,8 @@ type AutoUpdateSummary struct {
|
||||
Failures []string `json:"failures,omitempty"`
|
||||
}
|
||||
|
||||
// AutoUpdateService seeds the default plugin repository, auto-installs default
|
||||
// plugins, and auto-updates installed plugins at server startup.
|
||||
// AutoUpdateService reconciles managed plugin repositories, auto-installs
|
||||
// default plugins, and auto-updates installed plugins at server startup.
|
||||
type AutoUpdateService struct {
|
||||
repositories autoUpdateRepositoryStore
|
||||
installations autoUpdateInstallationStore
|
||||
@@ -157,13 +156,11 @@ func (s *AutoUpdateService) Check(ctx context.Context, opts AutoUpdateOptions) (
|
||||
var summary AutoUpdateSummary
|
||||
|
||||
if opts.SeedDefaultRepository {
|
||||
seeded, err := s.seedDefaultRepository(ctx)
|
||||
seeded, err := s.ensureManagedRepositoryRows(ctx)
|
||||
if err != nil {
|
||||
return summary, err
|
||||
}
|
||||
if seeded {
|
||||
summary.RepositoriesSeeded++
|
||||
}
|
||||
summary.RepositoriesSeeded += seeded
|
||||
}
|
||||
|
||||
entries, err := s.catalog.Fetch(ctx)
|
||||
@@ -178,33 +175,29 @@ func (s *AutoUpdateService) Check(ctx context.Context, opts AutoUpdateOptions) (
|
||||
}
|
||||
summary.InstalledPlugins = len(installed)
|
||||
|
||||
installedByPluginID := make(map[string]*Installation, len(installed))
|
||||
installedPluginIDs := make(map[string]struct{}, len(installed))
|
||||
for _, inst := range installed {
|
||||
if inst == nil {
|
||||
continue
|
||||
}
|
||||
installedByPluginID[inst.PluginID] = inst
|
||||
installedPluginIDs[inst.PluginID] = struct{}{}
|
||||
}
|
||||
|
||||
latestByPluginID := latestCatalogEntries(entries)
|
||||
for pluginID, entry := range latestByPluginID {
|
||||
existing, isInstalled := installedByPluginID[pluginID]
|
||||
|
||||
if !isInstalled {
|
||||
if opts.AutoInstallDefaults {
|
||||
installedDefault, err := s.handleNewPlugin(ctx, pluginID, entry)
|
||||
if err != nil {
|
||||
summary.recordFailure("auto-install default plugin %s: %v", pluginID, err)
|
||||
} else if installedDefault {
|
||||
summary.DefaultPluginsInstalled++
|
||||
}
|
||||
}
|
||||
latestByRepositoryPlugin := latestCatalogEntriesByRepository(entries)
|
||||
for _, existing := range installed {
|
||||
if existing == nil || existing.RepositoryID == nil {
|
||||
continue
|
||||
}
|
||||
entry, ok := latestByRepositoryPlugin[repositoryPluginKey{
|
||||
RepositoryID: *existing.RepositoryID,
|
||||
PluginID: existing.PluginID,
|
||||
}]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
outcome, err := s.handleExistingPlugin(ctx, existing, entry)
|
||||
if err != nil {
|
||||
summary.recordFailure("process plugin update %s: %v", pluginID, err)
|
||||
summary.recordFailure("process plugin update %s: %v", existing.PluginID, err)
|
||||
continue
|
||||
}
|
||||
switch outcome {
|
||||
@@ -215,6 +208,25 @@ func (s *AutoUpdateService) Check(ctx context.Context, opts AutoUpdateOptions) (
|
||||
}
|
||||
}
|
||||
|
||||
if opts.AutoInstallDefaults {
|
||||
latestOfficial := latestCatalogEntriesForSource(entries, RepositorySourceSilo)
|
||||
for _, pluginID := range defaultPluginIDs {
|
||||
if _, installed := installedPluginIDs[pluginID]; installed {
|
||||
continue
|
||||
}
|
||||
entry, ok := latestOfficial[pluginID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
installedDefault, err := s.handleNewPlugin(ctx, pluginID, entry)
|
||||
if err != nil {
|
||||
summary.recordFailure("auto-install default plugin %s: %v", pluginID, err)
|
||||
} else if installedDefault {
|
||||
summary.DefaultPluginsInstalled++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Any of these outcomes wrote to a plugin_installations row: installing a
|
||||
// default plugin creates one, an applied auto-update rewrites the version-
|
||||
// specific InstallPath/Version (and deletes the old install dir), and a
|
||||
@@ -239,10 +251,10 @@ func (s *AutoUpdateService) notifyChanged(ctx context.Context) {
|
||||
s.onChange(ctx)
|
||||
}
|
||||
|
||||
// Run seeds the default repository if needed, fetches the catalog, auto-installs
|
||||
// default plugins that are not yet installed, and processes updates for installed
|
||||
// plugins according to their update policy. All errors are logged rather than
|
||||
// returned so that startup is never blocked.
|
||||
// Run reconciles managed repositories, fetches the catalog, auto-installs
|
||||
// default plugins that are not yet installed, and processes updates for
|
||||
// installed plugins according to their update policy. All errors are logged
|
||||
// rather than returned so that startup is never blocked.
|
||||
func (s *AutoUpdateService) Run(ctx context.Context) error {
|
||||
summary, err := s.Check(ctx, AutoUpdateOptions{
|
||||
SeedDefaultRepository: true,
|
||||
@@ -258,15 +270,28 @@ func (s *AutoUpdateService) Run(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// seedDefaultRepository creates the official plugin repository when no
|
||||
// repositories are configured.
|
||||
func (s *AutoUpdateService) seedDefaultRepository(ctx context.Context) (bool, error) {
|
||||
// ensureManagedRepositoryRows reconciles built-in repositories. The fallback
|
||||
// preserves the legacy fake-store contract used by isolated unit tests.
|
||||
func (s *AutoUpdateService) ensureManagedRepositoryRows(ctx context.Context) (int, error) {
|
||||
if reconciler, ok := s.repositories.(managedRepositoryReconciler); ok {
|
||||
result, err := reconciler.ReconcileManaged(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if result.RepositoriesCreated > 0 {
|
||||
s.logger.InfoContext(ctx, "reconciled managed plugin repositories",
|
||||
"repositories_created", result.RepositoriesCreated,
|
||||
)
|
||||
}
|
||||
return result.RepositoriesCreated, nil
|
||||
}
|
||||
|
||||
repos, err := s.repositories.List(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
return 0, err
|
||||
}
|
||||
if len(repos) > 0 {
|
||||
return false, nil
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
enabled := true
|
||||
@@ -276,14 +301,14 @@ func (s *AutoUpdateService) seedDefaultRepository(ctx context.Context) (bool, er
|
||||
Enabled: &enabled,
|
||||
})
|
||||
if err != nil {
|
||||
return false, err
|
||||
return 0, err
|
||||
}
|
||||
|
||||
s.logger.InfoContext(ctx, "seeded default plugin repository",
|
||||
"url", DefaultRepositoryURL,
|
||||
"name", DefaultRepositoryName,
|
||||
)
|
||||
return true, nil
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
// handleNewPlugin auto-installs a plugin if it is in the default plugin list.
|
||||
@@ -398,17 +423,39 @@ func (s *AutoUpdateService) notifyPluginUpdate(ctx context.Context, existing *In
|
||||
return nil
|
||||
}
|
||||
|
||||
// latestCatalogEntries returns a map from plugin ID to the catalog entry with
|
||||
// the highest version string for that plugin.
|
||||
func latestCatalogEntries(entries []CatalogEntry) map[string]CatalogEntry {
|
||||
latest := make(map[string]CatalogEntry, len(entries))
|
||||
type repositoryPluginKey struct {
|
||||
RepositoryID int
|
||||
PluginID string
|
||||
}
|
||||
|
||||
func latestCatalogEntriesByRepository(entries []CatalogEntry) map[repositoryPluginKey]CatalogEntry {
|
||||
latest := make(map[repositoryPluginKey]CatalogEntry, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.Manifest == nil {
|
||||
continue
|
||||
}
|
||||
pluginID := entry.Manifest.GetPluginId()
|
||||
if existing, ok := latest[pluginID]; ok {
|
||||
key := repositoryPluginKey{RepositoryID: entry.RepositoryID, PluginID: pluginID}
|
||||
if existing, ok := latest[key]; ok {
|
||||
if compareVersions(entry.Manifest.GetVersion(), existing.Manifest.GetVersion()) <= 0 {
|
||||
continue
|
||||
}
|
||||
}
|
||||
latest[key] = entry
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
func latestCatalogEntriesForSource(entries []CatalogEntry, sourceKind string) map[string]CatalogEntry {
|
||||
latest := make(map[string]CatalogEntry, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.Manifest == nil || entry.SourceKind != sourceKind {
|
||||
continue
|
||||
}
|
||||
pluginID := entry.Manifest.GetPluginId()
|
||||
if existing, ok := latest[pluginID]; ok && compareVersions(entry.Manifest.GetVersion(), existing.Manifest.GetVersion()) <= 0 {
|
||||
continue
|
||||
}
|
||||
latest[pluginID] = entry
|
||||
}
|
||||
return latest
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
func TestAutoUpdateServiceCheckReplacesInstalledPluginsInPlace(t *testing.T) {
|
||||
installations := &fakeAutoUpdateInstallations{
|
||||
list: []*Installation{
|
||||
{ID: 41, PluginID: "silo.tmdb", Version: "1.0.0", UpdatePolicy: "auto", Enabled: true},
|
||||
{ID: 41, RepositoryID: pluginRepositoryID(7), PluginID: "silo.tmdb", Version: "1.0.0", UpdatePolicy: "auto", Enabled: true},
|
||||
},
|
||||
}
|
||||
host := &fakeAutoUpdateHost{}
|
||||
@@ -96,7 +96,7 @@ func TestAutoUpdateServiceCheckFiresOnChangeAfterMutation(t *testing.T) {
|
||||
t.Run("applied auto-update fires onChange", func(t *testing.T) {
|
||||
var calls int
|
||||
installations := &fakeAutoUpdateInstallations{
|
||||
list: []*Installation{{ID: 41, PluginID: "silo.tmdb", Version: "1.0.0", UpdatePolicy: "auto", Enabled: true}},
|
||||
list: []*Installation{{ID: 41, RepositoryID: pluginRepositoryID(7), PluginID: "silo.tmdb", Version: "1.0.0", UpdatePolicy: "auto", Enabled: true}},
|
||||
}
|
||||
summary, err := newOnChangeService(installations, AutoUpdateOptions{}, &calls)
|
||||
if err != nil {
|
||||
@@ -113,7 +113,7 @@ func TestAutoUpdateServiceCheckFiresOnChangeAfterMutation(t *testing.T) {
|
||||
t.Run("notify update fires onChange", func(t *testing.T) {
|
||||
var calls int
|
||||
installations := &fakeAutoUpdateInstallations{
|
||||
list: []*Installation{{ID: 42, PluginID: "silo.tmdb", Version: "1.0.0", UpdatePolicy: "notify", Enabled: true}},
|
||||
list: []*Installation{{ID: 42, RepositoryID: pluginRepositoryID(7), PluginID: "silo.tmdb", Version: "1.0.0", UpdatePolicy: "notify", Enabled: true}},
|
||||
}
|
||||
summary, err := newOnChangeService(installations, AutoUpdateOptions{}, &calls)
|
||||
if err != nil {
|
||||
@@ -130,7 +130,7 @@ func TestAutoUpdateServiceCheckFiresOnChangeAfterMutation(t *testing.T) {
|
||||
t.Run("no mutation does not fire onChange", func(t *testing.T) {
|
||||
var calls int
|
||||
installations := &fakeAutoUpdateInstallations{
|
||||
list: []*Installation{{ID: 43, PluginID: "silo.tmdb", Version: "1.1.0", UpdatePolicy: "auto", Enabled: true}},
|
||||
list: []*Installation{{ID: 43, RepositoryID: pluginRepositoryID(7), PluginID: "silo.tmdb", Version: "1.1.0", UpdatePolicy: "auto", Enabled: true}},
|
||||
}
|
||||
summary, err := newOnChangeService(installations, AutoUpdateOptions{}, &calls)
|
||||
if err != nil {
|
||||
@@ -146,7 +146,7 @@ func TestAutoUpdateServiceCheckFiresOnChangeAfterMutation(t *testing.T) {
|
||||
|
||||
t.Run("nil onChange is safe after mutation", func(t *testing.T) {
|
||||
installations := &fakeAutoUpdateInstallations{
|
||||
list: []*Installation{{ID: 44, PluginID: "silo.tmdb", Version: "1.0.0", UpdatePolicy: "auto", Enabled: true}},
|
||||
list: []*Installation{{ID: 44, RepositoryID: pluginRepositoryID(7), PluginID: "silo.tmdb", Version: "1.0.0", UpdatePolicy: "auto", Enabled: true}},
|
||||
}
|
||||
service := NewAutoUpdateService(
|
||||
&fakeAutoUpdateRepositories{list: []*Repository{{ID: 7, Enabled: true}}},
|
||||
@@ -197,7 +197,7 @@ func TestCompareVersions(t *testing.T) {
|
||||
|
||||
func TestAutoUpdateMultiDigitVersion(t *testing.T) {
|
||||
installations := &fakeAutoUpdateInstallations{
|
||||
list: []*Installation{{ID: 50, PluginID: "silo.tmdb", Version: "1.2.9", UpdatePolicy: "auto", Enabled: true}},
|
||||
list: []*Installation{{ID: 50, RepositoryID: pluginRepositoryID(7), PluginID: "silo.tmdb", Version: "1.2.9", UpdatePolicy: "auto", Enabled: true}},
|
||||
}
|
||||
host := &fakeAutoUpdateHost{}
|
||||
installer := &fakeAutoUpdateInstaller{}
|
||||
@@ -237,6 +237,79 @@ func TestAutoUpdateMultiDigitVersion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoUpdateServiceKeepsUpdatesPinnedToInstallationRepository(t *testing.T) {
|
||||
installations := &fakeAutoUpdateInstallations{
|
||||
list: []*Installation{{
|
||||
ID: 60,
|
||||
RepositoryID: pluginRepositoryID(7),
|
||||
PluginID: "silo.requests.arr",
|
||||
Version: "1.0.0",
|
||||
UpdatePolicy: "auto",
|
||||
Enabled: true,
|
||||
}},
|
||||
}
|
||||
catalog := &fakeAutoUpdateCatalog{
|
||||
entries: []CatalogEntry{
|
||||
{RepositoryID: 7, Manifest: &pluginv1.PluginManifest{PluginId: "silo.requests.arr", Version: "1.1.0"}},
|
||||
{RepositoryID: 8, Manifest: &pluginv1.PluginManifest{PluginId: "silo.requests.arr", Version: "9.0.0"}},
|
||||
},
|
||||
resolved: &ResolvedCatalogInstall{RepositoryID: 7, ArchiveURL: "https://plugins.example.test/arr", Checksum: "deadbeef"},
|
||||
}
|
||||
service := NewAutoUpdateService(
|
||||
&fakeAutoUpdateRepositories{list: []*Repository{{ID: 7, Enabled: true}, {ID: 8, Enabled: true}}},
|
||||
installations,
|
||||
catalog,
|
||||
&fakeAutoUpdateInstaller{},
|
||||
&fakeAutoUpdateHost{},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
summary, err := service.Check(context.Background(), AutoUpdateOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Check() returned error: %v", err)
|
||||
}
|
||||
if summary.UpdatesApplied != 1 {
|
||||
t.Fatalf("UpdatesApplied = %d, want 1", summary.UpdatesApplied)
|
||||
}
|
||||
if len(catalog.resolveRequests) != 1 {
|
||||
t.Fatalf("resolve requests = %d, want 1", len(catalog.resolveRequests))
|
||||
}
|
||||
request := catalog.resolveRequests[0]
|
||||
if request.RepositoryID != 7 || request.Version != "1.1.0" {
|
||||
t.Fatalf("resolved repository/version = %d/%s, want 7/1.1.0", request.RepositoryID, request.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoUpdateServiceDoesNotAttachUploadsToCatalogRepositories(t *testing.T) {
|
||||
service := NewAutoUpdateService(
|
||||
&fakeAutoUpdateRepositories{list: []*Repository{{ID: 7, Enabled: true}}},
|
||||
&fakeAutoUpdateInstallations{list: []*Installation{{
|
||||
ID: 61,
|
||||
PluginID: "silo.requests.arr",
|
||||
Version: "1.0.0",
|
||||
UpdatePolicy: "auto",
|
||||
Enabled: true,
|
||||
}}},
|
||||
&fakeAutoUpdateCatalog{entries: []CatalogEntry{{
|
||||
RepositoryID: 7,
|
||||
Manifest: &pluginv1.PluginManifest{PluginId: "silo.requests.arr", Version: "2.0.0"},
|
||||
}}},
|
||||
&fakeAutoUpdateInstaller{},
|
||||
&fakeAutoUpdateHost{},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
summary, err := service.Check(context.Background(), AutoUpdateOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Check() returned error: %v", err)
|
||||
}
|
||||
if summary.UpdatesApplied != 0 || summary.UpdatesAvailable != 0 {
|
||||
t.Fatalf("upload unexpectedly received catalog update: %+v", summary)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeAutoUpdateRepositories struct {
|
||||
list []*Repository
|
||||
}
|
||||
@@ -277,18 +350,24 @@ func (f *fakeAutoUpdateInstallations) Delete(_ context.Context, id int) error {
|
||||
}
|
||||
|
||||
type fakeAutoUpdateCatalog struct {
|
||||
entries []CatalogEntry
|
||||
resolved *ResolvedCatalogInstall
|
||||
entries []CatalogEntry
|
||||
resolved *ResolvedCatalogInstall
|
||||
resolveRequests []InstallCatalogRequest
|
||||
}
|
||||
|
||||
func (f *fakeAutoUpdateCatalog) Fetch(context.Context) ([]CatalogEntry, error) {
|
||||
return f.entries, nil
|
||||
}
|
||||
|
||||
func (f *fakeAutoUpdateCatalog) ResolveInstall(_ context.Context, _ InstallCatalogRequest) (*ResolvedCatalogInstall, error) {
|
||||
func (f *fakeAutoUpdateCatalog) ResolveInstall(_ context.Context, request InstallCatalogRequest) (*ResolvedCatalogInstall, error) {
|
||||
f.resolveRequests = append(f.resolveRequests, request)
|
||||
return f.resolved, nil
|
||||
}
|
||||
|
||||
func pluginRepositoryID(id int) *int {
|
||||
return &id
|
||||
}
|
||||
|
||||
type replaceBinaryCall struct {
|
||||
existingID int
|
||||
req InstallBinaryRequest
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package plugins
|
||||
|
||||
const (
|
||||
DefaultRepositoryURL = "https://raw.githubusercontent.com/Silo-Server/silo-plugins/main/manifest.json"
|
||||
DefaultRepositoryName = "Silo maintained"
|
||||
|
||||
ApprovedCommunityRepositoryURL = "https://raw.githubusercontent.com/Silo-Community/silo-plugins/main/manifest.json"
|
||||
ApprovedCommunityRepositoryName = "Approved community"
|
||||
|
||||
OfficialRepositoryManagedKey = "official"
|
||||
ApprovedCommunityRepositoryManagedKey = "approved-community"
|
||||
|
||||
RepositorySourceSilo = "silo"
|
||||
RepositorySourceApprovedCommunity = "approved_community"
|
||||
RepositorySourceExternal = "external"
|
||||
|
||||
IncludeApprovedCommunityPluginsSetting = "plugins.include_approved_community_plugins"
|
||||
MigratedApprovedCommunityCountSetting = "plugins.approved_community_migrated_plugin_count"
|
||||
)
|
||||
|
||||
type managedRepositoryDefinition struct {
|
||||
Key string
|
||||
URL string
|
||||
DisplayName string
|
||||
SourceKind string
|
||||
}
|
||||
|
||||
var managedRepositoryDefinitions = []managedRepositoryDefinition{
|
||||
{
|
||||
Key: OfficialRepositoryManagedKey,
|
||||
URL: DefaultRepositoryURL,
|
||||
DisplayName: DefaultRepositoryName,
|
||||
SourceKind: RepositorySourceSilo,
|
||||
},
|
||||
{
|
||||
Key: ApprovedCommunityRepositoryManagedKey,
|
||||
URL: ApprovedCommunityRepositoryURL,
|
||||
DisplayName: ApprovedCommunityRepositoryName,
|
||||
SourceKind: RepositorySourceApprovedCommunity,
|
||||
},
|
||||
}
|
||||
|
||||
var approvedCommunityPluginIDs = map[string]struct{}{
|
||||
"silo.requests.arr": {},
|
||||
"silo.requests.seerr": {},
|
||||
}
|
||||
|
||||
func isApprovedCommunityPlugin(pluginID string) bool {
|
||||
_, ok := approvedCommunityPluginIDs[pluginID]
|
||||
return ok
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestApprovedCommunityMigrationKeepsFreshInstallsOffAndScopesLegacyMoves(t *testing.T) {
|
||||
data, err := os.ReadFile("../../migrations/sql/20260709191109_plugin_catalog_community_migration.sql")
|
||||
if err != nil {
|
||||
t.Fatalf("read approved community migration: %v", err)
|
||||
}
|
||||
sql := string(data)
|
||||
|
||||
required := []string{
|
||||
"EXISTS (SELECT 1 FROM public.users)",
|
||||
"OR EXISTS (SELECT 1 FROM public.plugin_repositories)",
|
||||
"OR EXISTS (SELECT 1 FROM public.plugin_installations)",
|
||||
"CASE WHEN legacy_instance THEN 'true' ELSE 'false' END",
|
||||
"'plugins.include_approved_community_plugins'",
|
||||
"'approved-community'",
|
||||
"'approved_community'",
|
||||
"WHERE repository_id = official_repository_id",
|
||||
"plugin_id IN ('silo.requests.arr', 'silo.requests.seerr')",
|
||||
"available_version = NULL",
|
||||
}
|
||||
for _, fragment := range required {
|
||||
if !strings.Contains(sql, fragment) {
|
||||
t.Fatalf("migration missing %q", fragment)
|
||||
}
|
||||
}
|
||||
|
||||
legacyCheck := strings.Index(sql, "legacy_instance BOOLEAN :=")
|
||||
officialInsert := strings.Index(sql, "INSERT INTO public.plugin_repositories")
|
||||
if legacyCheck < 0 || officialInsert < 0 || legacyCheck > officialInsert {
|
||||
t.Fatal("legacy-instance state must be captured before managed repositories are inserted")
|
||||
}
|
||||
|
||||
for _, dependentTable := range []string{
|
||||
"plugin_runtime_configs",
|
||||
"plugin_task_bindings",
|
||||
"plugin_auth_bindings",
|
||||
"plugin_capabilities",
|
||||
} {
|
||||
if strings.Contains(sql, "UPDATE public."+dependentTable) || strings.Contains(sql, "DELETE FROM public."+dependentTable) {
|
||||
t.Fatalf("migration must preserve installation-owned data in %s", dependentTable)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
|
||||
)
|
||||
|
||||
func TestCatalogEntriesForDiscoveryUsesApprovedCommunityOwnerForTransferredPlugins(t *testing.T) {
|
||||
entries := []CatalogEntry{
|
||||
{
|
||||
RepositoryID: 1,
|
||||
SourceKind: RepositorySourceSilo,
|
||||
Manifest: &pluginv1.PluginManifest{PluginId: "silo.requests.arr", Version: "0.1.0"},
|
||||
},
|
||||
{
|
||||
RepositoryID: 2,
|
||||
SourceKind: RepositorySourceApprovedCommunity,
|
||||
Manifest: &pluginv1.PluginManifest{PluginId: "silo.requests.arr", Version: "0.1.1"},
|
||||
},
|
||||
}
|
||||
|
||||
result := catalogEntriesForDiscovery(entries)
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("catalog entries = %d, want 1", len(result))
|
||||
}
|
||||
if result[0].RepositoryID != 2 || result[0].Manifest.GetVersion() != "0.1.1" {
|
||||
t.Fatalf("selected repository/version = %d/%s, want 2/0.1.1", result[0].RepositoryID, result[0].Manifest.GetVersion())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogEntriesForDiscoveryHidesTransferredPluginWhenCommunityIsDisabled(t *testing.T) {
|
||||
result := catalogEntriesForDiscovery([]CatalogEntry{{
|
||||
RepositoryID: 1,
|
||||
SourceKind: RepositorySourceSilo,
|
||||
Manifest: &pluginv1.PluginManifest{PluginId: "silo.requests.seerr", Version: "0.1.0"},
|
||||
}})
|
||||
if len(result) != 0 {
|
||||
t.Fatalf("catalog entries = %d, want transferred plugin hidden", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogEntriesForDiscoveryPreventsExternalSourceShadowing(t *testing.T) {
|
||||
result := catalogEntriesForDiscovery([]CatalogEntry{
|
||||
{
|
||||
RepositoryID: 9,
|
||||
SourceKind: RepositorySourceExternal,
|
||||
Manifest: &pluginv1.PluginManifest{PluginId: "silo.tmdb", Version: "99.0.0"},
|
||||
},
|
||||
{
|
||||
RepositoryID: 1,
|
||||
SourceKind: RepositorySourceSilo,
|
||||
Manifest: &pluginv1.PluginManifest{PluginId: "silo.tmdb", Version: "1.0.0"},
|
||||
},
|
||||
})
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("catalog entries = %d, want 1", len(result))
|
||||
}
|
||||
if result[0].RepositoryID != 1 {
|
||||
t.Fatalf("selected repository = %d, want official repository 1", result[0].RepositoryID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogEntriesForDiscoveryShowsLatestVersionFromSelectedRepository(t *testing.T) {
|
||||
result := catalogEntriesForDiscovery([]CatalogEntry{
|
||||
{
|
||||
RepositoryID: 1,
|
||||
SourceKind: RepositorySourceSilo,
|
||||
Manifest: &pluginv1.PluginManifest{PluginId: "silo.tmdb", Version: "1.2.9"},
|
||||
},
|
||||
{
|
||||
RepositoryID: 1,
|
||||
SourceKind: RepositorySourceSilo,
|
||||
Manifest: &pluginv1.PluginManifest{PluginId: "silo.tmdb", Version: "1.2.10"},
|
||||
},
|
||||
})
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("catalog entries = %d, want 1", len(result))
|
||||
}
|
||||
if result[0].Manifest.GetVersion() != "1.2.10" {
|
||||
t.Fatalf("selected version = %s, want 1.2.10", result[0].Manifest.GetVersion())
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ type PlatformBinary struct {
|
||||
|
||||
type CatalogPackage struct {
|
||||
Manifest *pluginv1.PluginManifest `json:"manifest"`
|
||||
RepoURL string `json:"repo_url,omitempty"`
|
||||
ArchiveURL string `json:"archive_url,omitempty"`
|
||||
ChecksumsURL string `json:"checksums_url,omitempty"`
|
||||
Binaries map[string]PlatformBinary `json:"binaries,omitempty"`
|
||||
@@ -35,10 +36,13 @@ type RepositoryIndex struct {
|
||||
}
|
||||
|
||||
type CatalogEntry struct {
|
||||
RepositoryID int
|
||||
Manifest *pluginv1.PluginManifest
|
||||
ArchiveURL string
|
||||
Checksum string
|
||||
RepositoryID int
|
||||
RepositoryDisplayName string
|
||||
SourceKind string
|
||||
Manifest *pluginv1.PluginManifest
|
||||
RepoURL string
|
||||
ArchiveURL string
|
||||
Checksum string
|
||||
}
|
||||
|
||||
type InstallCatalogRequest struct {
|
||||
@@ -141,7 +145,7 @@ func (s *CatalogService) Fetch(ctx context.Context) ([]CatalogEntry, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
key := capabilityKey(entry.Manifest.GetPluginId(), entry.Manifest.GetVersion())
|
||||
key := fmt.Sprintf("%d:%s", entry.RepositoryID, capabilityKey(entry.Manifest.GetPluginId(), entry.Manifest.GetVersion()))
|
||||
if _, exists := catalogByVersion[key]; exists {
|
||||
continue
|
||||
}
|
||||
@@ -310,10 +314,13 @@ func (s *CatalogService) catalogEntryFromPackage(repository *Repository, pkg Cat
|
||||
}
|
||||
|
||||
return CatalogEntry{
|
||||
RepositoryID: repository.ID,
|
||||
Manifest: pkg.Manifest,
|
||||
ArchiveURL: resolvedURL,
|
||||
Checksum: checksum,
|
||||
RepositoryID: repository.ID,
|
||||
RepositoryDisplayName: repository.DisplayName,
|
||||
SourceKind: repository.SourceKind,
|
||||
Manifest: pkg.Manifest,
|
||||
RepoURL: pkg.RepoURL,
|
||||
ArchiveURL: resolvedURL,
|
||||
Checksum: checksum,
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
@@ -332,9 +339,12 @@ func (s *CatalogService) catalogEntryFromPackage(repository *Repository, pkg Cat
|
||||
return CatalogEntry{}, false, err
|
||||
}
|
||||
return CatalogEntry{
|
||||
RepositoryID: repository.ID,
|
||||
Manifest: pkg.Manifest,
|
||||
ArchiveURL: resolvedURL,
|
||||
RepositoryID: repository.ID,
|
||||
RepositoryDisplayName: repository.DisplayName,
|
||||
SourceKind: repository.SourceKind,
|
||||
Manifest: pkg.Manifest,
|
||||
RepoURL: pkg.RepoURL,
|
||||
ArchiveURL: resolvedURL,
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFetchRepositoryIndexPreservesPresentationAndRepositoryURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"plugins": [{
|
||||
"repo_url": "https://github.com/Silo-Server/example-plugin",
|
||||
"manifest": {
|
||||
"plugin_id": "silo.example",
|
||||
"version": "1.0.0",
|
||||
"presentation": {
|
||||
"display_name": "Example Plugin",
|
||||
"summary": "Explains the example.",
|
||||
"source_url": "https://github.com/Silo-Server/example-plugin",
|
||||
"changelog_url": "https://github.com/Silo-Server/example-plugin/releases"
|
||||
}
|
||||
}
|
||||
}]
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
service := NewCatalogService(nil, CatalogServiceOptions{HTTPClient: server.Client()})
|
||||
index, err := service.fetchRepositoryIndex(t.Context(), server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("fetchRepositoryIndex() error = %v", err)
|
||||
}
|
||||
if len(index.Plugins) != 1 {
|
||||
t.Fatalf("plugins length = %d, want 1", len(index.Plugins))
|
||||
}
|
||||
|
||||
plugin := index.Plugins[0]
|
||||
if plugin.RepoURL != "https://github.com/Silo-Server/example-plugin" {
|
||||
t.Fatalf("repo_url = %q", plugin.RepoURL)
|
||||
}
|
||||
presentation := plugin.Manifest.GetPresentation()
|
||||
if presentation.GetDisplayName() != "Example Plugin" {
|
||||
t.Fatalf("display_name = %q", presentation.GetDisplayName())
|
||||
}
|
||||
if presentation.GetSummary() != "Explains the example." {
|
||||
t.Fatalf("summary = %q", presentation.GetSummary())
|
||||
}
|
||||
if presentation.GetChangelogUrl() != "https://github.com/Silo-Server/example-plugin/releases" {
|
||||
t.Fatalf("changelog_url = %q", presentation.GetChangelogUrl())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
type CatalogSettings struct {
|
||||
IncludeApprovedCommunityPlugins bool
|
||||
ApprovedCommunityPluginCount int
|
||||
InstalledCommunityPluginCount int
|
||||
MigratedPluginCount int
|
||||
CommunityUpdatesPaused bool
|
||||
}
|
||||
|
||||
type ManagedRepositoryReconcileResult struct {
|
||||
RepositoriesCreated int
|
||||
}
|
||||
|
||||
func (s *RepositoryStore) ReconcileManaged(ctx context.Context) (ManagedRepositoryReconcileResult, error) {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return ManagedRepositoryReconcileResult{}, fmt.Errorf("begin managed plugin repository reconciliation: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO server_settings (key, value)
|
||||
VALUES ($1, 'false')
|
||||
ON CONFLICT (key) DO NOTHING
|
||||
`, IncludeApprovedCommunityPluginsSetting); err != nil {
|
||||
return ManagedRepositoryReconcileResult{}, fmt.Errorf("seed approved community plugin setting: %w", err)
|
||||
}
|
||||
|
||||
includeCommunity, err := readIncludeApprovedCommunityForUpdate(ctx, tx)
|
||||
if err != nil {
|
||||
return ManagedRepositoryReconcileResult{}, err
|
||||
}
|
||||
created, err := reconcileManagedRepositories(ctx, tx, includeCommunity)
|
||||
if err != nil {
|
||||
return ManagedRepositoryReconcileResult{}, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return ManagedRepositoryReconcileResult{}, fmt.Errorf("commit managed plugin repository reconciliation: %w", err)
|
||||
}
|
||||
return ManagedRepositoryReconcileResult{RepositoriesCreated: created}, nil
|
||||
}
|
||||
|
||||
func (s *RepositoryStore) GetCatalogSettings(ctx context.Context) (CatalogSettings, error) {
|
||||
includeCommunity, err := readIncludeApprovedCommunity(ctx, s.pool)
|
||||
if err != nil {
|
||||
return CatalogSettings{}, err
|
||||
}
|
||||
|
||||
var installedCommunityCount int
|
||||
if err := s.pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*)
|
||||
FROM plugin_installations AS installation
|
||||
JOIN plugin_repositories AS repository ON repository.id = installation.repository_id
|
||||
WHERE repository.source_kind = $1
|
||||
`, RepositorySourceApprovedCommunity).Scan(&installedCommunityCount); err != nil {
|
||||
return CatalogSettings{}, fmt.Errorf("count installed approved community plugins: %w", err)
|
||||
}
|
||||
|
||||
migratedPluginCount, err := readIntegerSetting(ctx, s.pool, MigratedApprovedCommunityCountSetting)
|
||||
if err != nil {
|
||||
return CatalogSettings{}, err
|
||||
}
|
||||
|
||||
return CatalogSettings{
|
||||
IncludeApprovedCommunityPlugins: includeCommunity,
|
||||
ApprovedCommunityPluginCount: len(approvedCommunityPluginIDs),
|
||||
InstalledCommunityPluginCount: installedCommunityCount,
|
||||
MigratedPluginCount: migratedPluginCount,
|
||||
CommunityUpdatesPaused: !includeCommunity && installedCommunityCount > 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *RepositoryStore) SetIncludeApprovedCommunity(ctx context.Context, include bool) (CatalogSettings, error) {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return CatalogSettings{}, fmt.Errorf("begin approved community plugin setting update: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO server_settings (key, value)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||
`, IncludeApprovedCommunityPluginsSetting, strconv.FormatBool(include)); err != nil {
|
||||
return CatalogSettings{}, fmt.Errorf("update approved community plugin setting: %w", err)
|
||||
}
|
||||
|
||||
if _, err := reconcileManagedRepositories(ctx, tx, include); err != nil {
|
||||
return CatalogSettings{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return CatalogSettings{}, fmt.Errorf("commit approved community plugin setting update: %w", err)
|
||||
}
|
||||
|
||||
return s.GetCatalogSettings(ctx)
|
||||
}
|
||||
|
||||
type catalogSettingsQuerier interface {
|
||||
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
|
||||
}
|
||||
|
||||
type catalogSettingsExecutor interface {
|
||||
catalogSettingsQuerier
|
||||
Exec(ctx context.Context, sql string, arguments ...any) (pgconn.CommandTag, error)
|
||||
}
|
||||
|
||||
func readIncludeApprovedCommunity(ctx context.Context, querier catalogSettingsQuerier) (bool, error) {
|
||||
return readIncludeApprovedCommunityQuery(ctx, querier, `SELECT value FROM server_settings WHERE key = $1`)
|
||||
}
|
||||
|
||||
func readIncludeApprovedCommunityForUpdate(ctx context.Context, querier catalogSettingsQuerier) (bool, error) {
|
||||
return readIncludeApprovedCommunityQuery(ctx, querier, `SELECT value FROM server_settings WHERE key = $1 FOR UPDATE`)
|
||||
}
|
||||
|
||||
func readIncludeApprovedCommunityQuery(ctx context.Context, querier catalogSettingsQuerier, query string) (bool, error) {
|
||||
var value string
|
||||
err := querier.QueryRow(ctx, query, IncludeApprovedCommunityPluginsSetting).Scan(&value)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("read approved community plugin setting: %w", err)
|
||||
}
|
||||
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func readIntegerSetting(ctx context.Context, querier catalogSettingsQuerier, key string) (int, error) {
|
||||
var value string
|
||||
err := querier.QueryRow(ctx, `SELECT value FROM server_settings WHERE key = $1`, key).Scan(&value)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, fmt.Errorf("read plugin setting %q: %w", key, err)
|
||||
}
|
||||
parsed, err := strconv.Atoi(strings.TrimSpace(value))
|
||||
if err != nil || parsed < 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func reconcileManagedRepositories(ctx context.Context, executor catalogSettingsExecutor, includeCommunity bool) (int, error) {
|
||||
created := 0
|
||||
for _, definition := range managedRepositoryDefinitions {
|
||||
var exists bool
|
||||
if err := executor.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM plugin_repositories WHERE url = $1)`, definition.URL).Scan(&exists); err != nil {
|
||||
return 0, fmt.Errorf("check managed plugin repository %q: %w", definition.Key, err)
|
||||
}
|
||||
|
||||
enabled := definition.Key == OfficialRepositoryManagedKey || includeCommunity
|
||||
if _, err := executor.Exec(ctx, `
|
||||
INSERT INTO plugin_repositories (url, display_name, enabled, managed_key, source_kind)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (url) DO UPDATE SET
|
||||
display_name = EXCLUDED.display_name,
|
||||
enabled = EXCLUDED.enabled,
|
||||
managed_key = EXCLUDED.managed_key,
|
||||
source_kind = EXCLUDED.source_kind,
|
||||
updated_at = NOW()
|
||||
`, definition.URL, definition.DisplayName, enabled, definition.Key, definition.SourceKind); err != nil {
|
||||
return 0, fmt.Errorf("reconcile managed plugin repository %q: %w", definition.Key, err)
|
||||
}
|
||||
if !exists {
|
||||
created++
|
||||
}
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
@@ -12,12 +12,15 @@ import (
|
||||
)
|
||||
|
||||
var ErrRepositoryNotFound = errors.New("plugin repository not found")
|
||||
var ErrManagedRepositoryReadOnly = errors.New("managed plugin repository is read-only")
|
||||
|
||||
type Repository struct {
|
||||
ID int
|
||||
URL string
|
||||
DisplayName string
|
||||
Enabled bool
|
||||
ManagedKey *string
|
||||
SourceKind string
|
||||
LastFetchedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
@@ -44,7 +47,7 @@ func NewRepositoryStore(pool *pgxpool.Pool) *RepositoryStore {
|
||||
return &RepositoryStore{pool: pool}
|
||||
}
|
||||
|
||||
const repositoryColumns = `id, url, display_name, enabled, last_fetched_at, created_at, updated_at`
|
||||
const repositoryColumns = `id, url, display_name, enabled, managed_key, source_kind, last_fetched_at, created_at, updated_at`
|
||||
|
||||
func scanRepository(row pgx.Row) (*Repository, error) {
|
||||
var repository Repository
|
||||
@@ -54,6 +57,8 @@ func scanRepository(row pgx.Row) (*Repository, error) {
|
||||
&repository.URL,
|
||||
&repository.DisplayName,
|
||||
&repository.Enabled,
|
||||
&repository.ManagedKey,
|
||||
&repository.SourceKind,
|
||||
&lastFetchedAt,
|
||||
&repository.CreatedAt,
|
||||
&repository.UpdatedAt,
|
||||
@@ -111,6 +116,16 @@ func (s *RepositoryStore) List(ctx context.Context) ([]*Repository, error) {
|
||||
}
|
||||
|
||||
func (s *RepositoryStore) Update(ctx context.Context, id int, input UpdateRepositoryInput) error {
|
||||
if input.URL != nil || input.DisplayName != nil || input.Enabled != nil {
|
||||
repository, err := s.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if repository.ManagedKey != nil {
|
||||
return ErrManagedRepositoryReadOnly
|
||||
}
|
||||
}
|
||||
|
||||
var setClauses []string
|
||||
var args []any
|
||||
argIndex := 1
|
||||
@@ -160,6 +175,14 @@ func (s *RepositoryStore) Update(ctx context.Context, id int, input UpdateReposi
|
||||
}
|
||||
|
||||
func (s *RepositoryStore) Delete(ctx context.Context, id int) error {
|
||||
repository, err := s.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if repository.ManagedKey != nil {
|
||||
return ErrManagedRepositoryReadOnly
|
||||
}
|
||||
|
||||
tag, err := s.pool.Exec(ctx, `DELETE FROM plugin_repositories WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("deleting plugin repository: %w", err)
|
||||
|
||||
@@ -10,7 +10,9 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime/debug"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
@@ -156,7 +158,61 @@ func NewService(
|
||||
}
|
||||
|
||||
func (s *Service) FetchCatalog(ctx context.Context) ([]CatalogEntry, error) {
|
||||
return s.catalog.Fetch(ctx)
|
||||
entries, err := s.catalog.Fetch(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return catalogEntriesForDiscovery(entries), nil
|
||||
}
|
||||
|
||||
func catalogEntriesForDiscovery(entries []CatalogEntry) []CatalogEntry {
|
||||
selected := make(map[string]CatalogEntry, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.Manifest == nil {
|
||||
continue
|
||||
}
|
||||
pluginID := entry.Manifest.GetPluginId()
|
||||
if isApprovedCommunityPlugin(pluginID) && entry.SourceKind != RepositorySourceApprovedCommunity {
|
||||
continue
|
||||
}
|
||||
|
||||
existing, ok := selected[pluginID]
|
||||
if !ok || catalogEntryPreferredForDiscovery(entry, existing) {
|
||||
selected[pluginID] = entry
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]CatalogEntry, 0, len(selected))
|
||||
for _, entry := range selected {
|
||||
result = append(result, entry)
|
||||
}
|
||||
slices.SortFunc(result, func(left, right CatalogEntry) int {
|
||||
return strings.Compare(left.Manifest.GetPluginId(), right.Manifest.GetPluginId())
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
func catalogEntryPreferredForDiscovery(candidate, current CatalogEntry) bool {
|
||||
candidatePrecedence := repositorySourcePrecedence(candidate.SourceKind)
|
||||
currentPrecedence := repositorySourcePrecedence(current.SourceKind)
|
||||
if candidatePrecedence != currentPrecedence {
|
||||
return candidatePrecedence < currentPrecedence
|
||||
}
|
||||
if candidate.RepositoryID != current.RepositoryID {
|
||||
return candidate.RepositoryID < current.RepositoryID
|
||||
}
|
||||
return compareVersions(candidate.Manifest.GetVersion(), current.Manifest.GetVersion()) > 0
|
||||
}
|
||||
|
||||
func repositorySourcePrecedence(sourceKind string) int {
|
||||
switch sourceKind {
|
||||
case RepositorySourceSilo:
|
||||
return 0
|
||||
case RepositorySourceApprovedCommunity:
|
||||
return 1
|
||||
default:
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) InstallLocal(ctx context.Context, req InstallArchiveRequest) (*InstallResult, error) {
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE public.plugin_repositories
|
||||
ADD COLUMN managed_key TEXT,
|
||||
ADD COLUMN source_kind TEXT NOT NULL DEFAULT 'external';
|
||||
|
||||
ALTER TABLE public.plugin_repositories
|
||||
ADD CONSTRAINT plugin_repositories_source_kind_check
|
||||
CHECK (source_kind IN ('silo', 'approved_community', 'external'));
|
||||
|
||||
CREATE UNIQUE INDEX plugin_repositories_managed_key_unique
|
||||
ON public.plugin_repositories (managed_key)
|
||||
WHERE managed_key IS NOT NULL;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
legacy_instance BOOLEAN :=
|
||||
EXISTS (SELECT 1 FROM public.users)
|
||||
OR EXISTS (SELECT 1 FROM public.plugin_repositories)
|
||||
OR EXISTS (SELECT 1 FROM public.plugin_installations);
|
||||
include_community BOOLEAN;
|
||||
official_repository_id BIGINT;
|
||||
community_repository_id BIGINT;
|
||||
migrated_plugin_count INTEGER := 0;
|
||||
BEGIN
|
||||
-- Any persisted account or plugin state is durable evidence that Silo has
|
||||
-- run before. Existing servers are opted into the new channel, while a
|
||||
-- truly fresh database remains default-off.
|
||||
INSERT INTO public.server_settings (key, value)
|
||||
VALUES (
|
||||
'plugins.include_approved_community_plugins',
|
||||
CASE WHEN legacy_instance THEN 'true' ELSE 'false' END
|
||||
)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
SELECT LOWER(TRIM(value)) = 'true'
|
||||
INTO include_community
|
||||
FROM public.server_settings
|
||||
WHERE key = 'plugins.include_approved_community_plugins';
|
||||
|
||||
include_community := COALESCE(include_community, false);
|
||||
|
||||
INSERT INTO public.plugin_repositories (
|
||||
url,
|
||||
enabled,
|
||||
display_name,
|
||||
managed_key,
|
||||
source_kind
|
||||
) VALUES (
|
||||
'https://raw.githubusercontent.com/Silo-Server/silo-plugins/main/manifest.json',
|
||||
true,
|
||||
'Silo maintained',
|
||||
'official',
|
||||
'silo'
|
||||
)
|
||||
ON CONFLICT (url) DO UPDATE SET
|
||||
enabled = true,
|
||||
display_name = EXCLUDED.display_name,
|
||||
managed_key = EXCLUDED.managed_key,
|
||||
source_kind = EXCLUDED.source_kind,
|
||||
updated_at = NOW()
|
||||
RETURNING id INTO official_repository_id;
|
||||
|
||||
INSERT INTO public.plugin_repositories (
|
||||
url,
|
||||
enabled,
|
||||
display_name,
|
||||
managed_key,
|
||||
source_kind
|
||||
) VALUES (
|
||||
'https://raw.githubusercontent.com/Silo-Community/silo-plugins/main/manifest.json',
|
||||
include_community,
|
||||
'Approved community',
|
||||
'approved-community',
|
||||
'approved_community'
|
||||
)
|
||||
ON CONFLICT (url) DO UPDATE SET
|
||||
enabled = EXCLUDED.enabled,
|
||||
display_name = EXCLUDED.display_name,
|
||||
managed_key = EXCLUDED.managed_key,
|
||||
source_kind = EXCLUDED.source_kind,
|
||||
updated_at = NOW()
|
||||
RETURNING id INTO community_repository_id;
|
||||
|
||||
-- Preserve the installation row and every dependent configuration/binding
|
||||
-- row. Only installations that came from Silo's managed catalog move;
|
||||
-- uploads and custom repositories with the same plugin IDs are untouched.
|
||||
UPDATE public.plugin_installations
|
||||
SET repository_id = community_repository_id,
|
||||
available_version = NULL,
|
||||
updated_at = NOW()
|
||||
WHERE repository_id = official_repository_id
|
||||
AND plugin_id IN ('silo.requests.arr', 'silo.requests.seerr');
|
||||
|
||||
GET DIAGNOSTICS migrated_plugin_count = ROW_COUNT;
|
||||
|
||||
INSERT INTO public.server_settings (key, value)
|
||||
VALUES ('plugins.approved_community_migrated_plugin_count', migrated_plugin_count::TEXT)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
|
||||
END $$;
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DO $$
|
||||
DECLARE
|
||||
official_repository_id BIGINT;
|
||||
community_repository_id BIGINT;
|
||||
BEGIN
|
||||
SELECT id INTO official_repository_id
|
||||
FROM public.plugin_repositories
|
||||
WHERE managed_key = 'official';
|
||||
|
||||
SELECT id INTO community_repository_id
|
||||
FROM public.plugin_repositories
|
||||
WHERE managed_key = 'approved-community';
|
||||
|
||||
IF official_repository_id IS NOT NULL AND community_repository_id IS NOT NULL THEN
|
||||
UPDATE public.plugin_installations
|
||||
SET repository_id = official_repository_id,
|
||||
available_version = NULL,
|
||||
updated_at = NOW()
|
||||
WHERE repository_id = community_repository_id
|
||||
AND plugin_id IN ('silo.requests.arr', 'silo.requests.seerr');
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DELETE FROM public.server_settings
|
||||
WHERE key IN (
|
||||
'plugins.include_approved_community_plugins',
|
||||
'plugins.approved_community_migrated_plugin_count'
|
||||
);
|
||||
|
||||
UPDATE public.plugin_repositories
|
||||
SET managed_key = NULL,
|
||||
source_kind = 'external',
|
||||
updated_at = NOW()
|
||||
WHERE managed_key IN ('official', 'approved-community');
|
||||
|
||||
DROP INDEX IF EXISTS public.plugin_repositories_managed_key_unique;
|
||||
|
||||
ALTER TABLE public.plugin_repositories
|
||||
DROP CONSTRAINT IF EXISTS plugin_repositories_source_kind_check,
|
||||
DROP COLUMN IF EXISTS source_kind,
|
||||
DROP COLUMN IF EXISTS managed_key;
|
||||
-- +goose StatementEnd
|
||||
@@ -3312,16 +3312,36 @@ export interface PluginRepository {
|
||||
url: string;
|
||||
display_name: string;
|
||||
enabled: boolean;
|
||||
source_kind: "silo" | "approved_community" | "external";
|
||||
managed: boolean;
|
||||
last_fetched_at?: string | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface PluginPresentation {
|
||||
display_name: string;
|
||||
summary: string;
|
||||
description_markdown: string;
|
||||
setup_markdown: string;
|
||||
homepage_url: string;
|
||||
source_url: string;
|
||||
support_url: string;
|
||||
changelog_url: string;
|
||||
publisher_name: string;
|
||||
publisher_url: string;
|
||||
license_spdx: string;
|
||||
}
|
||||
|
||||
export interface PluginCatalogEntry {
|
||||
repository_id: number;
|
||||
plugin_id: string;
|
||||
version: string;
|
||||
archive_url: string;
|
||||
source_kind: "silo" | "approved_community" | "external";
|
||||
repository_name: string;
|
||||
repo_url?: string;
|
||||
presentation?: PluginPresentation;
|
||||
capabilities: PluginCapability[];
|
||||
global_config_schema: PluginConfigSchema[];
|
||||
user_config_schema: PluginConfigSchema[];
|
||||
@@ -3348,11 +3368,28 @@ export interface PluginInstallation {
|
||||
task_bindings: PluginTaskBinding[];
|
||||
update_policy: string;
|
||||
available_version?: string | null;
|
||||
source_kind: "silo" | "approved_community" | "external";
|
||||
repository_name?: string;
|
||||
repo_url?: string;
|
||||
presentation?: PluginPresentation;
|
||||
updates_paused: boolean;
|
||||
legacy_metadata_import_types?: string[];
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface PluginCatalogSettings {
|
||||
include_approved_community_plugins: boolean;
|
||||
approved_community_plugin_count: number;
|
||||
installed_community_plugin_count: number;
|
||||
migrated_plugin_count: number;
|
||||
community_updates_paused: boolean;
|
||||
}
|
||||
|
||||
export interface UpdatePluginCatalogSettingsRequest {
|
||||
include_approved_community_plugins: boolean;
|
||||
}
|
||||
|
||||
export interface CreatePluginRepositoryRequest {
|
||||
url: string;
|
||||
display_name: string;
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
CreatePluginRepositoryRequest,
|
||||
InstallPluginRequest,
|
||||
PluginCatalogEntry,
|
||||
PluginCatalogSettings,
|
||||
PluginInstallation,
|
||||
PluginRepository,
|
||||
PluginTaskBindingUpdateResponse,
|
||||
@@ -15,6 +16,7 @@ import type {
|
||||
SavePluginConfigRequest,
|
||||
SavePluginTaskBindingRequest,
|
||||
UpdatePluginInstallationRequest,
|
||||
UpdatePluginCatalogSettingsRequest,
|
||||
UpdatePluginRepositoryRequest,
|
||||
} from "@/api/types";
|
||||
import {
|
||||
@@ -31,6 +33,7 @@ function invalidatePluginQueries(queryClient: ReturnType<typeof useQueryClient>)
|
||||
queryClient.invalidateQueries({ queryKey: adminKeys.pluginRepositories() });
|
||||
queryClient.invalidateQueries({ queryKey: adminKeys.pluginCatalog() });
|
||||
queryClient.invalidateQueries({ queryKey: adminKeys.pluginInstallations() });
|
||||
queryClient.invalidateQueries({ queryKey: adminKeys.pluginCatalogSettings() });
|
||||
}
|
||||
|
||||
// useAdminPluginInstallations is a slim hook for callers (e.g. AdminSidebar)
|
||||
@@ -66,17 +69,50 @@ export function useAdminPlugins() {
|
||||
staleTime: ADMIN_STALE_TIME,
|
||||
});
|
||||
|
||||
const catalogSettingsQuery = useQuery({
|
||||
queryKey: adminKeys.pluginCatalogSettings(),
|
||||
queryFn: () => api<PluginCatalogSettings>("/admin/plugins/catalog-settings"),
|
||||
staleTime: ADMIN_STALE_TIME,
|
||||
});
|
||||
|
||||
return {
|
||||
repositories: repositoriesQuery.data ?? [],
|
||||
catalog: catalogQuery.data ?? [],
|
||||
installations: installationsQuery.data ?? [],
|
||||
catalogSettings: catalogSettingsQuery.data,
|
||||
isLoading:
|
||||
repositoriesQuery.isLoading || catalogQuery.isLoading || installationsQuery.isLoading,
|
||||
repositoriesQuery.isLoading ||
|
||||
catalogQuery.isLoading ||
|
||||
installationsQuery.isLoading ||
|
||||
catalogSettingsQuery.isLoading,
|
||||
isFetching:
|
||||
repositoriesQuery.isFetching || catalogQuery.isFetching || installationsQuery.isFetching,
|
||||
repositoriesQuery.isFetching ||
|
||||
catalogQuery.isFetching ||
|
||||
installationsQuery.isFetching ||
|
||||
catalogSettingsQuery.isFetching,
|
||||
};
|
||||
}
|
||||
|
||||
export function useUpdatePluginCatalogSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: UpdatePluginCatalogSettingsRequest) =>
|
||||
api<PluginCatalogSettings>("/admin/plugins/catalog-settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success("Plugin catalog settings updated");
|
||||
invalidatePluginQueries(queryClient);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to update plugin catalog settings",
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreatePluginRepository() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
@@ -437,6 +437,7 @@ export const adminKeys = {
|
||||
markerFileHistory: (fileId: number) => ["admin", "markerHistory", "files", fileId] as const,
|
||||
pluginRepositories: () => ["admin", "plugins", "repositories"] as const,
|
||||
pluginCatalog: () => ["admin", "plugins", "catalog"] as const,
|
||||
pluginCatalogSettings: () => ["admin", "plugins", "catalogSettings"] as const,
|
||||
pluginInstallations: () => ["admin", "plugins", "installations"] as const,
|
||||
unmatchedItems: (page?: number, search?: string) =>
|
||||
page != null
|
||||
|
||||
@@ -2,11 +2,87 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { PluginCatalogEntry, PluginInstallation } from "@/api/types";
|
||||
|
||||
import AdminPlugins from "./AdminPlugins";
|
||||
|
||||
const useAdminPluginsMock = vi.fn();
|
||||
const checkPluginUpdatesMutateMock = vi.fn();
|
||||
const updatePluginCatalogSettingsMutateMock = vi.fn();
|
||||
const capturedButtonProps: Array<Record<string, unknown>> = [];
|
||||
const capturedSwitchProps: Array<Record<string, unknown>> = [];
|
||||
|
||||
function makeCatalogEntry(
|
||||
index: number,
|
||||
overrides: { displayName?: string; summary?: string } = {},
|
||||
): PluginCatalogEntry {
|
||||
const suffix = String(index).padStart(2, "0");
|
||||
const repoURL = `https://github.com/Silo-Server/plugin-${suffix}`;
|
||||
return {
|
||||
repository_id: 1,
|
||||
plugin_id: `silo.plugin-${suffix}`,
|
||||
version: "1.0.0",
|
||||
archive_url: `${repoURL}/releases/download/v1.0.0/plugin-linux-amd64`,
|
||||
source_kind: "silo",
|
||||
repository_name: "Silo plugins",
|
||||
repo_url: repoURL,
|
||||
presentation: {
|
||||
display_name: overrides.displayName ?? `Plugin ${suffix}`,
|
||||
summary: overrides.summary ?? `Summary for plugin ${suffix}.`,
|
||||
description_markdown: `Description for plugin ${suffix}.`,
|
||||
setup_markdown: "Install and configure it.",
|
||||
homepage_url: repoURL,
|
||||
source_url: repoURL,
|
||||
support_url: `${repoURL}/issues`,
|
||||
changelog_url: `${repoURL}/releases`,
|
||||
publisher_name: "Silo",
|
||||
publisher_url: "https://github.com/Silo-Server",
|
||||
license_spdx: "AGPL-3.0-or-later",
|
||||
},
|
||||
capabilities: [],
|
||||
global_config_schema: [],
|
||||
user_config_schema: [],
|
||||
routes: [],
|
||||
assets: [],
|
||||
};
|
||||
}
|
||||
|
||||
function makeInstallation(index: number, displayName: string): PluginInstallation {
|
||||
const suffix = String(index).padStart(2, "0");
|
||||
return {
|
||||
id: index,
|
||||
repository_id: 1,
|
||||
plugin_id: `silo.installed-${suffix}`,
|
||||
version: "1.0.0",
|
||||
install_path: `/plugins/installed-${suffix}`,
|
||||
enabled: true,
|
||||
source_kind: "silo",
|
||||
repository_name: "Silo plugins",
|
||||
updates_paused: false,
|
||||
presentation: {
|
||||
display_name: displayName,
|
||||
summary: `Installed summary ${suffix}.`,
|
||||
description_markdown: `Installed description ${suffix}.`,
|
||||
setup_markdown: "Configure it.",
|
||||
homepage_url: "",
|
||||
source_url: `https://github.com/Silo-Server/installed-${suffix}`,
|
||||
support_url: "",
|
||||
changelog_url: "",
|
||||
publisher_name: "Silo",
|
||||
publisher_url: "https://github.com/Silo-Server",
|
||||
license_spdx: "AGPL-3.0-or-later",
|
||||
},
|
||||
capabilities: [],
|
||||
global_config_schema: [],
|
||||
user_config_schema: [],
|
||||
routes: [],
|
||||
assets: [],
|
||||
global_configs: [],
|
||||
auth_bindings: [],
|
||||
task_bindings: [],
|
||||
update_policy: "auto",
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("@/components/ui/button", () => ({
|
||||
Button: (props: Record<string, unknown>) => {
|
||||
@@ -22,6 +98,13 @@ vi.mock("@/components/ui/tabs", () => ({
|
||||
TabsContent: (props: Record<string, unknown>) => props.children,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/switch", () => ({
|
||||
Switch: (props: Record<string, unknown>) => {
|
||||
capturedSwitchProps.push(props);
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@tanstack/react-query")>("@tanstack/react-query");
|
||||
@@ -35,6 +118,10 @@ vi.mock("@/hooks/queries/admin/plugins", () => ({
|
||||
CHECK_PLUGIN_UPDATES_TASK_KEY: "check_plugin_updates",
|
||||
useAdminPlugins: () => useAdminPluginsMock(),
|
||||
useCheckPluginUpdates: () => ({ mutate: checkPluginUpdatesMutateMock, isPending: false }),
|
||||
useUpdatePluginCatalogSettings: () => ({
|
||||
mutate: updatePluginCatalogSettingsMutateMock,
|
||||
isPending: false,
|
||||
}),
|
||||
useCreatePluginRepository: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useUpdatePluginRepository: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useDeletePluginRepository: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
@@ -42,6 +129,7 @@ vi.mock("@/hooks/queries/admin/plugins", () => ({
|
||||
useUploadPlugin: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
usePluginUpload: () => ({ upload: vi.fn(), progress: null, isPending: false }),
|
||||
useUpdatePluginInstallation: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useApplyPluginUpdate: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useDeletePluginInstallation: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useSavePluginConfig: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useSavePluginAuthBinding: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
@@ -55,11 +143,14 @@ vi.mock("@/hooks/queries/admin/tasks", () => ({
|
||||
describe("AdminPlugins", () => {
|
||||
beforeEach(() => {
|
||||
capturedButtonProps.length = 0;
|
||||
capturedSwitchProps.length = 0;
|
||||
checkPluginUpdatesMutateMock.mockReset();
|
||||
updatePluginCatalogSettingsMutateMock.mockReset();
|
||||
useAdminPluginsMock.mockReturnValue({
|
||||
repositories: [],
|
||||
catalog: [],
|
||||
installations: [],
|
||||
catalogSettings: undefined,
|
||||
isLoading: false,
|
||||
});
|
||||
});
|
||||
@@ -98,4 +189,277 @@ describe("AdminPlugins", () => {
|
||||
expect(markup).toContain("Choose plugin file...");
|
||||
expect(markup).not.toContain('accept=".zip"');
|
||||
});
|
||||
|
||||
it("shows the approved community setting and explains migrated installations", () => {
|
||||
useAdminPluginsMock.mockReturnValue({
|
||||
repositories: [],
|
||||
catalog: [],
|
||||
installations: [],
|
||||
catalogSettings: {
|
||||
include_approved_community_plugins: true,
|
||||
approved_community_plugin_count: 2,
|
||||
installed_community_plugin_count: 2,
|
||||
migrated_plugin_count: 2,
|
||||
community_updates_paused: false,
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<MemoryRouter>
|
||||
<AdminPlugins />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(markup).toContain("Include approved community plugins");
|
||||
expect(markup).toContain("2 existing installations were moved here");
|
||||
expect(capturedSwitchProps[0]?.checked).toBe(true);
|
||||
});
|
||||
|
||||
it("enables the approved community catalog directly when no community plugins are installed", () => {
|
||||
useAdminPluginsMock.mockReturnValue({
|
||||
repositories: [],
|
||||
catalog: [],
|
||||
installations: [],
|
||||
catalogSettings: {
|
||||
include_approved_community_plugins: false,
|
||||
approved_community_plugin_count: 2,
|
||||
installed_community_plugin_count: 0,
|
||||
migrated_plugin_count: 0,
|
||||
community_updates_paused: false,
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
renderToStaticMarkup(
|
||||
<MemoryRouter>
|
||||
<AdminPlugins />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const onCheckedChange = capturedSwitchProps[0]?.onCheckedChange as (checked: boolean) => void;
|
||||
onCheckedChange(true);
|
||||
|
||||
expect(updatePluginCatalogSettingsMutateMock).toHaveBeenCalledWith({
|
||||
include_approved_community_plugins: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not disable the community catalog without confirmation when plugins are installed", () => {
|
||||
useAdminPluginsMock.mockReturnValue({
|
||||
repositories: [],
|
||||
catalog: [],
|
||||
installations: [],
|
||||
catalogSettings: {
|
||||
include_approved_community_plugins: true,
|
||||
approved_community_plugin_count: 2,
|
||||
installed_community_plugin_count: 2,
|
||||
migrated_plugin_count: 2,
|
||||
community_updates_paused: false,
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
renderToStaticMarkup(
|
||||
<MemoryRouter>
|
||||
<AdminPlugins />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const onCheckedChange = capturedSwitchProps[0]?.onCheckedChange as (checked: boolean) => void;
|
||||
onCheckedChange(false);
|
||||
|
||||
expect(updatePluginCatalogSettingsMutateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows catalog presentation metadata and external resource links", () => {
|
||||
useAdminPluginsMock.mockReturnValue({
|
||||
repositories: [],
|
||||
installations: [],
|
||||
catalogSettings: undefined,
|
||||
isLoading: false,
|
||||
catalog: [
|
||||
{
|
||||
repository_id: 1,
|
||||
plugin_id: "silo.example",
|
||||
version: "1.0.0",
|
||||
archive_url: "https://example.com/plugin",
|
||||
source_kind: "silo",
|
||||
repository_name: "Silo plugins",
|
||||
repo_url: "https://github.com/Silo-Server/example-plugin",
|
||||
presentation: {
|
||||
display_name: "Example Plugin",
|
||||
summary: "Explains the example for a homelab administrator.",
|
||||
description_markdown: "Longer description.",
|
||||
setup_markdown: "Configure the example.",
|
||||
homepage_url: "https://example.com",
|
||||
source_url: "https://github.com/Silo-Server/example-plugin",
|
||||
support_url: "https://github.com/Silo-Server/example-plugin/issues",
|
||||
changelog_url: "https://github.com/Silo-Server/example-plugin/releases",
|
||||
publisher_name: "Silo",
|
||||
publisher_url: "https://github.com/Silo-Server",
|
||||
license_spdx: "AGPL-3.0-or-later",
|
||||
},
|
||||
capabilities: [],
|
||||
global_config_schema: [],
|
||||
user_config_schema: [],
|
||||
routes: [],
|
||||
assets: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<MemoryRouter>
|
||||
<AdminPlugins />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(markup).toContain("Example Plugin");
|
||||
expect(markup).toContain("Explains the example for a homelab administrator.");
|
||||
expect(markup).toContain('href="https://github.com/Silo-Server/example-plugin"');
|
||||
expect(markup).toContain('href="https://github.com/Silo-Server/example-plugin/releases"');
|
||||
});
|
||||
|
||||
it("uses catalog presentation metadata for an older installed manifest", () => {
|
||||
useAdminPluginsMock.mockReturnValue({
|
||||
repositories: [],
|
||||
catalogSettings: undefined,
|
||||
isLoading: false,
|
||||
installations: [
|
||||
{
|
||||
id: 7,
|
||||
repository_id: 1,
|
||||
plugin_id: "silo.example",
|
||||
version: "0.9.0",
|
||||
install_path: "/plugins/example",
|
||||
enabled: true,
|
||||
source_kind: "silo",
|
||||
repository_name: "Silo plugins",
|
||||
updates_paused: false,
|
||||
capabilities: [],
|
||||
global_config_schema: [],
|
||||
user_config_schema: [],
|
||||
routes: [],
|
||||
assets: [],
|
||||
global_configs: [],
|
||||
auth_bindings: [],
|
||||
task_bindings: [],
|
||||
update_policy: "auto",
|
||||
},
|
||||
],
|
||||
catalog: [
|
||||
{
|
||||
repository_id: 1,
|
||||
plugin_id: "silo.example",
|
||||
version: "1.0.0",
|
||||
archive_url: "https://example.com/plugin",
|
||||
source_kind: "silo",
|
||||
repository_name: "Silo plugins",
|
||||
repo_url: "https://github.com/Silo-Server/example-plugin",
|
||||
presentation: {
|
||||
display_name: "Example Plugin",
|
||||
summary: "Catalog fallback description.",
|
||||
description_markdown: "Longer description.",
|
||||
setup_markdown: "Configure the example.",
|
||||
homepage_url: "https://example.com",
|
||||
source_url: "https://github.com/Silo-Server/example-plugin",
|
||||
support_url: "https://github.com/Silo-Server/example-plugin/issues",
|
||||
changelog_url: "https://github.com/Silo-Server/example-plugin/releases",
|
||||
publisher_name: "Silo",
|
||||
publisher_url: "https://github.com/Silo-Server",
|
||||
license_spdx: "AGPL-3.0-or-later",
|
||||
},
|
||||
capabilities: [],
|
||||
global_config_schema: [],
|
||||
user_config_schema: [],
|
||||
routes: [],
|
||||
assets: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<MemoryRouter>
|
||||
<AdminPlugins />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(markup).toContain("Catalog fallback description.");
|
||||
expect(markup).toContain('href="https://github.com/Silo-Server/example-plugin"');
|
||||
});
|
||||
|
||||
it("searches catalog presentation metadata from the URL", () => {
|
||||
useAdminPluginsMock.mockReturnValue({
|
||||
repositories: [],
|
||||
installations: [],
|
||||
catalogSettings: undefined,
|
||||
isLoading: false,
|
||||
catalog: [
|
||||
makeCatalogEntry(1, {
|
||||
displayName: "Alpha Scanner",
|
||||
summary: "Indexes local media files.",
|
||||
}),
|
||||
makeCatalogEntry(2, {
|
||||
displayName: "Needle Requests",
|
||||
summary: "Routes requests to the right service.",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<MemoryRouter initialEntries={["/admin/plugins?tab=catalog&catalog_q=needle"]}>
|
||||
<AdminPlugins />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(markup).toContain("Needle Requests");
|
||||
expect(markup).toContain("1 of 2 plugins");
|
||||
expect(markup).not.toContain("Alpha Scanner");
|
||||
});
|
||||
|
||||
it("searches installed plugin metadata independently from the catalog", () => {
|
||||
useAdminPluginsMock.mockReturnValue({
|
||||
repositories: [],
|
||||
catalog: [],
|
||||
catalogSettings: undefined,
|
||||
isLoading: false,
|
||||
installations: [
|
||||
makeInstallation(1, "Alpha Metadata"),
|
||||
makeInstallation(2, "Needle Automation"),
|
||||
],
|
||||
});
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<MemoryRouter initialEntries={["/admin/plugins?installed_q=needle"]}>
|
||||
<AdminPlugins />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(markup).toContain("Needle Automation");
|
||||
expect(markup).toContain("1 of 2 plugins");
|
||||
expect(markup).not.toContain("Alpha Metadata");
|
||||
});
|
||||
|
||||
it("paginates the catalog from URL state", () => {
|
||||
useAdminPluginsMock.mockReturnValue({
|
||||
repositories: [],
|
||||
installations: [],
|
||||
catalogSettings: undefined,
|
||||
isLoading: false,
|
||||
catalog: Array.from({ length: 13 }, (_, index) => makeCatalogEntry(index + 1)),
|
||||
});
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<MemoryRouter initialEntries={["/admin/plugins?tab=catalog&catalog_page=2"]}>
|
||||
<AdminPlugins />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(markup).toContain("Plugin 13");
|
||||
expect(markup).not.toContain("Plugin 01");
|
||||
expect(markup).toContain("Showing");
|
||||
expect(markup).toContain(">13</span>–<span");
|
||||
expect(markup).toContain("2 / 2");
|
||||
});
|
||||
});
|
||||
|
||||
+516
-56
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import {
|
||||
Blocks,
|
||||
CircleDot,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
Loader2,
|
||||
Package,
|
||||
Plus,
|
||||
Search,
|
||||
Settings2,
|
||||
Shield,
|
||||
Trash2,
|
||||
@@ -17,6 +19,7 @@ import {
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { TablePagination } from "@/components/ui/pagination";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import {
|
||||
Select,
|
||||
@@ -35,6 +38,16 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
@@ -42,7 +55,12 @@ import {
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { PluginConfigForm } from "@/components/admin/plugins/PluginConfigForm";
|
||||
import type { PluginCatalogEntry, PluginInstallation } from "@/api/types";
|
||||
import type {
|
||||
PluginCatalogEntry,
|
||||
PluginCatalogSettings,
|
||||
PluginInstallation,
|
||||
PluginPresentation,
|
||||
} from "@/api/types";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
CHECK_PLUGIN_UPDATES_TASK_KEY,
|
||||
@@ -58,6 +76,7 @@ import {
|
||||
useSavePluginConfig,
|
||||
useSavePluginTaskBinding,
|
||||
useUpdatePluginInstallation,
|
||||
useUpdatePluginCatalogSettings,
|
||||
useUpdatePluginRepository,
|
||||
} from "@/hooks/queries/admin/plugins";
|
||||
import { useTask } from "@/hooks/queries/admin/tasks";
|
||||
@@ -65,6 +84,9 @@ import { adminKeys } from "@/hooks/queries/keys";
|
||||
import { pluginRouteHref } from "@/lib/pluginRouteHref";
|
||||
import { navigateToPluginRoute } from "@/lib/buildPluginHref";
|
||||
|
||||
const INSTALLED_PAGE_SIZE = 10;
|
||||
const CATALOG_PAGE_SIZE = 12;
|
||||
|
||||
function capabilityLabel(type: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
"metadata_provider.v1": "Metadata",
|
||||
@@ -75,19 +97,194 @@ function capabilityLabel(type: string): string {
|
||||
return labels[type] ?? type.split(".")[0] ?? type;
|
||||
}
|
||||
|
||||
function sourceLabel(sourceKind: string): string {
|
||||
switch (sourceKind) {
|
||||
case "silo":
|
||||
return "Silo maintained";
|
||||
case "approved_community":
|
||||
return "Approved community";
|
||||
default:
|
||||
return "External source";
|
||||
}
|
||||
}
|
||||
|
||||
function pluginDisplayName(pluginID: string, presentation?: PluginPresentation): string {
|
||||
const displayName = presentation?.display_name.trim();
|
||||
if (displayName) return displayName;
|
||||
|
||||
return pluginID
|
||||
.replace(/^silo[._-]?/, "")
|
||||
.split(/[._-]+/)
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function pluginSummary(
|
||||
presentation: PluginPresentation | undefined,
|
||||
capabilities: PluginInstallation["capabilities"],
|
||||
): string {
|
||||
const summary = presentation?.summary.trim();
|
||||
if (summary) return summary;
|
||||
|
||||
return (
|
||||
capabilities.find((capability) => capability.description?.trim())?.description?.trim() ??
|
||||
"No description provided."
|
||||
);
|
||||
}
|
||||
|
||||
function safeExternalURL(rawURL?: string): string | undefined {
|
||||
if (!rawURL) return undefined;
|
||||
try {
|
||||
const url = new URL(rawURL);
|
||||
return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function PluginResourceLinks({
|
||||
presentation,
|
||||
repoURL,
|
||||
}: {
|
||||
presentation?: PluginPresentation;
|
||||
repoURL?: string;
|
||||
}) {
|
||||
const links = [
|
||||
{ label: "Source", url: safeExternalURL(presentation?.source_url || repoURL) },
|
||||
{ label: "Changelog", url: safeExternalURL(presentation?.changelog_url) },
|
||||
{ label: "Support", url: safeExternalURL(presentation?.support_url) },
|
||||
].filter((link): link is { label: string; url: string } => Boolean(link.url));
|
||||
|
||||
if (links.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="text-muted-foreground flex flex-wrap gap-x-3 gap-y-1 text-xs">
|
||||
{links.map((link) => (
|
||||
<a
|
||||
key={link.label}
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-foreground inline-flex items-center gap-1 transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function parsePluginPage(rawPage: string | null): number {
|
||||
const page = Number.parseInt(rawPage ?? "1", 10);
|
||||
return Number.isFinite(page) && page > 0 ? page - 1 : 0;
|
||||
}
|
||||
|
||||
function pluginMatchesSearch({
|
||||
query,
|
||||
pluginID,
|
||||
presentation,
|
||||
capabilities,
|
||||
sourceKind,
|
||||
repositoryName,
|
||||
}: {
|
||||
query: string;
|
||||
pluginID: string;
|
||||
presentation?: PluginPresentation;
|
||||
capabilities: PluginInstallation["capabilities"];
|
||||
sourceKind: string;
|
||||
repositoryName?: string;
|
||||
}): boolean {
|
||||
const normalizedQuery = query.trim().toLocaleLowerCase();
|
||||
if (!normalizedQuery) return true;
|
||||
|
||||
const searchableText = [
|
||||
pluginID,
|
||||
pluginDisplayName(pluginID, presentation),
|
||||
presentation?.summary,
|
||||
presentation?.description_markdown,
|
||||
presentation?.publisher_name,
|
||||
repositoryName,
|
||||
sourceLabel(sourceKind),
|
||||
...capabilities.flatMap((capability) => [
|
||||
capability.display_name,
|
||||
capability.description,
|
||||
capability.type,
|
||||
]),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
.toLocaleLowerCase();
|
||||
|
||||
return searchableText.includes(normalizedQuery);
|
||||
}
|
||||
|
||||
function PluginListToolbar({
|
||||
query,
|
||||
total,
|
||||
matchingTotal,
|
||||
placeholder,
|
||||
onQueryChange,
|
||||
}: {
|
||||
query: string;
|
||||
total: number;
|
||||
matchingTotal: number;
|
||||
placeholder: string;
|
||||
onQueryChange: (query: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 border-b pb-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="relative w-full sm:max-w-sm">
|
||||
<Search
|
||||
className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2"
|
||||
aria-hidden
|
||||
/>
|
||||
<Input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => onQueryChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
aria-label={placeholder}
|
||||
className="pr-9 pl-9"
|
||||
/>
|
||||
{query ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => onQueryChange("")}
|
||||
aria-label="Clear plugin search"
|
||||
className="absolute top-1/2 right-1 -translate-y-1/2"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="text-muted-foreground shrink-0 text-xs tabular-nums" aria-live="polite">
|
||||
{query.trim() ? `${matchingTotal} of ${total} plugins` : `${total} plugins`}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Installed plugin card ─────────────────────────────────────── */
|
||||
|
||||
function InstalledPluginCard({
|
||||
installation,
|
||||
catalogEntry,
|
||||
onConfigure,
|
||||
}: {
|
||||
installation: PluginInstallation;
|
||||
catalogEntry?: PluginCatalogEntry;
|
||||
onConfigure: (installation: PluginInstallation) => void;
|
||||
}) {
|
||||
const updateInstallation = useUpdatePluginInstallation();
|
||||
const deleteInstallation = useDeletePluginInstallation();
|
||||
const applyUpdate = useApplyPluginUpdate();
|
||||
const capabilities = installation.capabilities ?? [];
|
||||
const presentation = installation.presentation ?? catalogEntry?.presentation;
|
||||
const repoURL = installation.repo_url || catalogEntry?.repo_url;
|
||||
const routes = installation.routes ?? [];
|
||||
const adminRoutes = routes.filter(
|
||||
(route) => route.navigable && route.navigation_kind === "admin",
|
||||
@@ -107,15 +304,25 @@ function InstalledPluginCard({
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-[15px] leading-tight font-semibold">{installation.plugin_id}</h3>
|
||||
<h3 className="text-[15px] leading-tight font-semibold">
|
||||
{pluginDisplayName(installation.plugin_id, presentation)}
|
||||
</h3>
|
||||
<Badge variant="secondary" className="font-mono text-[11px]">
|
||||
{installation.version}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-[11px]">
|
||||
{sourceLabel(installation.source_kind)}
|
||||
</Badge>
|
||||
{installation.available_version && (
|
||||
<Badge variant="outline" className="border-amber-500/40 text-[11px] text-amber-500">
|
||||
{installation.version} → {installation.available_version} available
|
||||
</Badge>
|
||||
)}
|
||||
{installation.updates_paused ? (
|
||||
<Badge variant="outline" className="text-muted-foreground text-[11px]">
|
||||
Updates paused
|
||||
</Badge>
|
||||
) : null}
|
||||
{installation.available_version && (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -146,6 +353,10 @@ function InstalledPluginCard({
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground font-mono text-[11px]">{installation.plugin_id}</p>
|
||||
<p className="text-muted-foreground max-w-3xl text-xs leading-relaxed">
|
||||
{pluginSummary(presentation, capabilities)}
|
||||
</p>
|
||||
{capabilities.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{capabilities.map((cap) => (
|
||||
@@ -158,6 +369,7 @@ function InstalledPluginCard({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<PluginResourceLinks presentation={presentation} repoURL={repoURL} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -452,6 +664,7 @@ function ConfigureDialog({
|
||||
|
||||
function CatalogCard({ entry, isInstalled }: { entry: PluginCatalogEntry; isInstalled: boolean }) {
|
||||
const installPlugin = useInstallPlugin();
|
||||
const capabilities = entry.capabilities ?? [];
|
||||
|
||||
return (
|
||||
<div className="surface-panel-subtle flex flex-col justify-between gap-4 rounded-xl p-5">
|
||||
@@ -461,14 +674,23 @@ function CatalogCard({ entry, isInstalled }: { entry: PluginCatalogEntry; isInst
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-[15px] leading-tight font-semibold">{entry.plugin_id}</h3>
|
||||
<h3 className="text-[15px] leading-tight font-semibold">
|
||||
{pluginDisplayName(entry.plugin_id, entry.presentation)}
|
||||
</h3>
|
||||
<Badge variant="secondary" className="font-mono text-[11px]">
|
||||
{entry.version}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-[11px]">
|
||||
{sourceLabel(entry.source_kind)}
|
||||
</Badge>
|
||||
</div>
|
||||
{entry.capabilities?.length > 0 && (
|
||||
<p className="text-muted-foreground font-mono text-[11px]">{entry.plugin_id}</p>
|
||||
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
|
||||
{pluginSummary(entry.presentation, capabilities)}
|
||||
</p>
|
||||
{capabilities.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{entry.capabilities.map((cap) => (
|
||||
{capabilities.map((cap) => (
|
||||
<span
|
||||
key={`${cap.type}:${cap.id}`}
|
||||
className="bg-muted text-muted-foreground inline-flex items-center rounded-md px-2 py-0.5 text-[11px] font-medium"
|
||||
@@ -480,7 +702,8 @@ function CatalogCard({ entry, isInstalled }: { entry: PluginCatalogEntry; isInst
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t pt-3">
|
||||
<PluginResourceLinks presentation={entry.presentation} repoURL={entry.repo_url} />
|
||||
{isInstalled ? (
|
||||
<Badge variant="outline" className="text-muted-foreground text-xs">
|
||||
Installed
|
||||
@@ -506,6 +729,81 @@ function CatalogCard({ entry, isInstalled }: { entry: PluginCatalogEntry; isInst
|
||||
);
|
||||
}
|
||||
|
||||
function CommunityCatalogControl({ settings }: { settings: PluginCatalogSettings }) {
|
||||
const updateSettings = useUpdatePluginCatalogSettings();
|
||||
const [confirmDisable, setConfirmDisable] = useState(false);
|
||||
|
||||
function setIncluded(include: boolean) {
|
||||
if (!include && settings.installed_community_plugin_count > 0) {
|
||||
setConfirmDisable(true);
|
||||
return;
|
||||
}
|
||||
updateSettings.mutate({ include_approved_community_plugins: include });
|
||||
}
|
||||
|
||||
function disableCommunityCatalog() {
|
||||
updateSettings.mutate({ include_approved_community_plugins: false });
|
||||
setConfirmDisable(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-3 border-b pb-5 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="max-w-2xl space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="text-muted-foreground h-4 w-4" />
|
||||
<label htmlFor="approved-community-plugins" className="text-sm font-medium">
|
||||
Include approved community plugins
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
Reviewed by Silo maintainers to work as described and be safe for their documented use.
|
||||
These plugins remain maintained and supported by community contributors.
|
||||
</p>
|
||||
{settings.migrated_plugin_count > 0 ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{settings.migrated_plugin_count} existing{" "}
|
||||
{settings.migrated_plugin_count === 1 ? "installation was" : "installations were"}{" "}
|
||||
moved here without changing configuration.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Switch
|
||||
id="approved-community-plugins"
|
||||
checked={settings.include_approved_community_plugins}
|
||||
disabled={updateSettings.isPending}
|
||||
onCheckedChange={setIncluded}
|
||||
aria-label="Include approved community plugins"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={confirmDisable} onOpenChange={setConfirmDisable}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Hide approved community plugins?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{settings.installed_community_plugin_count}{" "}
|
||||
{settings.installed_community_plugin_count === 1
|
||||
? "installed plugin will"
|
||||
: "installed plugins will"}{" "}
|
||||
keep running, but update discovery will pause until this catalog is included again.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={disableCommunityCatalog}
|
||||
disabled={updateSettings.isPending}
|
||||
>
|
||||
Hide and pause updates
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Repository management ─────────────────────────────────────── */
|
||||
|
||||
function RepositorySection() {
|
||||
@@ -584,23 +882,29 @@ function RepositorySection() {
|
||||
<p className="text-muted-foreground truncate font-mono text-xs">{repo.url}</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={() =>
|
||||
updateRepository.mutate({ id: repo.id, body: { enabled: !repo.enabled } })
|
||||
}
|
||||
>
|
||||
{repo.enabled ? "Disable" : "Enable"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
onClick={() => deleteRepository.mutate(repo.id)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
{repo.managed ? (
|
||||
<span className="text-muted-foreground self-center text-xs">Managed by Silo</span>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={() =>
|
||||
updateRepository.mutate({ id: repo.id, body: { enabled: !repo.enabled } })
|
||||
}
|
||||
>
|
||||
{repo.enabled ? "Disable" : "Enable"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
onClick={() => deleteRepository.mutate(repo.id)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -662,17 +966,95 @@ function UploadSection() {
|
||||
/* ─── Main page ─────────────────────────────────────────────────── */
|
||||
|
||||
export default function AdminPlugins() {
|
||||
const { installations, catalog, isLoading } = useAdminPlugins();
|
||||
const { installations, catalog, catalogSettings, isLoading } = useAdminPlugins();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const checkPluginUpdates = useCheckPluginUpdates();
|
||||
const { data: pluginUpdateTask } = useTask(CHECK_PLUGIN_UPDATES_TASK_KEY);
|
||||
const [configuring, setConfiguring] = useState<PluginInstallation | null>(null);
|
||||
const previousTaskState = useRef<string | null>(null);
|
||||
|
||||
const installedIds = new Set(installations.map((i) => i.plugin_id));
|
||||
const installedIds = useMemo(
|
||||
() => new Set(installations.map((installation) => installation.plugin_id)),
|
||||
[installations],
|
||||
);
|
||||
const catalogByPluginID = useMemo(
|
||||
() => new Map(catalog.map((entry) => [entry.plugin_id, entry])),
|
||||
[catalog],
|
||||
);
|
||||
const activeTab = searchParams.get("tab") === "catalog" ? "catalog" : "installed";
|
||||
const installedQuery = searchParams.get("installed_q") ?? "";
|
||||
const catalogQuery = searchParams.get("catalog_q") ?? "";
|
||||
const filteredInstallations = useMemo(
|
||||
() =>
|
||||
installations.filter((installation) => {
|
||||
const catalogEntry = catalogByPluginID.get(installation.plugin_id);
|
||||
return pluginMatchesSearch({
|
||||
query: installedQuery,
|
||||
pluginID: installation.plugin_id,
|
||||
presentation: installation.presentation ?? catalogEntry?.presentation,
|
||||
capabilities: installation.capabilities ?? [],
|
||||
sourceKind: installation.source_kind,
|
||||
repositoryName: installation.repository_name,
|
||||
});
|
||||
}),
|
||||
[catalogByPluginID, installations, installedQuery],
|
||||
);
|
||||
const filteredCatalog = useMemo(
|
||||
() =>
|
||||
catalog.filter((entry) =>
|
||||
pluginMatchesSearch({
|
||||
query: catalogQuery,
|
||||
pluginID: entry.plugin_id,
|
||||
presentation: entry.presentation,
|
||||
capabilities: entry.capabilities ?? [],
|
||||
sourceKind: entry.source_kind,
|
||||
repositoryName: entry.repository_name,
|
||||
}),
|
||||
),
|
||||
[catalog, catalogQuery],
|
||||
);
|
||||
const installedPageCount = Math.max(
|
||||
1,
|
||||
Math.ceil(filteredInstallations.length / INSTALLED_PAGE_SIZE),
|
||||
);
|
||||
const catalogPageCount = Math.max(1, Math.ceil(filteredCatalog.length / CATALOG_PAGE_SIZE));
|
||||
const installedPage = Math.min(
|
||||
parsePluginPage(searchParams.get("installed_page")),
|
||||
installedPageCount - 1,
|
||||
);
|
||||
const catalogPage = Math.min(
|
||||
parsePluginPage(searchParams.get("catalog_page")),
|
||||
catalogPageCount - 1,
|
||||
);
|
||||
const visibleInstallations = useMemo(
|
||||
() =>
|
||||
filteredInstallations.slice(
|
||||
installedPage * INSTALLED_PAGE_SIZE,
|
||||
(installedPage + 1) * INSTALLED_PAGE_SIZE,
|
||||
),
|
||||
[filteredInstallations, installedPage],
|
||||
);
|
||||
const visibleCatalog = useMemo(
|
||||
() =>
|
||||
filteredCatalog.slice(catalogPage * CATALOG_PAGE_SIZE, (catalogPage + 1) * CATALOG_PAGE_SIZE),
|
||||
[catalogPage, filteredCatalog],
|
||||
);
|
||||
const isCheckingUpdates =
|
||||
pluginUpdateTask?.state === "running" || pluginUpdateTask?.state === "cancelling";
|
||||
|
||||
function updatePluginView(
|
||||
updates: Record<string, string | undefined>,
|
||||
options: { replace?: boolean } = { replace: true },
|
||||
) {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (value) next.set(key, value);
|
||||
else next.delete(key);
|
||||
}
|
||||
setSearchParams(next, { replace: options.replace ?? true });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const currentState = pluginUpdateTask?.state ?? null;
|
||||
const previousState = previousTaskState.current;
|
||||
@@ -723,7 +1105,12 @@ export default function AdminPlugins() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="installed">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) =>
|
||||
updatePluginView({ tab: value === "catalog" ? "catalog" : undefined }, { replace: false })
|
||||
}
|
||||
>
|
||||
<TabsList variant="line" className="mb-2">
|
||||
<TabsTrigger value="installed">
|
||||
Installed
|
||||
@@ -733,8 +1120,8 @@ export default function AdminPlugins() {
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="available">
|
||||
Available
|
||||
<TabsTrigger value="catalog">
|
||||
Catalog
|
||||
{catalog.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-1.5 text-[10px]">
|
||||
{catalog.length}
|
||||
@@ -745,46 +1132,84 @@ export default function AdminPlugins() {
|
||||
|
||||
{/* ── Installed ── */}
|
||||
<TabsContent value="installed" className="space-y-3">
|
||||
{installations.length > 0 ? (
|
||||
<PluginListToolbar
|
||||
query={installedQuery}
|
||||
total={installations.length}
|
||||
matchingTotal={filteredInstallations.length}
|
||||
placeholder="Search installed plugins"
|
||||
onQueryChange={(query) =>
|
||||
updatePluginView({ installed_q: query || undefined, installed_page: undefined })
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{installations.length === 0 ? (
|
||||
<div className="surface-panel-subtle flex flex-col items-center gap-3 rounded-xl py-16">
|
||||
<Blocks className="text-muted-foreground h-10 w-10" />
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium">No plugins installed</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Browse the Available tab to find and install plugins.
|
||||
Browse the Catalog tab to find and install plugins.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{installations.map((installation) => (
|
||||
<InstalledPluginCard
|
||||
key={installation.id}
|
||||
installation={installation}
|
||||
onConfigure={setConfiguring}
|
||||
/>
|
||||
))}
|
||||
) : filteredInstallations.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-sm font-medium">No installed plugins match your search</p>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground mt-1 text-xs transition-colors"
|
||||
onClick={() =>
|
||||
updatePluginView({ installed_q: undefined, installed_page: undefined })
|
||||
}
|
||||
>
|
||||
Clear search
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Available ── */}
|
||||
<TabsContent value="available" className="space-y-8">
|
||||
{/* Catalog grid */}
|
||||
{catalog.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold">Catalog</h3>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{catalog.map((entry) => (
|
||||
<CatalogCard
|
||||
key={`${entry.plugin_id}:${entry.version}`}
|
||||
entry={entry}
|
||||
isInstalled={installedIds.has(entry.plugin_id)}
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
{visibleInstallations.map((installation) => (
|
||||
<InstalledPluginCard
|
||||
key={installation.id}
|
||||
installation={installation}
|
||||
catalogEntry={catalogByPluginID.get(installation.plugin_id)}
|
||||
onConfigure={setConfiguring}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
{filteredInstallations.length > INSTALLED_PAGE_SIZE ? (
|
||||
<TablePagination
|
||||
page={installedPage}
|
||||
pageSize={INSTALLED_PAGE_SIZE}
|
||||
total={filteredInstallations.length}
|
||||
itemNoun="plugin"
|
||||
onPageChange={(page) =>
|
||||
updatePluginView({ installed_page: page === 0 ? undefined : String(page + 1) })
|
||||
}
|
||||
className="border-t pt-3"
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Catalog ── */}
|
||||
<TabsContent value="catalog" className="space-y-8">
|
||||
{catalogSettings ? <CommunityCatalogControl settings={catalogSettings} /> : null}
|
||||
{catalog.length > 0 ? (
|
||||
<PluginListToolbar
|
||||
query={catalogQuery}
|
||||
total={catalog.length}
|
||||
matchingTotal={filteredCatalog.length}
|
||||
placeholder="Search the plugin catalog"
|
||||
onQueryChange={(query) =>
|
||||
updatePluginView({ catalog_q: query || undefined, catalog_page: undefined })
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{/* Catalog grid */}
|
||||
{catalog.length === 0 ? (
|
||||
<div className="surface-panel-subtle flex flex-col items-center gap-3 rounded-xl py-12">
|
||||
<Package className="text-muted-foreground h-10 w-10" />
|
||||
<div className="text-center">
|
||||
@@ -794,6 +1219,41 @@ export default function AdminPlugins() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : filteredCatalog.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-sm font-medium">No catalog plugins match your search</p>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground mt-1 text-xs transition-colors"
|
||||
onClick={() => updatePluginView({ catalog_q: undefined, catalog_page: undefined })}
|
||||
>
|
||||
Clear search
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{visibleCatalog.map((entry) => (
|
||||
<CatalogCard
|
||||
key={`${entry.plugin_id}:${entry.version}`}
|
||||
entry={entry}
|
||||
isInstalled={installedIds.has(entry.plugin_id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{filteredCatalog.length > CATALOG_PAGE_SIZE ? (
|
||||
<TablePagination
|
||||
page={catalogPage}
|
||||
pageSize={CATALOG_PAGE_SIZE}
|
||||
total={filteredCatalog.length}
|
||||
itemNoun="plugin"
|
||||
onPageChange={(page) =>
|
||||
updatePluginView({ catalog_page: page === 0 ? undefined : String(page + 1) })
|
||||
}
|
||||
className="border-t pt-3"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<UploadSection />
|
||||
|
||||
Reference in New Issue
Block a user