Files
LEDMatrix/web_interface/blueprints/api_v3/plugin_store.py
ChuckandClaude Opus 5.5 e32d177cbd fix(web-ui): Plugin Manager - enable aliased installs, Update All, on-demand modes, long installs, categories, GitHub-URL install (#746)
* fix(web-ui): Update All sends the live installed list and redraws the grid

updateAll() preferred PluginStateManager.installedPlugins over
window.installedPlugins. Only updateAll's own end-of-run refresh ever
fills PluginStateManager, so from the second run on it sent the first
run's plugins: one uninstalled since failed with "plugin not found" and
one installed since was never updated. That refresh also only replaced
window.installedPlugins, so the installed cards and the Updates badge
kept offering "Update to vX" for what had just been updated.

Read window.installedPlugins, the list plugins_manager.js republishes
after every install, uninstall and refresh, keeping PluginStateManager
as the fallback for a page without it, and refresh through
pluginManager.loadInstalledPlugins(true), which redraws the grid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web-ui): list each plugin's display modes in /plugins/installed

The on-demand modal fills its Display Mode select from
plugin.display_modes, but /plugins/installed never sent the field. Every
plugin offered one option, its own id, under "This plugin exposes a
single display mode"; the display resolved that id to the plugin's first
mode, so a multi-mode plugin could only be started, or pinned, there.

Add display_modes to each entry, read from the plugin catalog
(get_plugin_display_modes), the same declared list /display/modes and
on-demand/start use, keeping only strings. Single-mode plugins still get
one option and the same hint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web-ui): enable a store install by its installed id, and not on reinstall

The store's Install button enabled the new plugin by the registry id it
installed. Weather, Music, Stocks and Leaderboard install under the id
their manifests declare (weather -> ledmatrix-weather); the plugin list,
the config section and /plugins/toggle know only that id, so the toggle
answered 404 "Plugin not found" and the plugin stayed disabled behind
"installed, but enabling it failed". The same button on an installed
plugin (Reinstall) enabled it too, switching a plugin the user had
turned off back on.

POST /plugins/install now names the installed plugin: plugin_id in the
direct answer and in the queued operation's result, read from the
installed manifest found the way the store's update and uninstall find
it (_find_plugin_path: id, aliases, plugin_path name), else the
requested id. The client reloads the list, then enables that id; from
an answer without it, the installed entry the store entry matches
(findInstalledStorePlugin, which isStorePluginInstalled now uses). A
reinstall, decided by the same match that labelled the button, reloads
the list and leaves the enabled state alone.

test/js/plugins_manager_sandbox.js runs the whole of
plugins_manager.js in a vm context against a fake DOM and API, for
suites that drive its real flows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web-ui): wait for long store installs; on timeout reload, not fail

pollOperationStatus gave a queued install 60 polls, a second apart,
then reported "Install operation timed out" as an error and stopped.
The server allows the plugin's dependency install 300 s on its own
(install_requirements_file in store_install.py), after a download that
fetches the plugin a file at a time, so installs that went on to
succeed were reported as failed, never enabled, and left out of the
installed list until the page was reloaded.

Give installs INSTALL_POLL_MAX_ATTEMPTS (600, ten minutes). When even
that runs out, reload the installed list and the store badges and warn
that the install may still be running; nothing is enabled without the
operation's answer. Uninstall keeps the default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web-ui): build the store's category filter from the store's plugins

The #plugin-category select listed seven fixed categories while the
registry uses about twenty (productivity, utility, transit, finance,
...), so roughly a third of the store could not be filtered to, and
"Financial" missed the plugin filed under "finance".

The template now ships only "All Categories"; syncStoreCategoryOptions,
run by applyStoreFiltersAndSort, adds one option per category the cached
store plugins have (case folded, as the filter compares), keeps the
current choice, and rebuilds only when the set changes or the partial
was swapped in afresh -- the way the Starlark section builds its own.

The test sandbox gains window.addEventListener (initPluginsPage needs
it) and quiets the script's "element not found" warnings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web-ui): one handler for the GitHub-URL Install button

