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
+44 -4
View File
@@ -63,7 +63,14 @@ class _InstallMixin:
return False
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():
return self._install_plugin_impl(plugin_id, branch)
@@ -91,6 +98,26 @@ class _InstallMixin:
self._restore_backup(plugin_id, plugin_path, backup_path, "Install")
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]:
"""Rename an installed plugin to ``backup_path`` so a failed
(re)install can put it back.
@@ -164,6 +191,16 @@ class _InstallMixin:
self.logger.error(f"Plugin {plugin_id} missing repository URL")
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')
# If branch is provided, prioritize it; otherwise use default logic
branch_candidates = self._distinct_sequence([
@@ -280,9 +317,11 @@ class _InstallMixin:
return False
# Refuse a plugin that needs a newer core than this one. The
# registry carries no compatibility field, so the floor is only
# knowable once the files are down — checking here, before
# dependency installation, is the earliest possible point.
# registry's `ledmatrix_min_version` already refused the
# common case before the download (above); this is the
# 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
# False and _reinstall_with_rollback restores the version they
@@ -299,6 +338,7 @@ class _InstallMixin:
if not compatible:
self.logger.error(
"Refusing to install %s: %s", plugin_id, reason)
self._note_refusal(requested_id, reason)
self._safe_remove_directory(plugin_path)
return False