feat(store): read ledmatrix_min_version, aliases and commit from the registry (#686)

The store reads three optional registry fields: ledmatrix_min_version
(an incompatible install/update is refused before any download, with a
"Needs LEDMatrix X+" card badge), aliases (update/uninstall/reinstall by
registry id find a plugin installed under its manifest id, with registry
proof only), and commit (shown and linked on the store card). An older
plugins.json behaves as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-30 09:31:16 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 1c928b2033
commit 5ea0d511dc
15 changed files with 922 additions and 39 deletions
+32
View File
@@ -118,8 +118,40 @@ read any of them:
`"fixed"`): both always scrolled. Documented only; no warning, because `"fixed"`): both always scrolled. Documented only; no warning, because
official plugins' schemas still offer `"fixed"`. official plugins' schemas still offer `"fixed"`.
### Plugin store
- The store reads three optional registry fields that ledmatrix-plugins'
`update_registry.py` now publishes (ChuckBuilds/ledmatrix-plugins#579). An
older `plugins.json` without them behaves as before.
- `ledmatrix_min_version`: an install or update this core cannot run is
refused before anything is downloaded, pulled or moved aside, and the web
UI says why ("requires LEDMatrix X or newer…", HTTP 409) instead of "check
logs for details". The store card shows a "Needs LEDMatrix X+" badge. The
check on the downloaded manifest stays as the fallback (older registries,
an explicitly requested other branch, `compatible_versions`).
- `aliases`: the entry's other ids. Update, uninstall and reinstall by the
registry id now find a plugin installed under its manifest id
(`weather` → `ledmatrix-weather/`; likewise leaderboard, music, stocks).
Only registry proof counts: the entry's `aliases` or its `plugin_path`
name, or a folder whose manifest declares one of those ids. A
`ledmatrix-<id>/` folder with no such proof is never replaced or removed;
uninstall and update report "not installed" and log the folder's path.
Install and update fetch the registry first when such a folder exists
and none is loaded; uninstall stays offline.
- `commit`: the monorepo commit that introduced the listed version, shown
on the store card and linked to the plugin's source at that commit.
Informational only; installs still come from the branch head.
### Fixes ### Fixes
- Reinstalling a plugin by its registry id when it is installed under its
manifest id (`weather` in `ledmatrix-weather/`) no longer deletes it when
the install then fails. The safety copy was taken of `weather/`, which did
not exist, and the real install was removed to make room for the download,
so a refusal by the compatibility gate left no plugin at all. Uninstalling
by the registry id reported success and removed nothing; updating by it
said "not installed". All three now find the install.
- On-demand no longer restarts a running display. `POST - On-demand no longer restarts a running display. `POST
/display/on-demand/start` treated `start_service` (on by default, and what /display/on-demand/start` treated `start_service` (on by default, and what
"Preview on display", the on-demand dialog and the MQTT bridge all send) as "Preview on display", the on-demand dialog and the MQTT bridge all send) as
+1
View File
@@ -46,6 +46,7 @@
- Store manager (`PluginStoreManager` in `src/plugin_system/store_manager.py`) handles install/update/uninstall - Store manager (`PluginStoreManager` in `src/plugin_system/store_manager.py`) handles install/update/uninstall
- Monorepo plugins are installed without a `.git` directory: GitHub Trees API + raw downloads, falling back to ZIP extraction - Monorepo plugins are installed without a `.git` directory: GitHub Trees API + raw downloads, falling back to ZIP extraction
- Update detection for monorepo plugins uses version comparison (manifest version vs registry latest_version) - Update detection for monorepo plugins uses version comparison (manifest version vs registry latest_version)
- Optional registry entry fields (`store_registry.py`): `ledmatrix_min_version` refuses an incompatible install/update before the download (the post-download manifest gate stays as the fallback); `aliases` are the entry's other ids (manifest id `ledmatrix-weather` for `weather`), used with the `plugin_path` name by update/uninstall/reinstall to find the install — only this registry proof counts, never a bare `ledmatrix-<id>` folder (owner decision, #686; such a folder is only logged); `commit` is informational. An older plugins.json has none of them
- Plugin configs stored in `config/config.json`, NOT in plugin directories — safe across reinstalls - Plugin configs stored in `config/config.json`, NOT in plugin directories — safe across reinstalls
- Third-party plugins can use their own repo URL with empty `plugin_path` - Third-party plugins can use their own repo URL with empty `plugin_path`
+19 -1
View File
@@ -687,6 +687,12 @@ Install a plugin from the plugin store.
When the operation queue is unavailable the install runs synchronously and When the operation queue is unavailable the install runs synchronously and
the response has only a `message`. the response has only a `message`.
A plugin whose registry entry (or downloaded manifest) needs a newer
LEDMatrix is refused: the synchronous install answers `409` with a message
such as `Failed to install plugin x: X requires LEDMatrix 3.8.0 or newer…`,
and a queued one fails with that message. Nothing already installed is
changed.
### Uninstall Plugin ### Uninstall Plugin
**POST** `/api/v3/plugins/uninstall` **POST** `/api/v3/plugins/uninstall`
@@ -735,6 +741,9 @@ Update a plugin to the latest version. Runs synchronously.
} }
``` ```
An update this core cannot run answers `409` with `Plugin update refused:`
and the reason; the installed version is left as it was.
### Install Plugin from URL ### Install Plugin from URL
**POST** `/api/v3/plugins/install-from-url` **POST** `/api/v3/plugins/install-from-url`
@@ -1235,13 +1244,22 @@ searches.
"version": "1.2.3", "version": "1.2.3",
"branch": "main", "branch": "main",
"default_branch": "main", "default_branch": "main",
"plugin_path": "plugins/football-scoreboard" "plugin_path": "plugins/football-scoreboard",
"commit": "843588025a81197056f8d96779ccb2be19337ab8",
"ledmatrix_min_version": "3.7.0",
"aliases": [],
"incompatible_reason": null
} }
] ]
} }
} }
``` ```
`commit` (the monorepo commit that introduced `version`),
`ledmatrix_min_version` and `aliases` come from the registry entry and are
`null` / `[]` when an older registry lacks them. `incompatible_reason` is the
message an install would be refused with on this core, or `null`.
### Get GitHub Status ### Get GitHub Status
**GET** `/api/v3/plugins/store/github-status` **GET** `/api/v3/plugins/store/github-status`
+6 -2
View File
@@ -514,8 +514,12 @@ a floor can be trusted against, and today it is not:
the update path that re-downloads. the update path that re-downloads.
`update_plugin`'s git branch pulls in place and re-downloads nothing, so it `update_plugin`'s git branch pulls in place and re-downloads nothing, so it
stayed ungated until `_gate_pulled_commit` closed it — checked after the pull stayed ungated until `_gate_pulled_commit` closed it — checked after the pull
(the registry carries no floor field, so the incoming floor is unknowable (the registry then carried no floor field, so the incoming floor was
before it) and undone with `git reset --hard` to the pre-pull commit. That unknowable before it) and undone with `git reset --hard` to the pre-pull
commit. The registry now publishes `ledmatrix_min_version`, and install and
update refuse on it before downloading or pulling; both post-download gates
remain as the fallback for older registries, other branches and
`compatible_versions`. That
route is rare in practice, since monorepo plugins install as archives; it was route is rare in practice, since monorepo plugins install as archives; it was
closed because the sunset rule in the plugins repo's closed because the sunset rule in the plugins repo's
`08-shared-sports-code.md` states as **condition 3** that the core enforces `08-shared-sports-code.md` states as **condition 3** that the core enforces
+44 -4
View File
@@ -63,7 +63,14 @@ class _InstallMixin:
return False return False
with self._get_reinstall_lock(plugin_id): with self._get_reinstall_lock(plugin_id):
plugin_path = self.plugins_dir / plugin_id # The copy to protect is wherever this plugin is installed, not
# necessarily plugins_dir/<id>: asked for the registry id
# `weather`, the install lives in `ledmatrix-weather/`, the
# manifest's id. Backing up only `weather/` protected nothing,
# and _install_plugin_impl then deleted `ledmatrix-weather/` to
# make room for the download -- so a refusal after that point
# (the post-download compatibility gate) left no plugin at all.
plugin_path = self._existing_install(plugin_id) or self.plugins_dir / plugin_id
if not plugin_path.exists(): if not plugin_path.exists():
return self._install_plugin_impl(plugin_id, branch) return self._install_plugin_impl(plugin_id, branch)
@@ -91,6 +98,26 @@ class _InstallMixin:
self._restore_backup(plugin_id, plugin_path, backup_path, "Install") self._restore_backup(plugin_id, plugin_path, backup_path, "Install")
return False return False
def _existing_install(self, plugin_id: str) -> Optional[Path]:
"""The installed copy of ``plugin_id`` in plugins_dir, by the id or an
alias the registry proves (`_installed_id_candidates`).
When nothing matches but a ``ledmatrix-<id>`` folder exists and no
registry is loaded yet, the registry is fetched first -- the install
fetches it anyway -- because without it that folder can be neither
protected nor trusted: the download may be renamed onto it.
"""
dirs = [self.plugins_dir]
found = self._resolve_installed(plugin_id, dirs)
if (found is None and not getattr(self, 'registry_cache', None)
and self._unproven_prefix_folder(plugin_id, dirs) is not None):
try:
self.fetch_registry()
except Exception as e: # noqa: BLE001 - proceed as before without proof
self.logger.debug("Registry fetch before installing %s failed: %s", plugin_id, e)
found = self._resolve_installed(plugin_id, dirs)
return found
def _set_aside(self, plugin_path: Path, backup_path: Path) -> Optional[str]: def _set_aside(self, plugin_path: Path, backup_path: Path) -> Optional[str]:
"""Rename an installed plugin to ``backup_path`` so a failed """Rename an installed plugin to ``backup_path`` so a failed
(re)install can put it back. (re)install can put it back.
@@ -164,6 +191,16 @@ class _InstallMixin:
self.logger.error(f"Plugin {plugin_id} missing repository URL") self.logger.error(f"Plugin {plugin_id} missing repository URL")
return False return False
# The registry's floor describes the release on the entry's branch.
# Checked here, before anything is removed or downloaded; the gate on
# the downloaded manifest below stays as the fallback (older
# registries, compatible_versions ranges). A different branch asked
# for by name is a different release, so only the fallback applies.
registry_branch = plugin_info.get('branch') or plugin_info.get('default_branch')
if (not branch or not registry_branch or branch == registry_branch) and \
self._refuse_if_registry_incompatible(plugin_id, plugin_info, "install"):
return False
plugin_subpath = plugin_info.get('plugin_path') plugin_subpath = plugin_info.get('plugin_path')
# If branch is provided, prioritize it; otherwise use default logic # If branch is provided, prioritize it; otherwise use default logic
branch_candidates = self._distinct_sequence([ branch_candidates = self._distinct_sequence([
@@ -280,9 +317,11 @@ class _InstallMixin:
return False return False
# Refuse a plugin that needs a newer core than this one. The # Refuse a plugin that needs a newer core than this one. The
# registry carries no compatibility field, so the floor is only # registry's `ledmatrix_min_version` already refused the
# knowable once the files are down — checking here, before # common case before the download (above); this is the
# dependency installation, is the earliest possible point. # fallback for a registry without it, a branch other than the
# registry's, and `compatible_versions`, which only the
# manifest carries. Before dependency installation, still.
# #
# Refusing costs the user nothing: on an update this returns # Refusing costs the user nothing: on an update this returns
# False and _reinstall_with_rollback restores the version they # False and _reinstall_with_rollback restores the version they
@@ -299,6 +338,7 @@ class _InstallMixin:
if not compatible: if not compatible:
self.logger.error( self.logger.error(
"Refusing to install %s: %s", plugin_id, reason) "Refusing to install %s: %s", plugin_id, reason)
self._note_refusal(requested_id, reason)
self._safe_remove_directory(plugin_path) self._safe_remove_directory(plugin_path)
return False return False
+64 -11
View File
@@ -21,7 +21,7 @@ from src.plugin_system.plugin_dirs import (
PluginDirectoryIndex, resolve_plugin_dir, store_search_dirs, PluginDirectoryIndex, resolve_plugin_dir, store_search_dirs,
) )
from src.plugin_system.store_install import _InstallMixin from src.plugin_system.store_install import _InstallMixin
from src.plugin_system.store_registry import _RegistryMixin from src.plugin_system.store_registry import _RegistryMixin, prefix_hint
from src.plugin_system.store_update import _UpdateMixin from src.plugin_system.store_update import _UpdateMixin
@@ -407,13 +407,20 @@ class PluginStoreManager(_RegistryMixin, _InstallMixin, _UpdateMixin):
alone reported such a plugin as not installed, so update_plugin() alone reported such a plugin as not installed, so update_plugin()
silently did nothing. silently did nothing.
No ``ledmatrix-`` prefix and no case folding here, unlike the loader: When nothing answers to the id itself, the ids the registry proves
a store operation may delete what this returns, so it only accepts a are the same plugin are tried the same way
directory that names the id exactly or declares it. So a registry id (`_installed_id_candidates`): the entry's own id, its ``aliases`` and
such as `stocks` does not resolve to an installed `ledmatrix-stocks/` its ``plugin_path`` name. So the registry id `stocks` finds an
declaring `ledmatrix-stocks` (the monorepo's leaderboard, music, installed `ledmatrix-stocks/` declaring `ledmatrix-stocks` (the
stocks and weather); callers pass the installed id, and monorepo's leaderboard, music, stocks and weather), and uninstalling
update_plugin() maps it back to the registry id itself. by the registry id no longer reports success while leaving the
plugin on disk.
Never ``ledmatrix-<id>`` without that proof -- no registry loaded, or
an entry that doesn't name it: a store operation may delete or
replace what this returns, and an unrelated plugin can own that
folder. Such a folder is only logged, so a person can act on it.
Still no case folding.
Args: Args:
plugin_id: Plugin identifier plugin_id: Plugin identifier
@@ -421,9 +428,55 @@ class PluginStoreManager(_RegistryMixin, _InstallMixin, _UpdateMixin):
Returns: Returns:
Path to plugin directory if found, None otherwise Path to plugin directory if found, None otherwise
""" """
return resolve_plugin_dir( return self._find_with_proof(plugin_id, fetch=False)
plugin_id, self._candidate_plugin_dirs(), prefix=False,
case_insensitive=False) def _find_with_proof(self, plugin_id: str, fetch: bool) -> Optional[Path]:
"""`_find_plugin_path`; with ``fetch``, a ``ledmatrix-<id>`` folder
found while no registry is loaded makes it fetch the registry and
look again, since only the registry can prove the folder is this
plugin. Uninstall passes False (it must work offline); update, which
needs the network anyway, passes True."""
search_dirs = self._candidate_plugin_dirs()
found = self._resolve_installed(plugin_id, search_dirs)
if found is not None:
return found
folder = self._unproven_prefix_folder(plugin_id, search_dirs)
if folder is not None and fetch and not getattr(self, 'registry_cache', None):
try:
self.fetch_registry()
except Exception as e: # noqa: BLE001 - fall through to "not found"
self.logger.debug("Registry fetch while looking for %s failed: %s", plugin_id, e)
found = self._resolve_installed(plugin_id, search_dirs)
if found is not None:
return found
if folder is not None:
self.logger.warning(
"Plugin %s not found. %s may be it, but nothing in the plugin "
"registry says so (no alias), so the store leaves it alone; "
"if it is this plugin, manage it as %s.",
plugin_id, folder, prefix_hint(plugin_id))
return None
@staticmethod
def _unproven_prefix_folder(plugin_id: str, search_dirs: List[Path]) -> Optional[Path]:
"""A ``ledmatrix-<id>`` folder, which the store names but won't touch."""
hint = prefix_hint(plugin_id)
if hint is None:
return None
return resolve_plugin_dir(hint, search_dirs, prefix=False, by_manifest=False)
def _resolve_installed(self, plugin_id: str, search_dirs: List[Path]) -> Optional[Path]:
"""The first of ``plugin_id``'s candidate ids found in ``search_dirs``.
The id itself is looked for in every directory before any alias is,
so an exact install anywhere beats an alias in the configured one.
"""
for candidate in self._installed_id_candidates(plugin_id):
found = resolve_plugin_dir(
candidate, search_dirs, prefix=False, case_insensitive=False)
if found is not None:
return found
return None
def _candidate_plugin_dirs(self) -> List[Path]: def _candidate_plugin_dirs(self) -> List[Path]:
"""Directories that may hold installed plugins, configured one first.""" """Directories that may hold installed plugins, configured one first."""
+150 -1
View File
@@ -13,11 +13,62 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import List, Dict, Optional, Any from typing import List, Dict, Optional, Any
from jsonschema import Draft7Validator, ValidationError from jsonschema import Draft7Validator, ValidationError
from src.plugin_system.plugin_dirs import PLUGIN_DIR_PREFIX
from src.plugin_system.repo_urls import ( from src.plugin_system.repo_urls import (
github_api_headers, github_owner_repo, normalize_repo_url, github_api_headers, github_owner_repo, normalize_repo_url,
) )
# Registry entry fields the plugin monorepo's update_registry.py added after
# 3.7.0. All optional: an older plugins.json has none of them, and every
# reader here treats a missing or malformed one as "not stated".
#
# - ``ledmatrix_min_version``: the floor the plugin's manifest declares, so an
# incompatible install or update is refused before the download
# (`registry_incompatibility`). The post-download gate stays as the fallback.
# - ``aliases``: other ids the plugin goes by (the manifest id when it differs
# from the registry id, e.g. ``ledmatrix-weather`` for ``weather``). With
# ``plugin_path``'s name, the only proof the store accepts that a folder
# under another name is this plugin (`alternate_ids`).
# - ``commit``: the monorepo commit that introduced ``latest_version``.
# Informational only -- installs still come from the branch head.
def declared_aliases(entry: Dict[str, Any]) -> Optional[List[str]]:
"""The entry's ``aliases``, or None when it carries no such list."""
aliases = entry.get('aliases')
if not isinstance(aliases, list):
return None
own = entry.get('id')
return [a for a in aliases if isinstance(a, str) and a and a != own]
def alternate_ids(entry: Dict[str, Any]) -> List[str]:
"""Ids other than the registry id that the registry *proves* an installed
copy may carry: the entry's ``aliases``, then its ``plugin_path``
directory name (all an older registry has).
Never ``ledmatrix-<id>`` on its own say-so. Store operations delete and
replace what these ids resolve to, and an unrelated plugin can live in a
folder of that name (owner decision on #686). A guess is only a hint:
see `prefix_hint`.
"""
own = entry.get('id')
ids: List[str] = list(declared_aliases(entry) or [])
path = entry.get('plugin_path')
if isinstance(path, str) and path.strip('/'):
ids.append(path.rstrip('/').rsplit('/', 1)[-1])
return [g for i, g in enumerate(ids) if g and g != own and g not in ids[:i]]
def prefix_hint(plugin_id: Any) -> Optional[str]:
"""``ledmatrix-<id>``: the legacy folder name worth *mentioning* when
``plugin_id`` is not found -- never one to act on without registry proof."""
if isinstance(plugin_id, str) and plugin_id and not plugin_id.startswith(PLUGIN_DIR_PREFIX):
return PLUGIN_DIR_PREFIX + plugin_id
return None
class _RegistryMixin: class _RegistryMixin:
"""PluginStoreManager methods: see the module docstring.""" """PluginStoreManager methods: see the module docstring."""
@@ -821,19 +872,117 @@ class _RegistryMixin:
Matching ``plugin_path`` fixes it without renaming any published id, Matching ``plugin_path`` fixes it without renaming any published id,
which would orphan ``plugin_state.json`` entries keyed on the old ones. which would orphan ``plugin_state.json`` entries keyed on the old ones.
Exact id always wins, so an entry whose *path* happens to collide with Exact id always wins, so an entry whose *path* happens to collide with
another entry's id cannot shadow it. another entry's id cannot shadow it. An entry's ``aliases`` (registries
from after 3.7.0) come next, then ``plugin_path``, which is what an
older registry has to go on.
""" """
if not plugin_id: if not plugin_id:
return None return None
exact = next((p for p in plugins if p.get('id') == plugin_id), None) exact = next((p for p in plugins if p.get('id') == plugin_id), None)
if exact is not None: if exact is not None:
return exact return exact
for entry in plugins:
if plugin_id in (declared_aliases(entry) or ()):
return entry
for entry in plugins: for entry in plugins:
path = (entry.get('plugin_path') or '').rstrip('/') path = (entry.get('plugin_path') or '').rstrip('/')
if path and path.rsplit('/', 1)[-1] == plugin_id: if path and path.rsplit('/', 1)[-1] == plugin_id:
return entry return entry
return None return None
def registry_incompatibility(self, plugin_id: str,
entry: Optional[Dict[str, Any]] = None) -> Optional[str]:
"""Why the registry says this core cannot run the plugin's latest
release, or None when it says nothing against it.
Reads the entry's ``ledmatrix_min_version`` and asks
``compatibility.check`` -- the same function, and so the same wording
and the same leniency (an untrustworthy or unparseable core version
allows), as the gate that runs on the downloaded manifest. That gate
stays: it also sees ``compatible_versions``, and an older registry
without the field says nothing here.
``entry`` defaults to the registry entry for ``plugin_id``. Any
failure to read the registry answers None: the pre-check exists to
refuse early on evidence, never to block on a guess.
"""
if entry is None:
try:
entry = self.get_registry_info(plugin_id)
except Exception as e: # noqa: BLE001 - never block an install on this
self.logger.debug("Registry lookup for %s failed: %s", plugin_id, e)
return None
if not isinstance(entry, dict):
return None
floor = entry.get('ledmatrix_min_version')
if not isinstance(floor, str) or not floor.strip():
return None
from src.plugin_system import compatibility
compatible, reason = compatibility.check(
{'id': entry.get('id') or plugin_id, 'name': entry.get('name'),
'min_ledmatrix_version': floor.strip()},
compatibility.current_core_version())
return None if compatible else reason
def _refuse_if_registry_incompatible(self, plugin_id: str, entry: Optional[Dict[str, Any]],
action: str, record_as: Optional[str] = None) -> bool:
"""Log and record a registry-based refusal; True when refused.
``record_as`` is the id the caller will ask `pop_refusal` about (the
id it was handed, which may be an alias of ``plugin_id``).
"""
reason = self.registry_incompatibility(plugin_id, entry)
if reason is None:
return False
self.logger.error("Refusing to %s %s before downloading it: %s",
action, plugin_id, reason)
self._note_refusal(record_as or plugin_id, reason)
return True
def _note_refusal(self, plugin_id: str, reason: str) -> None:
"""Remember why an install or update of ``plugin_id`` was refused, so
the web route can say so instead of "check logs for details"."""
refusals = self.__dict__.setdefault('_refusals', {})
refusals[plugin_id] = reason
def pop_refusal(self, *plugin_ids: str) -> Optional[str]:
"""The compatibility refusal recorded for any of ``plugin_ids`` since
the last call, clearing them all; None when there was none."""
refusals = self.__dict__.get('_refusals') or {}
found = None
for plugin_id in plugin_ids:
reason = refusals.pop(plugin_id, None)
if found is None and reason:
found = reason
return found
def _installed_id_candidates(self, plugin_id: str) -> List[str]:
"""``plugin_id`` and the other ids the registry proves its installed
copy may carry.
From the registry already in memory -- no fetch, because uninstall
and the update lookup must work offline. With an entry: its id and
`alternate_ids` (``aliases``, ``plugin_path`` name). Without one (no
registry loaded yet, or a plugin that isn't in it): the id alone.
A folder whose manifest declares one of these ids is found by the
resolver's manifest pass whatever it is called.
"""
ids: List[str] = [plugin_id]
cache = getattr(self, 'registry_cache', None)
plugins = cache.get('plugins') if isinstance(cache, dict) else None
entry = None
if isinstance(plugins, list) and isinstance(plugin_id, str):
entry = self._match_registry_entry(
[p for p in plugins if isinstance(p, dict)], plugin_id)
if entry is not None:
ids.append(entry.get('id'))
ids.extend(alternate_ids(entry))
unique: List[str] = []
for candidate in ids:
if isinstance(candidate, str) and candidate and candidate not in unique:
unique.append(candidate)
return unique
def get_registry_info(self, plugin_id: str) -> Optional[Dict]: def get_registry_info(self, plugin_id: str) -> Optional[Dict]:
""" """
Get plugin information from the registry cache only (no GitHub API calls). Get plugin information from the registry cache only (no GitHub API calls).
+29 -5
View File
@@ -195,10 +195,12 @@ class _UpdateMixin:
surfaces as one line in the journal and a scoreboard that silently surfaces as one line in the journal and a scoreboard that silently
stopped appearing. stopped appearing.
Checked after the pull rather than before it, for the same reason The registry's ``ledmatrix_min_version`` refuses most of these before
``_install_plugin_impl`` checks after the download: the registry the pull (``update_plugin``). This is the fallback, for the same cases
carries no compatibility field, so the incoming floor is only knowable ``_install_plugin_impl``'s post-download gate covers: a registry
once the new commit is on disk. without the field, a checkout on another branch than the registry's,
and ``compatible_versions`` -- all only knowable once the new commit
is on disk.
Undone with ``git reset --hard`` rather than by removing the directory. Undone with ``git reset --hard`` rather than by removing the directory.
This is a live checkout, the previous commit is still in the object This is a live checkout, the previous commit is still in the object
@@ -233,6 +235,7 @@ class _UpdateMixin:
return True return True
self.logger.error("Refusing the update to %s: %s", plugin_id, reason) self.logger.error("Refusing the update to %s: %s", plugin_id, reason)
self._note_refusal(plugin_id, reason)
if not previous_sha: if not previous_sha:
self.logger.error( self.logger.error(
@@ -310,7 +313,9 @@ class _UpdateMixin:
""" """
Update a plugin to the latest commit on its upstream branch. Update a plugin to the latest commit on its upstream branch.
""" """
plugin_path = self._find_plugin_path(plugin_id) # fetch=True: an update needs the registry anyway, and only it can
# prove a ledmatrix-<id>/ folder is this plugin.
plugin_path = self._find_with_proof(plugin_id, fetch=True)
if plugin_path is None or not plugin_path.exists(): if plugin_path is None or not plugin_path.exists():
self.logger.error(f"Plugin not installed: {plugin_id}") self.logger.error(f"Plugin not installed: {plugin_id}")
@@ -368,6 +373,11 @@ class _UpdateMixin:
f"Plugin {resolved_id} git remote ({local_remote}) differs from registry ({registry_repo}). " f"Plugin {resolved_id} git remote ({local_remote}) differs from registry ({registry_repo}). "
f"Reinstalling from registry to migrate to new source." f"Reinstalling from registry to migrate to new source."
) )
# Before the old copy is moved aside: the reinstall
# would only refuse after a download and a restore.
if self._refuse_if_registry_incompatible(
resolved_id, plugin_info_remote, "update", record_as=plugin_id):
return False
return self._reinstall_with_rollback(resolved_id, plugin_path) return self._reinstall_with_rollback(resolved_id, plugin_path)
# Check if already up to date # Check if already up to date
@@ -375,6 +385,14 @@ class _UpdateMixin:
self.logger.info(f"Plugin {plugin_id} already matches remote commit {remote_sha[:7]}") self.logger.info(f"Plugin {plugin_id} already matches remote commit {remote_sha[:7]}")
return True return True
# The registry's floor describes its branch; a checkout
# on another branch pulls another release, and the gate
# after the pull (_gate_pulled_commit) still covers it.
if (not remote_branch or remote_branch == local_branch) and \
self._refuse_if_registry_incompatible(
resolved_id, plugin_info_remote, "update", record_as=plugin_id):
return False
# Update via git pull # Update via git pull
self.logger.info(f"Updating {plugin_id} via git pull (local branch: {local_branch})...") self.logger.info(f"Updating {plugin_id} via git pull (local branch: {local_branch})...")
try: try:
@@ -718,6 +736,12 @@ class _UpdateMixin:
except Exception as e: except Exception as e:
self.logger.debug(f"Could not compare versions for {plugin_id}: {e}") self.logger.debug(f"Could not compare versions for {plugin_id}: {e}")
# A newer version this core cannot run: refuse now, while the
# installed copy is untouched, rather than after a download.
if self._refuse_if_registry_incompatible(
registry_id, plugin_info_remote, "update", record_as=plugin_id):
return False
# Plugin is not a git repo but is in registry and has a newer version - reinstall # Plugin is not a git repo but is in registry and has a newer version - reinstall
self.logger.info(f"Plugin {plugin_id} not installed via git; re-installing latest archive (registry id: {registry_id})") self.logger.info(f"Plugin {plugin_id} not installed via git; re-installing latest archive (registry id: {registry_id})")
+1
View File
@@ -50,6 +50,7 @@ server has none.
| `unit/test_style_editor_layout_leaf_columns.js` | no | `columnsFor()` from `widgets/style-editor.js`: a layout-only key whose own value is a leaf (no x/y sub-object, e.g. a `show_logo` toggle) gets a self-keyed column instead of a blank, uneditable row | | `unit/test_style_editor_layout_leaf_columns.js` | no | `columnsFor()` from `widgets/style-editor.js`: a layout-only key whose own value is a leaf (no x/y sub-object, e.g. a `show_logo` toggle) gets a self-keyed column instead of a blank, uneditable row |
| `unit/test_style_editor_layout_leaf_collision.js` | no | `columnsFor()` from `widgets/style-editor.js`: a layout-only leaf key still gets its own column even when its name collides with an unrelated element's style sub-field or another layout axis's sub-field | | `unit/test_style_editor_layout_leaf_collision.js` | no | `columnsFor()` from `widgets/style-editor.js`: a layout-only leaf key still gets its own column even when its name collides with an unrelated element's style sub-field or another layout axis's sub-field |
| `unit/test_inline_handler_escaping.js` | no | The store, saved-repository and custom-registry inline `onclick` handlers and the live `window.updateImageList` from `plugins_manager.js`: a registry id, URL or uploaded file name carrying `'`, `"` or entities adds no attributes and reaches the handler intact, and the store's View button opens only http(s) links | | `unit/test_inline_handler_escaping.js` | no | The store, saved-repository and custom-registry inline `onclick` handlers and the live `window.updateImageList` from `plugins_manager.js`: a registry id, URL or uploaded file name carrying `'`, `"` or entities adds no attributes and reaches the handler intact, and the store's View button opens only http(s) links |
| `unit/test_store_registry_fields.js` | no | The store card's registry fields from `plugins_manager.js`: the commit that introduced the listed version (a hex SHA only, linked to that tree), the "Needs LEDMatrix X+" warning, a card from an older registry without either, and `isStorePluginInstalled` answering to `aliases` |
| `unit/test_plugin_action_delegation.js` | no | The document-level card-action delegation and `handlePluginAction` from `plugins_manager.js`, run with the handler inside an IIFE as in the real file: each action is handled once, a Starlark app uninstall goes to `DELETE /starlark/apps/<id>`, and an uninstall is confirmed once | | `unit/test_plugin_action_delegation.js` | no | The document-level card-action delegation and `handlePluginAction` from `plugins_manager.js`, run with the handler inside an IIFE as in the real file: each action is handled once, a Starlark app uninstall goes to `DELETE /starlark/apps/<id>`, and an uninstall is confirmed once |
| `dom/test_installed_dom.js` | yes | The toolbar in a real DOM: pill/search/sort interaction, the HTMX partial re-swap, and a `getComputedStyle` check that `.filter-pill[data-active]` really matches the emitted markup | | `dom/test_installed_dom.js` | yes | The toolbar in a real DOM: pill/search/sort interaction, the HTMX partial re-swap, and a `getComputedStyle` check that `.filter-pill[data-active]` really matches the emitted markup |
| `dom/test_store_dom.js` | yes | Store pagination, per-page, category, tri-state Installed button, and persistence across a re-boot, against the live registry | | `dom/test_store_dom.js` | yes | Store pagination, per-page, category, tri-state Installed button, and persistence across a re-boot, against the live registry |
+2 -1
View File
@@ -21,7 +21,8 @@ const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js',
'unit/test_style_editor_layout_leaf_columns.js', 'unit/test_style_editor_layout_leaf_columns.js',
'unit/test_style_editor_layout_leaf_collision.js', 'unit/test_style_editor_layout_leaf_collision.js',
'unit/test_update_all.js', 'unit/test_inline_handler_escaping.js', 'unit/test_update_all.js', 'unit/test_inline_handler_escaping.js',
'unit/test_plugin_action_delegation.js', 'unit/test_file_upload_widget.js']; 'unit/test_plugin_action_delegation.js', 'unit/test_file_upload_widget.js',
'unit/test_store_registry_fields.js'];
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js', const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js',
'dom/test_tools_sections.js']; 'dom/test_tools_sections.js'];
+105
View File
@@ -0,0 +1,105 @@
// The store card shows the registry fields added after 3.7.0 -- the commit
// that introduced the listed version and a warning when the plugin needs a
// newer core -- and still renders a card from an older registry that has
// neither. isStorePluginInstalled also answers to an entry's `aliases`.
//
// Rendered with the shipped functions (extracted from plugins_manager.js).
const fs = require('fs');
const path = require('path');
const SRC = fs.readFileSync(
path.resolve(__dirname, '../../../web_interface/static/v3/plugins_manager.js'), 'utf8');
let pass = 0, fail = 0;
const ok = (label, cond, extra) => cond
? (pass++, console.log(' ok ' + label))
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' -> ' + JSON.stringify(extra).slice(0, 400) : '')));
function extract(opener) {
const start = SRC.indexOf(opener);
if (start < 0) { console.error('FAIL: cannot find ' + JSON.stringify(opener)); process.exit(1); }
let depth = 0;
for (let j = SRC.indexOf('{', start); j < SRC.length; j++) {
if (SRC[j] === '{') depth++;
else if (SRC[j] === '}' && --depth === 0) return SRC.slice(start, j + 1);
}
console.error('FAIL: unbalanced braces after ' + opener); process.exit(1);
}
class FakeEl {
constructor() { this.innerHTML = ''; this.value = ''; this.textContent = ''; }
}
class TextEl {
set textContent(v) { this._t = String(v == null ? '' : v); }
get innerHTML() {
return (this._t || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
}
const els = {};
global.document = {
getElementById: id => (els[id] ||= new FakeEl()),
createElement: () => new TextEl(),
};
global.window = global;
require('../led_escape').install(window);
global.pluginLog = () => {};
global.isNewPlugin = () => false;
global.formatDate = () => '';
global.setGridHtmlIfChanged = (container, html) => { container.innerHTML = html; };
global.installedPlugins = [];
// eslint-disable-next-line no-eval
eval([
'function escapeHtml(text) {', 'function escapeAttribute(text) {', 'function jsStringAttr(value) {',
'function isStorePluginInstalled(pluginIdOrPlugin) {', 'function renderPluginStore(plugins) {',
].map(extract).join('\n') + '\nglobal.renderPluginStore = renderPluginStore;'
+ '\nglobal.isStorePluginInstalled = isStorePluginInstalled;');
function render(plugin) {
renderPluginStore([plugin]);
return els['plugin-store-grid'].innerHTML;
}
const SHA = '843588025a81197056f8d96779ccb2be19337ab8';
const base = {
id: 'weather', name: 'Weather', author: 'ChuckBuilds', category: 'weather',
description: 'Forecasts', version: '2.1.0',
repo: 'https://github.com/ChuckBuilds/ledmatrix-plugins', plugin_path: 'plugins/ledmatrix-weather',
};
console.log('\n1. a registry with the new fields');
let html = render({ ...base, commit: SHA, ledmatrix_min_version: '3.7.0', aliases: ['ledmatrix-weather'] });
ok('shows the short commit', html.includes('>8435880<'), html.match(/v2\.1\.0[^\n]*/));
ok('links it to the plugin at that commit',
html.includes(`href="https://github.com/ChuckBuilds/ledmatrix-plugins/tree/${SHA}/plugins/ledmatrix-weather"`));
ok('no compatibility warning when the core is new enough', !html.includes('Needs LEDMatrix'));
console.log('\n2. a plugin this core cannot run');
html = render({ ...base, ledmatrix_min_version: '9.0.0',
incompatible_reason: 'Weather requires LEDMatrix 9.0.0 or newer' });
ok('warns with the floor', html.includes('Needs LEDMatrix 9.0.0+'));
ok('and the reason as its title', html.includes('title="Weather requires LEDMatrix 9.0.0 or newer"'));
console.log('\n3. an older registry: no commit, floor or aliases');
html = render({ ...base });
ok('still renders the card and its version', html.includes('class="plugin-card"') && html.includes('v2.1.0'));
ok('shows no commit and no warning', !html.includes('font-mono') && !html.includes('Needs LEDMatrix'));
console.log('\n4. a commit value that is not a SHA is not rendered');
html = render({ ...base, commit: 'javascript:alert(1)' });
ok('dropped', !html.includes('javascript:') && !html.includes('font-mono'));
html = render({ ...base, commit: SHA, repo: 'javascript:alert(1)' });
ok('without a web repo link it is plain text, not a link',
html.includes('>8435880<') && !html.includes('/tree/'));
console.log('\n5. installed under an alias');
global.installedPlugins = [{ id: 'ledmatrix-weather' }];
ok('aliases count as installed',
isStorePluginInstalled({ id: 'weather', plugin_path: '', aliases: ['ledmatrix-weather'] }));
ok('plugin_path still does (older registry)',
isStorePluginInstalled({ id: 'weather', plugin_path: 'plugins/ledmatrix-weather' }));
ok('a different plugin is not installed', !isStorePluginInstalled({ id: 'stocks', plugin_path: '' }));
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
+5 -2
View File
@@ -11,7 +11,8 @@ Callers:
discovery PluginManager._scan_directory_for_plugins -> plugin_directories discovery PluginManager._scan_directory_for_plugins -> plugin_directories
pm_get PluginManager.get_plugin_directory before discovery has run pm_get PluginManager.get_plugin_directory before discovery has run
loader PluginLoader.find_plugin_directory (no discovery mapping) loader PluginLoader.find_plugin_directory (no discovery mapping)
store PluginStoreManager._find_plugin_path store PluginStoreManager._find_plugin_path (which also tries the ids the
registry proves: aliases, plugin_path name; never a bare prefix)
""" """
import json import json
@@ -144,7 +145,9 @@ TABLE = [
# manifest id != dir name: the manifest finds it; pm_get by prefix # manifest id != dir name: the manifest finds it; pm_get by prefix
("stocks", R + "ledmatrix-stocks", R + "ledmatrix-stocks", ("stocks", R + "ledmatrix-stocks", R + "ledmatrix-stocks",
R + "ledmatrix-stocks", R + "ledmatrix-stocks"), R + "ledmatrix-stocks", R + "ledmatrix-stocks"),
# ledmatrix- prefix: name-only callers with prefix=True; the store has none # ledmatrix- prefix: name-only callers with prefix=True. The store only
# with registry proof (aliases / plugin_path), and none is loaded here;
# see test_store_registry_fields.py
("legacy", None, R + "ledmatrix-legacy", R + "ledmatrix-legacy", None), ("legacy", None, R + "ledmatrix-legacy", R + "ledmatrix-legacy", None),
("ledmatrix-legacy", R + "ledmatrix-legacy", R + "ledmatrix-legacy", ("ledmatrix-legacy", R + "ledmatrix-legacy", R + "ledmatrix-legacy",
R + "ledmatrix-legacy", R + "ledmatrix-legacy"), R + "ledmatrix-legacy", R + "ledmatrix-legacy"),
+388
View File
@@ -0,0 +1,388 @@
"""The registry fields added after 3.7.0: ``ledmatrix_min_version``,
``aliases`` and ``commit``.
- ``ledmatrix_min_version`` lets the store refuse an install or update this
core cannot run *before* downloading anything. The gate on the downloaded
manifest stays as the fallback.
- ``aliases`` (or, for a registry without it, the ``plugin_path`` name and the
``ledmatrix-`` prefix) lets update, uninstall and reinstall find a plugin
installed under its manifest id: registry ``weather`` lives in
``ledmatrix-weather/``. Before this, update by the registry id said "not
installed", uninstall by it reported success and deleted nothing, and a
reinstall backed up ``weather/`` -- which did not exist -- then deleted
``ledmatrix-weather/`` to make room, so a post-download refusal left the user
with no plugin at all.
- ``commit`` is shown in the store; nothing installs from it.
Every field is optional: an older plugins.json must behave exactly as before.
"""
import json
import subprocess
from pathlib import Path
from typing import Any, Dict, List, Optional
from unittest.mock import MagicMock
import pytest
from src.plugin_system import compatibility
from src.plugin_system.store_manager import PluginStoreManager
from src.plugin_system.store_registry import alternate_ids, declared_aliases
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401
REPO = "https://github.com/ChuckBuilds/ledmatrix-plugins"
CORE = "3.7.0"
# Shaped like the real registry before and after the monorepo change.
OLD_WEATHER = {"id": "weather", "name": "Weather", "repo": REPO, "branch": "main",
"plugin_path": "plugins/ledmatrix-weather", "latest_version": "2.0.0"}
NEW_WEATHER = {**OLD_WEATHER, "ledmatrix_min_version": "3.5.0",
"aliases": ["ledmatrix-weather"],
"commit": "843588025a81197056f8d96779ccb2be19337ab8"}
def manifest(version: str, floor: str = "3.0.0", plugin_id: str = "ledmatrix-weather") -> Dict[str, Any]:
return {"id": plugin_id, "name": "Weather", "class_name": "Weather",
"display_modes": ["weather"], "version": version,
"versions": [{"version": version, "ledmatrix_min_version": floor}]}
def write_install(plugins_dir: Path, name: str, content: Dict[str, Any], marker: str) -> Path:
path = plugins_dir / name
path.mkdir(parents=True)
(path / "manifest.json").write_text(json.dumps(content), encoding="utf-8")
(path / "marker.txt").write_text(marker, encoding="utf-8")
return path
def markers(plugins_dir: Path) -> Dict[str, Optional[str]]:
out = {}
for d in sorted(plugins_dir.iterdir()):
m = d / "marker.txt"
out[d.name] = m.read_text(encoding="utf-8") if m.exists() else None
return out
class FakeStore:
"""A PluginStoreManager with the registry and the download faked."""
def __init__(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
entries: List[Dict[str, Any]], downloaded: Dict[str, Any]):
self.plugins_dir = tmp_path / "plugin-repos"
self.plugins_dir.mkdir()
self.store = PluginStoreManager(plugins_dir=str(self.plugins_dir))
self.store.logger = MagicMock()
self.downloads: List[Path] = []
# Tests may replace registry["plugins"]; the fakes read it per call.
self.registry: Dict[str, Any] = {"plugins": entries}
store = self.store
def fetch_registry(*_a, **_k):
store.registry_cache = self.registry
return self.registry
def get_plugin_info(pid, **_k):
entry = store._match_registry_entry(fetch_registry()["plugins"], pid)
return dict(entry) if entry else None
monkeypatch.setattr(store, "fetch_registry", fetch_registry)
monkeypatch.setattr(store, "get_plugin_info", get_plugin_info)
monkeypatch.setattr(store, "_install_dependencies", lambda _p: True)
def fake_download(_url, _subpath, target: Path) -> bool:
self.downloads.append(target)
target.mkdir(parents=True, exist_ok=True)
(target / "manifest.json").write_text(json.dumps(downloaded), encoding="utf-8")
(target / "marker.txt").write_text("new", encoding="utf-8")
return True
monkeypatch.setattr(store, "_install_from_monorepo", fake_download)
monkeypatch.setattr(compatibility, "current_core_version", lambda: CORE)
@pytest.fixture
def make(tmp_path, monkeypatch):
def _make(entries, downloaded=None):
return FakeStore(tmp_path, monkeypatch, entries, downloaded or manifest("2.0.0"))
return _make
class TestAliasHelpers:
def test_declared_aliases_distinguishes_absent_from_empty(self):
assert declared_aliases(OLD_WEATHER) is None
assert declared_aliases({**OLD_WEATHER, "aliases": []}) == []
assert declared_aliases(NEW_WEATHER) == ["ledmatrix-weather"]
def test_declared_aliases_drops_junk_and_the_own_id(self):
assert declared_aliases({"id": "a", "aliases": ["a", "", 3, None, "b"]}) == ["b"]
assert declared_aliases({"id": "a", "aliases": "b"}) is None
def test_only_registry_proof_counts(self):
"""aliases and the plugin_path name; never a bare ledmatrix-<id>."""
assert alternate_ids(OLD_WEATHER) == ["ledmatrix-weather"]
assert alternate_ids(NEW_WEATHER) == ["ledmatrix-weather"]
assert alternate_ids({"id": "clock", "plugin_path": "plugins/clock-simple"}) == ["clock-simple"]
assert alternate_ids({"id": "ext", "plugin_path": ""}) == []
assert alternate_ids({"id": "foo", "plugin_path": "plugins/foo", "aliases": []}) == []
assert alternate_ids({"id": "m", "plugin_path": "plugins/m", "aliases": ["x"]}) == ["x"]
class TestRegistryLookup:
def test_alias_resolves_an_entry_whose_path_does_not_say_so(self):
entry = {"id": "music", "plugin_path": "plugins/music", "aliases": ["ledmatrix-music"]}
assert PluginStoreManager._match_registry_entry([entry], "ledmatrix-music") is entry
def test_exact_id_still_beats_an_alias(self):
decoy = {"id": "decoy", "aliases": ["weather"]}
real = {"id": "weather"}
assert PluginStoreManager._match_registry_entry([decoy, real], "weather") is real
class TestFindInstalledPlugin:
def test_registry_id_finds_the_manifest_id_install(self, make):
fake = make([NEW_WEATHER])
path = write_install(fake.plugins_dir, "ledmatrix-weather", manifest("1.0.0"), "old")
fake.store.fetch_registry()
assert fake.store._find_plugin_path("weather") == path
def test_old_registry_still_finds_it(self, make):
fake = make([OLD_WEATHER])
path = write_install(fake.plugins_dir, "ledmatrix-weather", manifest("1.0.0"), "old")
fake.store.fetch_registry()
assert fake.store._find_plugin_path("weather") == path
def test_no_registry_loaded_is_no_proof(self, make):
"""Offline, a ledmatrix-weather/ folder is only named in the log."""
fake = make([])
path = write_install(fake.plugins_dir, "ledmatrix-weather", manifest("1.0.0"), "old")
assert fake.store.registry_cache is None
assert fake.store._find_plugin_path("weather") is None
hint = " ".join(str(a) for c in fake.store.logger.warning.call_args_list for a in c.args)
assert str(path) in hint and "ledmatrix-weather" in hint
def test_a_folder_whose_manifest_declares_the_id_needs_no_proof(self, make):
fake = make([])
path = write_install(fake.plugins_dir, "ledmatrix-weather",
manifest("1.0.0", plugin_id="weather"), "old")
assert fake.store._find_plugin_path("weather") == path
def test_an_empty_aliases_list_means_no_guessing(self, make):
fake = make([{"id": "foo", "repo": REPO, "plugin_path": "plugins/foo", "aliases": []}])
write_install(fake.plugins_dir, "ledmatrix-foo", manifest("1.0.0", plugin_id="ledmatrix-foo"), "x")
fake.store.fetch_registry()
assert fake.store._find_plugin_path("foo") is None
def test_the_exact_install_wins_over_an_alias(self, make):
fake = make([NEW_WEATHER])
exact = write_install(fake.plugins_dir, "weather", manifest("1.0.0", plugin_id="weather"), "a")
write_install(fake.plugins_dir, "ledmatrix-weather", manifest("1.0.0"), "b")
fake.store.fetch_registry()
assert fake.store._find_plugin_path("weather") == exact
def test_uninstall_by_registry_id_removes_the_install(self, make):
fake = make([NEW_WEATHER])
write_install(fake.plugins_dir, "ledmatrix-weather", manifest("1.0.0"), "old")
fake.store.fetch_registry()
assert fake.store.uninstall_plugin("weather") is True
assert markers(fake.plugins_dir) == {}
# An unrelated plugin that happens to live in ledmatrix-foo/ (its manifest id
# is ledmatrix-foo) and that no registry entry names. Owner decision on #686:
# without registry proof the store must never replace or remove it.
FOO = {"id": "foo", "name": "Foo", "repo": REPO, "branch": "main", "plugin_path": "plugins/foo",
"latest_version": "2.0.0"}
class TestUnprovenPrefixFolderIsLeftAlone:
@pytest.fixture(params=["old registry", "empty aliases", "no registry"])
def fake(self, request, make):
entries = {"old registry": [FOO], "empty aliases": [{**FOO, "aliases": []}],
"no registry": []}[request.param]
fake = make(entries, downloaded=manifest("2.0.0", plugin_id="foo"))
write_install(fake.plugins_dir, "ledmatrix-foo",
manifest("1.0.0", plugin_id="ledmatrix-foo"), "unrelated")
return fake
def test_uninstall_foo_leaves_it(self, fake):
if fake.registry["plugins"]:
fake.store.fetch_registry()
assert fake.store.uninstall_plugin("foo") is True # "already uninstalled"
assert markers(fake.plugins_dir) == {"ledmatrix-foo": "unrelated"}
def test_install_foo_does_not_replace_it(self, fake):
if fake.registry["plugins"]:
fake.store.fetch_registry()
else:
# Nothing loaded when install_plugin looks for a copy to protect;
# the entry to install from is fetched afterwards.
fake.registry["plugins"] = [FOO]
assert fake.store.install_plugin("foo") is True
assert markers(fake.plugins_dir) == {"foo": "new", "ledmatrix-foo": "unrelated"}
def test_update_foo_finds_nothing_to_update(self, fake):
if fake.registry["plugins"]:
fake.store.fetch_registry()
assert fake.store.update_plugin("foo") is False
assert markers(fake.plugins_dir) == {"ledmatrix-foo": "unrelated"}
assert fake.downloads == []
class TestInstall:
def test_registry_floor_refuses_before_downloading(self, make):
fake = make([{**NEW_WEATHER, "ledmatrix_min_version": "9.0.0"}])
write_install(fake.plugins_dir, "ledmatrix-weather", manifest("1.0.0"), "old")
assert fake.store.install_plugin("weather") is False
assert fake.downloads == [], "nothing may be downloaded for a refused install"
assert markers(fake.plugins_dir) == {"ledmatrix-weather": "old"}
reason = fake.store.pop_refusal("weather")
assert reason and "requires LEDMatrix 9.0.0 or newer" in reason and CORE in reason
assert fake.store.pop_refusal("weather") is None, "a refusal is reported once"
def test_a_compatible_floor_installs(self, make):
fake = make([NEW_WEATHER])
assert fake.store.install_plugin("weather") is True
assert markers(fake.plugins_dir) == {"ledmatrix-weather": "new"}
def test_old_registry_downloads_and_the_manifest_gate_still_refuses(self, make):
"""No field means no early answer: the post-download gate decides,
and the copy it would have replaced -- found under its alias -- is put
back. This used to delete ledmatrix-weather/ outright."""
fake = make([OLD_WEATHER], downloaded=manifest("2.0.0", floor="9.0.0"))
write_install(fake.plugins_dir, "ledmatrix-weather", manifest("1.0.0"), "old")
assert fake.store.install_plugin("weather") is False
assert len(fake.downloads) == 1
assert markers(fake.plugins_dir) == {"ledmatrix-weather": "old"}
assert "9.0.0" in (fake.store.pop_refusal("weather") or "")
def test_old_registry_reinstall_over_an_alias_succeeds(self, make):
fake = make([OLD_WEATHER])
write_install(fake.plugins_dir, "ledmatrix-weather", manifest("1.0.0"), "old")
assert fake.store.install_plugin("weather") is True
assert markers(fake.plugins_dir) == {"ledmatrix-weather": "new"}
def test_another_branch_skips_the_registry_floor(self, make):
"""The floor describes the registry's branch; a named other branch is
another release, left to the post-download gate."""
fake = make([{**NEW_WEATHER, "ledmatrix_min_version": "9.0.0"}])
assert fake.store.install_plugin("weather", branch="dev") is True
assert len(fake.downloads) == 1
@pytest.mark.parametrize("floor", [None, "", 3, ["9.0.0"]])
def test_a_missing_or_malformed_floor_says_nothing(self, make, floor):
entry = {**NEW_WEATHER, "ledmatrix_min_version": floor}
assert make([entry]).store.registry_incompatibility("weather", entry) is None
def test_an_untrustworthy_core_version_is_not_refused(self, make, monkeypatch):
"""Same leniency as the manifest gate: a core reporting 1.0.0 (the
v3.1.0 release) is unknown, not old -- unless the floor is above 2.0.0,
which that release cannot meet either."""
entry = {**NEW_WEATHER, "ledmatrix_min_version": "2.0.0"}
store = make([entry]).store
monkeypatch.setattr(compatibility, "current_core_version", lambda: "1.0.0")
assert store.registry_incompatibility("weather", entry) is None
class TestUpdate:
def test_update_by_registry_id_finds_and_updates_the_install(self, make):
fake = make([NEW_WEATHER])
write_install(fake.plugins_dir, "ledmatrix-weather", manifest("1.0.0"), "old")
assert fake.store.update_plugin("weather") is True
assert markers(fake.plugins_dir) == {"ledmatrix-weather": "new"}
def test_update_by_manifest_id_still_works(self, make):
fake = make([NEW_WEATHER])
write_install(fake.plugins_dir, "ledmatrix-weather", manifest("1.0.0"), "old")
assert fake.store.update_plugin("ledmatrix-weather") is True
assert markers(fake.plugins_dir) == {"ledmatrix-weather": "new"}
def test_incompatible_update_is_refused_before_anything_moves(self, make, monkeypatch):
fake = make([{**NEW_WEATHER, "ledmatrix_min_version": "9.0.0"}])
write_install(fake.plugins_dir, "ledmatrix-weather", manifest("1.0.0"), "old")
rollback = MagicMock()
monkeypatch.setattr(fake.store, "_reinstall_with_rollback", rollback)
assert fake.store.update_plugin("ledmatrix-weather") is False
rollback.assert_not_called()
assert fake.downloads == []
assert markers(fake.plugins_dir) == {"ledmatrix-weather": "old"}
assert "9.0.0" in (fake.store.pop_refusal("ledmatrix-weather") or "")
def test_up_to_date_plugin_is_not_refused(self, make):
"""The floor belongs to latest_version; a plugin already on it is
running, so there is nothing to refuse."""
fake = make([{**NEW_WEATHER, "ledmatrix_min_version": "9.0.0"}])
write_install(fake.plugins_dir, "ledmatrix-weather", manifest("2.0.0"), "current")
assert fake.store.update_plugin("weather") is True
assert fake.store.pop_refusal("weather") is None
def test_old_registry_update_falls_back_to_the_manifest_gate(self, make):
fake = make([OLD_WEATHER], downloaded=manifest("2.0.0", floor="9.0.0"))
write_install(fake.plugins_dir, "ledmatrix-weather", manifest("1.0.0"), "old")
assert fake.store.update_plugin("weather") is False
assert len(fake.downloads) == 1
assert markers(fake.plugins_dir) == {"ledmatrix-weather": "old"}
def test_git_checkout_is_not_pulled_when_the_registry_refuses(self, make, monkeypatch):
fake = make([{**NEW_WEATHER, "ledmatrix_min_version": "9.0.0"}])
write_install(fake.plugins_dir, "ledmatrix-weather", manifest("1.0.0"), "old")
monkeypatch.setattr(fake.store, "_get_local_git_info", lambda _p: {
"sha": "a" * 40, "branch": "main", "remote_url": REPO})
calls = []
monkeypatch.setattr(subprocess, "run", lambda *a, **k: calls.append(a) or MagicMock(
returncode=0, stdout="", stderr=""))
assert fake.store.update_plugin("weather") is False
assert not any("pull" in (a[0] if a else []) for a in calls), calls
assert "9.0.0" in (fake.store.pop_refusal("weather") or "")
# -- web routes --------------------------------------------------------------
@pytest.fixture
def web_store(api_v3_module, tmp_path, monkeypatch):
fake = FakeStore(tmp_path, monkeypatch, [], manifest("2.0.0"))
api_v3_module.api_v3.plugin_store_manager = fake.store
api_v3_module.api_v3.operation_queue = None
# No plugin manager: the routes skip discovery and reload, and the update
# route finds the directory through the store alone.
api_v3_module.api_v3.plugin_manager = None
return fake
def test_store_list_carries_the_new_fields(api_v3_client, web_store, monkeypatch):
listing = [
dict(NEW_WEATHER),
{**NEW_WEATHER, "id": "later", "aliases": [], "ledmatrix_min_version": "9.0.0"},
{"id": "old", "name": "Old", "repo": REPO, "plugin_path": "plugins/old"},
]
monkeypatch.setattr(web_store.store, "search_plugins", lambda **_k: listing)
resp = api_v3_client.get("/api/v3/plugins/store/list")
assert resp.status_code == 200
by_id = {p["id"]: p for p in resp.get_json()["data"]["plugins"]}
assert by_id["weather"]["commit"] == NEW_WEATHER["commit"]
assert by_id["weather"]["ledmatrix_min_version"] == "3.5.0"
assert by_id["weather"]["aliases"] == ["ledmatrix-weather"]
assert by_id["weather"]["incompatible_reason"] is None
assert "9.0.0" in by_id["later"]["incompatible_reason"]
# An older registry: every new key present, and empty.
assert (by_id["old"]["commit"], by_id["old"]["ledmatrix_min_version"],
by_id["old"]["aliases"], by_id["old"]["incompatible_reason"]) == (None, None, [], None)
def test_install_route_says_why_it_refused(api_v3_client, web_store):
web_store.registry["plugins"] = [{**NEW_WEATHER, "ledmatrix_min_version": "9.0.0"}]
resp = api_v3_client.post("/api/v3/plugins/install", data=json.dumps({"plugin_id": "weather"}),
content_type="application/json")
assert resp.status_code == 409
message = resp.get_json()["message"]
assert "requires LEDMatrix 9.0.0 or newer" in message
assert web_store.downloads == []
def test_update_route_says_why_it_refused(api_v3_client, web_store):
web_store.registry["plugins"] = [{**NEW_WEATHER, "ledmatrix_min_version": "9.0.0"}]
write_install(web_store.plugins_dir, "ledmatrix-weather", manifest("1.0.0"), "old")
resp = api_v3_client.post("/api/v3/plugins/update", data=json.dumps({"plugin_id": "weather"}),
content_type="application/json")
assert resp.status_code == 409
assert "requires LEDMatrix 9.0.0 or newer" in resp.get_json()["message"]
assert markers(web_store.plugins_dir) == {"ledmatrix-weather": "old"}
@@ -14,6 +14,33 @@ from src.common.path_safety import resolve_under, safe_path_component
from typing import Optional from typing import Optional
def _compatibility_refusal(plugin_id: str) -> Optional[str]:
"""Why the store just refused ``plugin_id`` as incompatible, or None.
The store records the reason under the id it was handed and, for a
reinstall, under the registry id it resolved to; both are cleared here.
"""
store = api_v3.plugin_store_manager
ids = [plugin_id]
try:
entry = store.get_registry_info(plugin_id)
except Exception: # noqa: BLE001 - only used to phrase an error
entry = None
if isinstance(entry, dict) and isinstance(entry.get('id'), str):
ids.append(entry['id'])
reason = store.pop_refusal(*ids)
return reason if isinstance(reason, str) and reason else None
def _store_incompatibility(plugin: dict) -> Optional[str]:
"""The pre-download compatibility verdict for a store listing entry."""
try:
reason = api_v3.plugin_store_manager.registry_incompatibility(plugin.get('id'), plugin)
except Exception: # noqa: BLE001 - a listing must not fail over a hint
return None
return reason if isinstance(reason, str) and reason else None
def _listed_plugin_dir(base: Path, name: str) -> Optional[Path]: def _listed_plugin_dir(base: Path, name: str) -> Optional[Path]:
"""The entry of ``base`` called ``name``, or None. """The entry of ``base`` called ``name``, or None.
@@ -145,7 +172,9 @@ def update_plugin():
remote_commit = remote_info.get('last_commit_sha') if remote_info else None remote_commit = remote_info.get('last_commit_sha') if remote_info else None
remote_branch = remote_info.get('branch') if remote_info else None remote_branch = remote_info.get('branch') if remote_info else None
# Update the plugin # Update the plugin. A refusal left over from an earlier attempt
# (say, the automatic updater's) must not explain this one.
_compatibility_refusal(plugin_id)
success = api_v3.plugin_store_manager.update_plugin(plugin_id) success = api_v3.plugin_store_manager.update_plugin(plugin_id)
if success: if success:
@@ -235,7 +264,11 @@ def update_plugin():
message=message message=message
) )
else: else:
if plugin_dir is None or not plugin_dir.exists(): refusal = _compatibility_refusal(plugin_id)
if refusal:
# The plugin is untouched; say why rather than "check logs".
client_msg = f'Plugin update refused: {refusal}'
elif plugin_dir is None or not plugin_dir.exists():
client_msg = 'Plugin update failed: plugin not found' client_msg = 'Plugin update failed: plugin not found'
else: else:
git_info = api_v3.plugin_store_manager._get_local_git_info(plugin_dir) git_info = api_v3.plugin_store_manager._get_local_git_info(plugin_dir)
@@ -264,7 +297,8 @@ def update_plugin():
return error_response( return error_response(
ErrorCode.PLUGIN_UPDATE_FAILED, ErrorCode.PLUGIN_UPDATE_FAILED,
client_msg, client_msg,
status_code=500 # A refusal is the plugin's requirement, not a server fault.
status_code=409 if refusal else 500
) )
except Exception as e: except Exception as e:
@@ -406,6 +440,7 @@ def install_plugin():
if api_v3.operation_queue: if api_v3.operation_queue:
def install_callback(operation): def install_callback(operation):
"""Callback to execute plugin installation.""" """Callback to execute plugin installation."""
_compatibility_refusal(plugin_id) # clear any stale one
success = api_v3.plugin_store_manager.install_plugin(plugin_id, branch=branch) success = api_v3.plugin_store_manager.install_plugin(plugin_id, branch=branch)
if success: if success:
@@ -438,8 +473,10 @@ def install_plugin():
error_msg = f'Failed to install plugin {plugin_id}' error_msg = f'Failed to install plugin {plugin_id}'
if branch: if branch:
error_msg += f' (branch: {branch})' error_msg += f' (branch: {branch})'
plugin_info = api_v3.plugin_store_manager.get_plugin_info(plugin_id) refusal = _compatibility_refusal(plugin_id)
if not plugin_info: if refusal:
error_msg += f': {refusal}'
elif not api_v3.plugin_store_manager.get_plugin_info(plugin_id):
error_msg += ' (plugin not found in registry)' error_msg += ' (plugin not found in registry)'
# Record failure in history # Record failure in history
@@ -468,6 +505,7 @@ def install_plugin():
) )
else: else:
# Fallback to direct installation # Fallback to direct installation
_compatibility_refusal(plugin_id) # clear any stale one
success = api_v3.plugin_store_manager.install_plugin(plugin_id, branch=branch) success = api_v3.plugin_store_manager.install_plugin(plugin_id, branch=branch)
if success: if success:
@@ -493,8 +531,10 @@ def install_plugin():
error_msg = f'Failed to install plugin {plugin_id}' error_msg = f'Failed to install plugin {plugin_id}'
if branch: if branch:
error_msg += f' (branch: {branch})' error_msg += f' (branch: {branch})'
plugin_info = api_v3.plugin_store_manager.get_plugin_info(plugin_id) refusal = _compatibility_refusal(plugin_id)
if not plugin_info: if refusal:
error_msg += f': {refusal}'
elif not api_v3.plugin_store_manager.get_plugin_info(plugin_id):
error_msg += ' (plugin not found in registry)' error_msg += ' (plugin not found in registry)'
if api_v3.operation_history: if api_v3.operation_history:
@@ -509,7 +549,7 @@ def install_plugin():
return error_response( return error_response(
ErrorCode.PLUGIN_INSTALL_FAILED, ErrorCode.PLUGIN_INSTALL_FAILED,
error_msg, error_msg,
status_code=500 status_code=409 if refusal else 500
) )
@@ -719,7 +759,17 @@ def list_plugin_store():
'version': plugin.get('latest_version') or plugin.get('version', ''), 'version': plugin.get('latest_version') or plugin.get('version', ''),
'branch': plugin.get('branch') or plugin.get('default_branch'), 'branch': plugin.get('branch') or plugin.get('default_branch'),
'default_branch': plugin.get('default_branch'), 'default_branch': plugin.get('default_branch'),
'plugin_path': plugin.get('plugin_path', '') 'plugin_path': plugin.get('plugin_path', ''),
# Registry fields from after 3.7.0; absent (None) in an older
# plugins.json. `commit` is the one that introduced `version`.
'commit': plugin.get('commit') if isinstance(plugin.get('commit'), str) else None,
'ledmatrix_min_version': (plugin.get('ledmatrix_min_version')
if isinstance(plugin.get('ledmatrix_min_version'), str) else None),
'aliases': [a for a in plugin.get('aliases') or [] if isinstance(a, str)]
if isinstance(plugin.get('aliases'), list) else [],
# What Install would answer, without trying: the same check the
# store runs before downloading.
'incompatible_reason': _store_incompatibility(plugin),
}) })
return jsonify({'status': 'success', 'data': {'plugins': formatted_plugins}}) return jsonify({'status': 'success', 'data': {'plugins': formatted_plugins}})
+16 -2
View File
@@ -2267,7 +2267,10 @@ function isStorePluginInstalled(pluginIdOrPlugin) {
// Derive the actual installed directory name from plugin_path (e.g. "plugins/ledmatrix-weather" → "ledmatrix-weather") // Derive the actual installed directory name from plugin_path (e.g. "plugins/ledmatrix-weather" → "ledmatrix-weather")
const pluginPath = pluginIdOrPlugin.plugin_path || ''; const pluginPath = pluginIdOrPlugin.plugin_path || '';
const pathDerivedId = pluginPath ? pluginPath.split('/').pop() : null; const pathDerivedId = pluginPath ? pluginPath.split('/').pop() : null;
return installed.some(p => p.id === storeId || (pathDerivedId && p.id === pathDerivedId)); // Newer registries also list the other ids outright (the manifest id).
const aliases = Array.isArray(pluginIdOrPlugin.aliases) ? pluginIdOrPlugin.aliases : [];
return installed.some(p => p.id === storeId || (pathDerivedId && p.id === pathDerivedId)
|| aliases.includes(p.id));
} }
// ── Plugin Store: search / filter / sort ──────────────────────────────── // ── Plugin Store: search / filter / sort ────────────────────────────────
@@ -2425,6 +2428,16 @@ function renderPluginStore(plugins) {
const installed = isStorePluginInstalled(plugin); const installed = isStorePluginInstalled(plugin);
// Registry data: only open real web links, never javascript: URLs. // Registry data: only open real web links, never javascript: URLs.
const repoLink = plugin.repo && /^https?:\/\//i.test(plugin.repo) ? plugin.repo : ''; const repoLink = plugin.repo && /^https?:\/\//i.test(plugin.repo) ? plugin.repo : '';
// The commit that introduced this version (newer registries only).
// Checked as a hex SHA before it goes anywhere near a URL.
const commit = typeof plugin.commit === 'string' && /^[0-9a-f]{7,40}$/i.test(plugin.commit) ? plugin.commit : '';
const commitUrl = commit && repoLink
? repoLink.replace(/\/+$/, '').replace(/\.git$/, '') + '/tree/' + commit
+ (plugin.plugin_path ? '/' + plugin.plugin_path.split('/').map(encodeURIComponent).join('/') : '')
: '';
const commitHtml = !commit ? '' : (commitUrl
? `<a href="${escapeAttribute(commitUrl)}" target="_blank" rel="noopener noreferrer" class="text-xs font-mono text-gray-500 hover:underline" title="Source of this version: commit ${escapeAttribute(commit)}">${escapeHtml(commit.slice(0, 7))}</a>`
: `<span class="text-xs font-mono text-gray-500" title="Commit ${escapeAttribute(commit)}">${escapeHtml(commit.slice(0, 7))}</span>`);
return ` return `
<div class="plugin-card"> <div class="plugin-card">
<div class="flex items-start justify-between mb-4"> <div class="flex items-start justify-between mb-4">
@@ -2435,10 +2448,11 @@ function renderPluginStore(plugins) {
${installed ? '<span class="badge badge-success"><i class="fas fa-check mr-1"></i>Installed</span>' : ''} ${installed ? '<span class="badge badge-success"><i class="fas fa-check mr-1"></i>Installed</span>' : ''}
${isNewPlugin(plugin.last_updated) ? '<span class="badge badge-info"><i class="fas fa-sparkles mr-1"></i>New</span>' : ''} ${isNewPlugin(plugin.last_updated) ? '<span class="badge badge-info"><i class="fas fa-sparkles mr-1"></i>New</span>' : ''}
${plugin._source === 'custom_repository' ? `<span class="badge badge-accent" title="From: ${escapeHtml(plugin._repository_name || plugin._repository_url || 'Custom Repository')}"><i class="fas fa-bookmark mr-1"></i>Custom</span>` : ''} ${plugin._source === 'custom_repository' ? `<span class="badge badge-accent" title="From: ${escapeHtml(plugin._repository_name || plugin._repository_url || 'Custom Repository')}"><i class="fas fa-bookmark mr-1"></i>Custom</span>` : ''}
${plugin.incompatible_reason ? `<span class="badge badge-warning" title="${escapeAttribute(plugin.incompatible_reason)}"><i class="fas fa-exclamation-triangle mr-1"></i>Needs LEDMatrix ${escapeHtml(plugin.ledmatrix_min_version || 'update')}+</span>` : ''}
</div> </div>
<div class="text-sm text-gray-600 space-y-1.5 mb-3"> <div class="text-sm text-gray-600 space-y-1.5 mb-3">
<p class="flex items-center"><i class="fas fa-user mr-2 text-gray-400 w-4"></i>${escapeHtml(plugin.author || 'Unknown')}</p> <p class="flex items-center"><i class="fas fa-user mr-2 text-gray-400 w-4"></i>${escapeHtml(plugin.author || 'Unknown')}</p>
${plugin.version ? `<p class="flex items-center"><i class="fas fa-tag mr-2 text-gray-400 w-4"></i>v${escapeHtml(plugin.version)}</p>` : ''} ${plugin.version ? `<p class="flex items-center flex-wrap gap-1.5"><i class="fas fa-tag mr-2 text-gray-400 w-4"></i>v${escapeHtml(plugin.version)}${commitHtml}</p>` : ''}
<p class="flex items-center"><i class="fas fa-folder mr-2 text-gray-400 w-4"></i>${escapeHtml(plugin.category || 'General')}</p> <p class="flex items-center"><i class="fas fa-folder mr-2 text-gray-400 w-4"></i>${escapeHtml(plugin.category || 'General')}</p>
</div> </div>
<p class="text-sm text-gray-700 leading-relaxed">${escapeHtml(plugin.description || 'No description available')}</p> <p class="text-sm text-gray-700 leading-relaxed">${escapeHtml(plugin.description || 'No description available')}</p>