feat(plugins): add approved community catalog hub (#355)

This commit is contained in:
Quick
2026-07-09 19:06:36 -04:00
committed by GitHub
parent d68e70bb47
commit 10e15798e0
22 changed files with 2507 additions and 128 deletions
@@ -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.
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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=
+171
View File
@@ -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)
}
}
+2
View File
@@ -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)
+91 -44
View File
@@ -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
+88 -9
View File
@@ -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
+51
View File
@@ -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())
}
}
+22 -12
View File
@@ -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())
}
}
+185
View File
@@ -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
}
+24 -1
View File
@@ -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)
+57 -1
View File
@@ -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
+37
View File
@@ -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;
+38 -2
View File
@@ -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({
+1
View File
@@ -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
+364
View File
@@ -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
View File
@@ -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} &rarr; {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 />