#install-plugin-from-url had an inline onclick calling
window.handleGitHubPluginInstall, and attachInstallButtonHandler also
gave it a click listener that installs, so both ran on every click
(and on Enter, which clicks it). The inline handler threw a
ReferenceError -- it called isGithubUrl, which is local to the
plugin-manager IIFE, from outside it -- so only the listener's request
went out; correcting that scope alone would have sent every install
twice.

Remove the inline onclick and the window.handleGitHubPluginInstall it
called, which nothing else uses. The listener, which already sent the
only request, is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:18:54 -04:00

893 lines
38 KiB
Python

"""Plugin install, update and uninstall, and the plugin store and saved
repositories.
Routes decorate the shared `api_v3` Blueprint from the package `__init__`,
so their endpoint names do not depend on which module they live in.
"""
from web_interface.blueprints.api_v3 import (
ErrorCode, OperationType, Path, _do_transactional_uninstall,
_non_plugin_id_error, _get_plugin_version, _plugin_directory,
_plugin_enabled_in_config, _reload_after_store_update, _store_restart_fields, api_v3,
error_response, exception_error_response, json, jsonify, logger,
request, success_response, validate_request_json,
)
from src.common.path_safety import resolve_under, safe_path_component
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 _installed_plugin_id(plugin_id: str) -> str:
"""The id the plugin installed for store entry ``plugin_id`` declares.
A registry entry can install under another id: ``weather`` installs a
directory whose manifest says ``ledmatrix-weather``, and that is the id
the plugin list, the config section and /plugins/toggle know it by. The
install is found the way the store's update and uninstall find it (the
entry's id, ``aliases`` and ``plugin_path`` name); ``plugin_id`` itself
when its manifest can't be read.
"""
try:
plugin_dir = api_v3.plugin_store_manager._find_plugin_path(plugin_id)
manifest_path = (resolve_under(plugin_dir, 'manifest.json')
if isinstance(plugin_dir, Path) else None)
if manifest_path is None or not manifest_path.is_file():
return plugin_id
with open(manifest_path, 'r', encoding='utf-8') as f:
manifest_id = json.load(f).get('id')
except Exception: # noqa: BLE001 - only names the install for the client
return plugin_id
return manifest_id if isinstance(manifest_id, str) and safe_path_component(manifest_id) else plugin_id
def _listed_plugin_dir(base: Path, name: str) -> Optional[Path]:
"""The entry of ``base`` called ``name``, or None.
The path returned comes from listing ``base``, not from joining ``name``
onto it, so a caller that validated ``name`` doesn't have to rely on that
validation alone: nothing reaches the filesystem unless it's already there.
"""
try:
for entry in base.iterdir():
if entry.name == name:
return entry
except OSError:
pass
return None
@api_v3.route('/plugins/update', methods=['POST'])
def update_plugin():
"""Update plugin"""
try:
# Support both JSON and form data
content_type = request.content_type or ''
if 'application/json' in content_type:
# JSON request
data, error = validate_request_json(['plugin_id'])
if error:
logger.debug("[UPDATE] JSON validation failed. Content-Type: %s", content_type)
return error
else:
# Form data or query string
plugin_id = request.args.get('plugin_id') or request.form.get('plugin_id')
if not plugin_id:
logger.debug("[UPDATE] Missing plugin_id. Content-Type: %s", content_type)
return error_response(
ErrorCode.INVALID_INPUT,
'plugin_id required',
status_code=400
)
data = {'plugin_id': plugin_id}
# /plugins/installed lists installed Starlark apps as virtual
# 'starlark:<app_id>' entries. They are not plugin directories, so the
# store manager can only fail to find them -- which used to surface as
# a 500 "plugin not found" for an app that is installed and working.
raw_id = data.get('plugin_id')
if isinstance(raw_id, str) and raw_id.startswith('starlark:'):
return error_response(
ErrorCode.INVALID_INPUT,
f'{raw_id} is a Starlark app, not a plugin; Starlark apps are '
'not updated through the plugin updater',
status_code=400
)
if not api_v3.plugin_store_manager:
return error_response(
ErrorCode.SYSTEM_ERROR,
'Plugin store manager not initialized',
status_code=500
)
# The id names a directory this handler reads a manifest out of and
# hands to the store manager to run git in. It comes from the request
# body, so it is validated before anything is joined to a path, and
# the validated value -- not the raw one -- is what gets used below.
plugin_id = safe_path_component(data['plugin_id'])
if not plugin_id:
return error_response(
ErrorCode.INVALID_INPUT,
'Invalid plugin_id',
status_code=400
)
# Always do direct updates (they're fast git pull operations)
# Operation queue is reserved for longer operations like install/uninstall
# The resolver finds a plugin installed as ledmatrix-<id>. Either way
# the directory used is taken from a listing of plugins_dir, matched by
# name, never built from the request value -- so no path here depends
# on user input. None means nothing by that name is installed; the
# store manager still gets the id and reports that itself.
resolved = _plugin_directory(plugin_id)
plugin_dir = _listed_plugin_dir(
Path(api_v3.plugin_store_manager.plugins_dir),
resolved.name if resolved else plugin_id)
manifest_path = None
if plugin_dir is not None:
manifest_path = resolve_under(plugin_dir, "manifest.json")
if manifest_path is None:
return error_response(
ErrorCode.INVALID_INPUT,
'Invalid plugin_id',
status_code=400
)
current_last_updated = None
current_version = None
current_commit = None
current_branch = None
if manifest_path is not None and manifest_path.exists():
try:
with open(manifest_path, 'r', encoding='utf-8') as f:
manifest = json.load(f)
current_last_updated = manifest.get('last_updated')
current_version = manifest.get('version')
if manifest.get('local_only'):
logger.debug("Skipping update for local-only plugin: %s", plugin_id)
if api_v3.operation_history:
api_v3.operation_history.record_operation(
"update",
plugin_id=plugin_id,
status="skipped",
details={"reason": "local_only"}
)
return success_response(
data={'update_status': 'local_only'},
message=f'Plugin {plugin_id} is managed locally and does not receive registry updates')
except Exception as e:
logger.debug("Could not read local manifest for plugin: %s", e)
git_info_before = (api_v3.plugin_store_manager._get_local_git_info(plugin_dir)
if plugin_dir is not None else None)
if git_info_before:
current_commit = git_info_before.get('sha')
current_branch = git_info_before.get('branch')
logger.debug("Plugin is a git repository, will update via git pull")
remote_info = api_v3.plugin_store_manager.get_plugin_info(plugin_id, fetch_latest_from_github=True)
remote_commit = remote_info.get('last_commit_sha') if remote_info else None
remote_branch = remote_info.get('branch') if remote_info else None
# 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)
if success:
updated_last_updated = current_last_updated
updated_version = current_version
# The id the display knows the plugin by (and keys its config
# section on) is the manifest's, which can differ from the
# store id this route was given (an alias such as weather /
# ledmatrix-weather).
display_plugin_id = plugin_id
try:
if manifest_path is not None and manifest_path.exists():
with open(manifest_path, 'r', encoding='utf-8') as f:
manifest = json.load(f)
updated_last_updated = manifest.get('last_updated', current_last_updated)
updated_version = manifest.get('version', current_version)
manifest_id = manifest.get('id')
if isinstance(manifest_id, str) and safe_path_component(manifest_id):
display_plugin_id = manifest_id
except Exception as e:
logger.debug("Could not read updated manifest after update: %s", e)
updated_commit = None
updated_branch = remote_branch or current_branch
git_info_after = (api_v3.plugin_store_manager._get_local_git_info(plugin_dir)
if plugin_dir is not None else None)
if git_info_after:
updated_commit = git_info_after.get('sha')
updated_branch = git_info_after.get('branch') or updated_branch
# update_plugin() answers True for "nothing to do" as well as for
# a real update (a ZIP-installed monorepo plugin already at the
# registry version, a bundled plugin), so what changed is read off
# the plugin itself: its git commit, else its manifest.
update_status = 'updated'
message = f'Plugin {plugin_id} updated successfully'
if current_commit and updated_commit and current_commit == updated_commit:
update_status = 'up_to_date'
message = f'Plugin {plugin_id} already up to date (commit {updated_commit[:7]})'
elif updated_commit:
message = f'Plugin {plugin_id} updated to commit {updated_commit[:7]}'
if updated_branch:
message += f' on branch {updated_branch}'
elif updated_version and updated_version != current_version:
message = f'Plugin {plugin_id} updated to version {updated_version}'
elif updated_last_updated and updated_last_updated != current_last_updated:
message = f'Plugin {plugin_id} refreshed (Last Updated {updated_last_updated})'
elif not current_commit:
update_status = 'up_to_date'
message = f'Plugin {plugin_id} already up to date'
if updated_version:
message += f' (version {updated_version})'
remote_commit_short = remote_commit[:7] if remote_commit else None
if remote_commit_short and updated_commit and remote_commit_short != updated_commit[:7]:
message += f' (remote latest {remote_commit_short})'
# Invalidate schema cache
if api_v3.schema_manager:
api_v3.schema_manager.invalidate_cache(plugin_id)
# Rediscover plugins. The web process runs no plugin code, so
# there is nothing to reload here: the display reloads it, asked
# over the control socket below, or keeps running the version it
# loaded until a restart, which restart_required then asks for.
if api_v3.plugin_catalog:
api_v3.plugin_catalog.discover_plugins()
restart_fields = _reload_after_store_update(
display_plugin_id,
_store_restart_fields('update', _plugin_enabled_in_config(display_plugin_id),
changed=update_status == 'updated'))
if restart_fields.get('reloaded'):
message += '; the display is running the new version'
# Record in history (the only record of when it was updated;
# the version is the manifest on disk).
if api_v3.operation_history:
version = _get_plugin_version(plugin_id)
api_v3.operation_history.record_operation(
"update",
plugin_id=plugin_id,
status="success",
details={
"version": version,
"previous_commit": current_commit[:7] if current_commit else None,
"commit": updated_commit[:7] if updated_commit else None,
"branch": updated_branch,
"update_status": update_status
}
)
return success_response(
data={
'last_updated': updated_last_updated,
'commit': updated_commit,
'update_status': update_status
},
message=message,
extra=restart_fields,
)
else:
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'
else:
git_info = api_v3.plugin_store_manager._get_local_git_info(plugin_dir)
if not git_info:
plugin_info = api_v3.plugin_store_manager.get_plugin_info(plugin_id)
if not plugin_info:
client_msg = 'Plugin update failed: not found in registry'
else:
client_msg = 'Plugin update failed; check logs for details'
else:
client_msg = 'Plugin update failed; check logs for details'
logger.error("update_plugin failed for plugin_id=%s: %s", plugin_id, client_msg)
if api_v3.operation_history:
api_v3.operation_history.record_operation(
"update",
plugin_id=plugin_id,
status="failed",
error=client_msg,
details={
"previous_commit": current_commit[:7] if current_commit else None,
"branch": current_branch
}
)
return error_response(
ErrorCode.PLUGIN_UPDATE_FAILED,
client_msg,
# A refusal is the plugin's requirement, not a server fault.
status_code=409 if refusal else 500
)
except Exception as e:
logger.error("Unhandled exception in update endpoint", exc_info=True)
if api_v3.operation_history:
api_v3.operation_history.record_operation(
"update",
plugin_id=data.get('plugin_id') if 'data' in locals() else None,
status="failed",
error=str(e)
)
return exception_error_response(e, ErrorCode.PLUGIN_UPDATE_FAILED)
@api_v3.route('/plugins/uninstall', methods=['POST'])
def uninstall_plugin():
"""Uninstall plugin"""
try:
# Validate request
data, error = validate_request_json(['plugin_id'])
if error:
return error
if not api_v3.plugin_store_manager:
return error_response(
ErrorCode.SYSTEM_ERROR,
'Plugin store manager not initialized',
status_code=500
)
plugin_id = data['plugin_id']
preserve_config = data.get('preserve_config', False)
id_error = _non_plugin_id_error(plugin_id)
if id_error:
return id_error
# Both queued and direct paths use the same transactional helper so
# snapshot/rollback behaviour is consistent regardless of deployment.
if api_v3.operation_queue:
def uninstall_callback(operation):
"""Callback to execute plugin uninstallation via transactional helper."""
# Read before the uninstall removes the config section.
was_enabled = _plugin_enabled_in_config(plugin_id)
success, error_msg = _do_transactional_uninstall(plugin_id, preserve_config)
if not success:
if api_v3.operation_history:
api_v3.operation_history.record_operation(
"uninstall",
plugin_id=plugin_id,
status="failed",
error=error_msg
)
raise Exception(error_msg or f'Failed to uninstall plugin {plugin_id}')
if api_v3.operation_history:
api_v3.operation_history.record_operation(
"uninstall",
plugin_id=plugin_id,
status="success",
details={"preserve_config": preserve_config}
)
return {'success': True, 'message': 'Plugin uninstalled successfully',
**_store_restart_fields('uninstall', was_enabled,
preserve_config=preserve_config)}
# Enqueue operation
operation_id = api_v3.operation_queue.enqueue_operation(
OperationType.UNINSTALL,
plugin_id,
operation_callback=uninstall_callback
)
return success_response(
data={'operation_id': operation_id},
message='Plugin uninstallation queued'
)
else:
# Direct (non-queued) transactional uninstall
was_enabled = _plugin_enabled_in_config(plugin_id)
success, error_msg = _do_transactional_uninstall(plugin_id, preserve_config)
if success:
if api_v3.operation_history:
api_v3.operation_history.record_operation(
"uninstall",
plugin_id=plugin_id,
status="success",
details={"preserve_config": preserve_config}
)
return success_response(
message='Plugin uninstalled successfully',
extra=_store_restart_fields('uninstall', was_enabled,
preserve_config=preserve_config))
else:
if api_v3.operation_history:
api_v3.operation_history.record_operation(
"uninstall",
plugin_id=plugin_id,
status="failed",
error=error_msg
)
return error_response(
ErrorCode.PLUGIN_UNINSTALL_FAILED,
error_msg or 'Plugin uninstall failed',
status_code=500
)
except Exception as e:
if api_v3.operation_history:
api_v3.operation_history.record_operation(
"uninstall",
plugin_id=data.get('plugin_id') if 'data' in locals() else None,
status="failed",
error=str(e)
)
return exception_error_response(e, ErrorCode.PLUGIN_UNINSTALL_FAILED)
@api_v3.route('/plugins/install', methods=['POST'])
def install_plugin():
"""Install plugin from store"""
if not api_v3.plugin_store_manager:
return jsonify({'status': 'error', 'message': 'Plugin store manager not initialized'}), 500
data = request.get_json(silent=True)
if not data or 'plugin_id' not in data:
return jsonify({'status': 'error', 'message': 'plugin_id required'}), 400
plugin_id = data['plugin_id']
branch = data.get('branch') # Optional branch parameter
# A registry entry that isn't a plugin (a custom registry can still
# list old "type": "skin" entries) gets a clear refusal, not a failed
# install.
try:
registry_entry = api_v3.plugin_store_manager.get_registry_info(plugin_id)
except Exception:
registry_entry = None
if isinstance(registry_entry, dict) and not api_v3.plugin_store_manager.is_plugin_entry(registry_entry):
return jsonify({'status': 'error',
'message': f"{plugin_id} is a {registry_entry.get('type')!r} entry, not a plugin"}), 400
plugins_dir = api_v3.plugin_store_manager.plugins_dir
logger.info("Installing plugin to directory: %s", plugins_dir)
# Use operation queue if available
if api_v3.operation_queue:
def install_callback(operation):
"""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)
if success:
# Invalidate schema cache
if api_v3.schema_manager:
api_v3.schema_manager.invalidate_cache(plugin_id)
# List the new plugin. The display loads it, from disk, when
# it is enabled; see restart_required below for one that
# already is.
if api_v3.plugin_catalog:
api_v3.plugin_catalog.discover_plugins()
# Record in history
if api_v3.operation_history:
version = _get_plugin_version(plugin_id)
api_v3.operation_history.record_operation(
"install",
plugin_id=plugin_id,
status="success",
details={"version": version, "branch": branch}
)
branch_msg = f" (branch: {branch})" if branch else ""
# plugin_id: the id to enable it by (see _installed_plugin_id).
return {'success': True,
'message': f'Plugin {plugin_id} installed successfully{branch_msg}',
'plugin_id': _installed_plugin_id(plugin_id),
**_store_restart_fields('install', _plugin_enabled_in_config(plugin_id))}
else:
error_msg = f'Failed to install plugin {plugin_id}'
if branch:
error_msg += f' (branch: {branch})'
refusal = _compatibility_refusal(plugin_id)
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)'
# Record failure in history
if api_v3.operation_history:
api_v3.operation_history.record_operation(
"install",
plugin_id=plugin_id,
status="failed",
error=error_msg,
details={"branch": branch}
)
raise Exception(error_msg)
# Enqueue operation
operation_id = api_v3.operation_queue.enqueue_operation(
OperationType.INSTALL,
plugin_id,
operation_callback=install_callback
)
branch_msg = f" (branch: {branch})" if branch else ""
return success_response(
data={'operation_id': operation_id},
message=f'Plugin {plugin_id} installation queued{branch_msg}'
)
else:
# Fallback to direct installation
_compatibility_refusal(plugin_id) # clear any stale one
success = api_v3.plugin_store_manager.install_plugin(plugin_id, branch=branch)
if success:
if api_v3.schema_manager:
api_v3.schema_manager.invalidate_cache(plugin_id)
if api_v3.plugin_catalog:
api_v3.plugin_catalog.discover_plugins()
if api_v3.operation_history:
version = _get_plugin_version(plugin_id)
api_v3.operation_history.record_operation(
"install",
plugin_id=plugin_id,
status="success",
details={"version": version, "branch": branch}
)
branch_msg = f" (branch: {branch})" if branch else ""
return success_response(
message=f'Plugin installed successfully{branch_msg}',
extra={'plugin_id': _installed_plugin_id(plugin_id),
**_store_restart_fields('install', _plugin_enabled_in_config(plugin_id))})
else:
error_msg = f'Failed to install plugin {plugin_id}'
if branch:
error_msg += f' (branch: {branch})'
refusal = _compatibility_refusal(plugin_id)
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)'
if api_v3.operation_history:
api_v3.operation_history.record_operation(
"install",
plugin_id=plugin_id,
status="failed",
error=error_msg,
details={"branch": branch}
)
return error_response(
ErrorCode.PLUGIN_INSTALL_FAILED,
error_msg,
status_code=409 if refusal else 500
)
@api_v3.route('/plugins/install-from-url', methods=['POST'])
def install_plugin_from_url():
"""Install plugin from custom GitHub URL"""
if not api_v3.plugin_store_manager:
return jsonify({'status': 'error', 'message': 'Plugin store manager not initialized'}), 500
data = request.get_json(silent=True)
if not data or 'repo_url' not in data:
return jsonify({'status': 'error', 'message': 'repo_url required'}), 400
# A non-string repo_url is a client mistake, not a server fault:
# .strip() would raise and the catch-all would report it as a 500.
if not isinstance(data['repo_url'], str) or not data['repo_url'].strip():
return jsonify({'status': 'error', 'message': 'repo_url must be a non-empty string'}), 400
repo_url = data['repo_url'].strip()
plugin_id = data.get('plugin_id') # Optional, for monorepo installations
plugin_path = data.get('plugin_path') # Optional, for monorepo subdirectory
branch = data.get('branch') # Optional branch parameter
# Install the plugin
result = api_v3.plugin_store_manager.install_from_url(
repo_url=repo_url,
plugin_id=plugin_id,
plugin_path=plugin_path,
branch=branch
)
if result.get('success'):
# Invalidate schema cache for the installed plugin
installed_plugin_id = result.get('plugin_id')
if api_v3.schema_manager and installed_plugin_id:
api_v3.schema_manager.invalidate_cache(installed_plugin_id)
# List the new plugin; the display loads it when it is enabled.
if api_v3.plugin_catalog and installed_plugin_id:
api_v3.plugin_catalog.discover_plugins()
branch_msg = f" (branch: {result.get('branch', branch)})" if (result.get('branch') or branch) else ""
response_data = {
'status': 'success',
'message': f"Plugin {installed_plugin_id} installed successfully{branch_msg}",
'plugin_id': installed_plugin_id,
'name': result.get('name')
}
if result.get('branch'):
response_data['branch'] = result.get('branch')
if installed_plugin_id:
response_data.update(_store_restart_fields(
'install', _plugin_enabled_in_config(installed_plugin_id)))
return jsonify(response_data)
else:
return jsonify({
'status': 'error',
'message': result.get('error', 'Failed to install plugin from URL')
}), 500
@api_v3.route('/plugins/registry-from-url', methods=['POST'])
def get_registry_from_url():
"""Get plugin list from a registry-style monorepo URL"""
if not api_v3.plugin_store_manager:
return jsonify({'status': 'error', 'message': 'Plugin store manager not initialized'}), 500
data = request.get_json(silent=True)
if not data or 'repo_url' not in data:
return jsonify({'status': 'error', 'message': 'repo_url required'}), 400
# A non-string repo_url is a client mistake, not a server fault:
# .strip() would raise and the catch-all would report it as a 500.
if not isinstance(data['repo_url'], str) or not data['repo_url'].strip():
return jsonify({'status': 'error', 'message': 'repo_url must be a non-empty string'}), 400
repo_url = data['repo_url'].strip()
# Get registry from the URL
registry = api_v3.plugin_store_manager.fetch_registry_from_url(repo_url)
if registry:
return jsonify({
'status': 'success',
'plugins': [p for p in registry.get('plugins', [])
if api_v3.plugin_store_manager.is_plugin_entry(p)],
'registry_url': repo_url
})
else:
return jsonify({
'status': 'error',
'message': 'Failed to fetch registry from URL or URL does not contain a valid registry'
}), 400
@api_v3.route('/plugins/saved-repositories', methods=['GET'])
def get_saved_repositories():
"""Get all saved repositories"""
if not api_v3.saved_repositories_manager:
return jsonify({'status': 'error', 'message': 'Saved repositories manager not initialized'}), 500
repositories = api_v3.saved_repositories_manager.get_all()
return jsonify({'status': 'success', 'data': {'repositories': repositories}})
@api_v3.route('/plugins/saved-repositories', methods=['POST'])
def add_saved_repository():
"""Add a repository to saved list"""
if not api_v3.saved_repositories_manager:
return jsonify({'status': 'error', 'message': 'Saved repositories manager not initialized'}), 500
data = request.get_json(silent=True)
if not data or 'repo_url' not in data:
return jsonify({'status': 'error', 'message': 'repo_url required'}), 400
# A non-string repo_url is a client mistake, not a server fault:
# .strip() would raise and the catch-all would report it as a 500.
if not isinstance(data['repo_url'], str) or not data['repo_url'].strip():
return jsonify({'status': 'error', 'message': 'repo_url must be a non-empty string'}), 400
repo_url = data['repo_url'].strip()
name = data.get('name')
success = api_v3.saved_repositories_manager.add(repo_url, name)
if success:
return jsonify({
'status': 'success',
'message': 'Repository saved successfully',
'data': {'repositories': api_v3.saved_repositories_manager.get_all()}
})
else:
return jsonify({
'status': 'error',
'message': 'Repository already exists or failed to save'
}), 400
@api_v3.route('/plugins/saved-repositories', methods=['DELETE'])
def remove_saved_repository():
"""Remove a repository from saved list"""
if not api_v3.saved_repositories_manager:
return jsonify({'status': 'error', 'message': 'Saved repositories manager not initialized'}), 500
data = request.get_json(silent=True)
if not data or 'repo_url' not in data:
return jsonify({'status': 'error', 'message': 'repo_url required'}), 400
repo_url = data['repo_url']
success = api_v3.saved_repositories_manager.remove(repo_url)
if success:
return jsonify({
'status': 'success',
'message': 'Repository removed successfully',
'data': {'repositories': api_v3.saved_repositories_manager.get_all()}
})
else:
return jsonify({
'status': 'error',
'message': 'Repository not found'
}), 404
@api_v3.route('/plugins/store/list', methods=['GET'])
def list_plugin_store():
"""Search plugin store"""
if not api_v3.plugin_store_manager:
return jsonify({'status': 'error', 'message': 'Plugin store manager not initialized'}), 500
query = request.args.get('query', '')
category = request.args.get('category', '')
tags = request.args.getlist('tags')
# Default to fetching commit metadata to ensure accurate commit timestamps
fetch_commit_param = request.args.get('fetch_commit_info', request.args.get('fetch_latest_versions', '')).lower()
fetch_commit = fetch_commit_param != 'false'
# Search plugins from the registry (including saved repositories)
plugins = api_v3.plugin_store_manager.search_plugins(
query=query,
category=category,
tags=tags,
fetch_commit_info=fetch_commit,
include_saved_repos=True,
saved_repositories_manager=api_v3.saved_repositories_manager
)
# Format plugins for the web interface
formatted_plugins = []
for plugin in plugins:
if not api_v3.plugin_store_manager.is_plugin_entry(plugin):
continue
formatted_plugins.append({
'id': plugin.get('id'),
'name': plugin.get('name'),
'author': plugin.get('author'),
'category': plugin.get('category'),
'description': plugin.get('description'),
'tags': plugin.get('tags', []),
'stars': plugin.get('stars', 0),
'verified': plugin.get('verified', False),
'repo': plugin.get('repo', ''),
'last_updated': plugin.get('last_updated') or plugin.get('last_updated_iso', ''),
'last_updated_iso': plugin.get('last_updated_iso', ''),
'last_commit': plugin.get('last_commit') or plugin.get('last_commit_sha'),
'last_commit_message': plugin.get('last_commit_message'),
'last_commit_author': plugin.get('last_commit_author'),
'version': plugin.get('latest_version') or plugin.get('version', ''),
'branch': plugin.get('branch') or plugin.get('default_branch'),
'default_branch': plugin.get('default_branch'),
'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}})
@api_v3.route('/plugins/store/github-status', methods=['GET'])
def get_github_auth_status():
"""Check if GitHub authentication is configured and validate token"""
if not api_v3.plugin_store_manager:
return jsonify({'status': 'error', 'message': 'Plugin store manager not initialized'}), 500
token = api_v3.plugin_store_manager.github_token
# Check if GitHub token is configured
if not token or len(token) == 0:
return jsonify({
'status': 'success',
'data': {
'token_status': 'none', # nosec B105 - a status label # nosemgrep
'authenticated': False,
'rate_limit': 60,
'message': 'No GitHub token configured',
'error': None
}
})
# Validate the token
is_valid, error_message = api_v3.plugin_store_manager._validate_github_token(token)
if is_valid:
return jsonify({
'status': 'success',
'data': {
'token_status': 'valid', # nosec B105 - a status label # nosemgrep
'authenticated': True,
'rate_limit': 5000,
'message': 'GitHub API authenticated',
'error': None
}
})
else:
return jsonify({
'status': 'success',
'data': {
'token_status': 'invalid', # nosec B105 - a status label # nosemgrep
'authenticated': False,
'rate_limit': 60,
'message': f'GitHub token is invalid: {error_message}' if error_message else 'GitHub token is invalid',
'error': error_message
}
})
@api_v3.route('/plugins/store/refresh', methods=['POST'])
def refresh_plugin_store():
"""Re-download the plugin registry, bypassing its cache.
Takes no body. Answers ``{status, message, plugin_count}``, the count
being the registry's entries. Commit metadata is not refreshed here: the
store list fetches it per plugin when it is shown.
"""
if not api_v3.plugin_store_manager:
return jsonify({'status': 'error', 'message': 'Plugin store manager not initialized'}), 500
registry = api_v3.plugin_store_manager.fetch_registry(force_refresh=True)
plugin_count = len(registry.get('plugins', []))
return jsonify({
'status': 'success',
'message': 'Plugin store refreshed',
'plugin_count': plugin_count
})