mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ from src.plugin_system.plugin_dirs import (
|
||||
PluginDirectoryIndex, resolve_plugin_dir, store_search_dirs,
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
@@ -407,13 +407,20 @@ class PluginStoreManager(_RegistryMixin, _InstallMixin, _UpdateMixin):
|
||||
alone reported such a plugin as not installed, so update_plugin()
|
||||
silently did nothing.
|
||||
|
||||
No ``ledmatrix-`` prefix and no case folding here, unlike the loader:
|
||||
a store operation may delete what this returns, so it only accepts a
|
||||
directory that names the id exactly or declares it. So a registry id
|
||||
such as `stocks` does not resolve to an installed `ledmatrix-stocks/`
|
||||
declaring `ledmatrix-stocks` (the monorepo's leaderboard, music,
|
||||
stocks and weather); callers pass the installed id, and
|
||||
update_plugin() maps it back to the registry id itself.
|
||||
When nothing answers to the id itself, the ids the registry proves
|
||||
are the same plugin are tried the same way
|
||||
(`_installed_id_candidates`): the entry's own id, its ``aliases`` and
|
||||
its ``plugin_path`` name. So the registry id `stocks` finds an
|
||||
installed `ledmatrix-stocks/` declaring `ledmatrix-stocks` (the
|
||||
monorepo's leaderboard, music, stocks and weather), and uninstalling
|
||||
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:
|
||||
plugin_id: Plugin identifier
|
||||
@@ -421,9 +428,55 @@ class PluginStoreManager(_RegistryMixin, _InstallMixin, _UpdateMixin):
|
||||
Returns:
|
||||
Path to plugin directory if found, None otherwise
|
||||
"""
|
||||
return resolve_plugin_dir(
|
||||
plugin_id, self._candidate_plugin_dirs(), prefix=False,
|
||||
case_insensitive=False)
|
||||
return self._find_with_proof(plugin_id, fetch=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]:
|
||||
"""Directories that may hold installed plugins, configured one first."""
|
||||
|
||||
@@ -13,11 +13,62 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Any
|
||||
from jsonschema import Draft7Validator, ValidationError
|
||||
from src.plugin_system.plugin_dirs import PLUGIN_DIR_PREFIX
|
||||
from src.plugin_system.repo_urls import (
|
||||
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:
|
||||
"""PluginStoreManager methods: see the module docstring."""
|
||||
|
||||
@@ -821,19 +872,117 @@ class _RegistryMixin:
|
||||
Matching ``plugin_path`` fixes it without renaming any published id,
|
||||
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
|
||||
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:
|
||||
return None
|
||||
exact = next((p for p in plugins if p.get('id') == plugin_id), None)
|
||||
if exact is not None:
|
||||
return exact
|
||||
for entry in plugins:
|
||||
if plugin_id in (declared_aliases(entry) or ()):
|
||||
return entry
|
||||
for entry in plugins:
|
||||
path = (entry.get('plugin_path') or '').rstrip('/')
|
||||
if path and path.rsplit('/', 1)[-1] == plugin_id:
|
||||
return entry
|
||||
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]:
|
||||
"""
|
||||
Get plugin information from the registry cache only (no GitHub API calls).
|
||||
|
||||
@@ -195,10 +195,12 @@ class _UpdateMixin:
|
||||
surfaces as one line in the journal and a scoreboard that silently
|
||||
stopped appearing.
|
||||
|
||||
Checked after the pull rather than before it, for the same reason
|
||||
``_install_plugin_impl`` checks after the download: the registry
|
||||
carries no compatibility field, so the incoming floor is only knowable
|
||||
once the new commit is on disk.
|
||||
The registry's ``ledmatrix_min_version`` refuses most of these before
|
||||
the pull (``update_plugin``). This is the fallback, for the same cases
|
||||
``_install_plugin_impl``'s post-download gate covers: a registry
|
||||
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.
|
||||
This is a live checkout, the previous commit is still in the object
|
||||
@@ -233,6 +235,7 @@ class _UpdateMixin:
|
||||
return True
|
||||
|
||||
self.logger.error("Refusing the update to %s: %s", plugin_id, reason)
|
||||
self._note_refusal(plugin_id, reason)
|
||||
|
||||
if not previous_sha:
|
||||
self.logger.error(
|
||||
@@ -310,8 +313,10 @@ class _UpdateMixin:
|
||||
"""
|
||||
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():
|
||||
self.logger.error(f"Plugin not installed: {plugin_id}")
|
||||
return False
|
||||
@@ -368,6 +373,11 @@ class _UpdateMixin:
|
||||
f"Plugin {resolved_id} git remote ({local_remote}) differs from registry ({registry_repo}). "
|
||||
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)
|
||||
|
||||
# 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]}")
|
||||
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
|
||||
self.logger.info(f"Updating {plugin_id} via git pull (local branch: {local_branch})...")
|
||||
try:
|
||||
@@ -718,6 +736,12 @@ class _UpdateMixin:
|
||||
except Exception as 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
|
||||
self.logger.info(f"Plugin {plugin_id} not installed via git; re-installing latest archive (registry id: {registry_id})")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user