mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
* fix(web): pass plugin action params to the wrapper on stdin
POST /api/v3/plugins/action runs a plugin's script through a generated
Python wrapper, and the params went into that wrapper's source as
`params = <json.dumps(params)>`. JSON true, false and null are undefined
names in Python, so any params holding one made the wrapper die with a
NameError before the script ran, and the route answered "Action failed".
The plugin file manager's category toggle sends {"category_name": ...,
"enabled": true}, so of-the-day's category toggle failed every time.
The wrapper now reads the params from its own stdin (json.loads) and the
route passes them there; nothing taken from the request is written into
the generated source any more. The script's side is unchanged: the same
json.dumps(params) on its stdin, LEDMATRIX_ROOT set, stdout parsed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): a refused on-demand start leaves no request in the mailbox
POST /api/v3/display/on-demand/start delivered the request (control
socket, else the file mailbox) before it checked the display service.
With the service stopped the socket is absent, so the request went to the
mailbox; the route then answered 400 "Display service is not running"
when start_service was off, or 500 "Failed to start display service" when
the start failed. The display reads that mailbox with max_age=3600 and
never checks a request's timestamp, so the next time it was started it
ran the refused request, pinned if asked.
The service is now checked before anything is delivered, and nothing is
posted when start_service is off and the service is down. When the start
itself fails, the request is withdrawn from the mailbox, but only while
the mailbox still holds this request_id (the compare-before-delete the
display's _consume_on_demand_request uses), so a newer request posted in
the meantime is left for the display.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): a pending plugin operation's status no longer answers 500
PluginOperationQueue.enqueue_operation stores the operation's callback in
operation.parameters['_callback'], and the worker pops it only when it
runs the operation. PluginOperation.to_dict() returned parameters as they
were, so GET /api/v3/plugins/operation/<id> for an operation still
waiting in the queue (an install queued behind another plugin's) handed
jsonify a function and answered 500 "A system error occurred" on every
poll until the worker reached it.
to_dict() now leaves out parameters whose name starts with "_". The
operation itself keeps its callback for the worker; every other field of
the answer, and the operation-history records (a different class), are
unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): a second install or uninstall of a busy plugin is a 409
PluginOperationQueue.enqueue_operation raises ValueError when the plugin
already has an operation waiting or running. /plugins/install did not
catch it, so a double-clicked Install (the button is never disabled)
answered 500 "An error occurred; see logs for details" from the
blueprint's catch-all while the first install carried on.
/plugins/uninstall caught it in its own catch-all: a 500 "Failed to
uninstall plugin", plus an "uninstall failed" operation-history record
for an uninstall that never started.
Both routes now enqueue through _enqueue_or_conflict, which turns the
queue's refusal into a 409 PLUGIN_OPERATION_CONFLICT naming the plugin,
and records nothing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): serve binary plugin static files instead of a 500
GET /api/v3/plugins/<plugin_id>/static/<path> read every file with
open(..., 'r', encoding='utf-8') and returned the decoded text, so any
binary file -- a plugin icon or preview image, which is what the REST API
reference says the route is for -- raised UnicodeDecodeError and answered
500.
The file is now sent with send_file, as bytes. HTML, JavaScript, CSS and
JSON keep the content types the route always set, and other text keeps
text/plain; anything else gets the type mimetypes knows it by (image/png
for a .png). The plugin id and path validation and the resolve_under
containment check are untouched.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): a socket-acknowledged on-demand start is a success
cdaeb385 checked the systemd unit before delivering the on-demand
request, so a display run by hand or in the emulator (no active unit)
with start_service off now got nothing, where before the request went
over the control socket and took effect behind a 400. A socket
acknowledgement is the display itself saying it is running and has the
request queued, so it is the better witness than systemd.
The request is delivered first again. When the display acknowledged it
over the socket, the route answers success without consulting systemd for
the "not running" 400 and without starting the unit (with start_service
on it tried to start a second display beside the one that answered); the
service is still reported the way _ensure_display_service_running reports
a running one. When it went to the mailbox, the 400 (service down,
start_service off) and the failed-start 500 both withdraw this request_id
from the mailbox, leaving a newer request alone, so neither refusal runs
later.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
912 lines
39 KiB
Python
912 lines
39 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
|
|
|
|
|
|
def _enqueue_or_conflict(operation_type, plugin_id, callback):
|
|
"""``(operation_id, None)``, or ``(None, a 409 response)``.
|
|
|
|
The queue raises ValueError when the plugin already has an operation
|
|
waiting or running -- a double-clicked Install, an uninstall during an
|
|
install. That is the caller's timing, not a server fault: it reached
|
|
the client as a 500, and the uninstall route recorded a failed
|
|
uninstall that had never started.
|
|
"""
|
|
try:
|
|
return api_v3.operation_queue.enqueue_operation(
|
|
operation_type, plugin_id, operation_callback=callback), None
|
|
except ValueError:
|
|
return None, error_response(
|
|
ErrorCode.PLUGIN_OPERATION_CONFLICT,
|
|
f'Plugin {plugin_id} already has an install, update or uninstall '
|
|
'in progress; wait for it to finish, then try again',
|
|
status_code=409
|
|
)
|
|
|
|
|
|
@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, conflict = _enqueue_or_conflict(
|
|
OperationType.UNINSTALL, plugin_id, uninstall_callback)
|
|
if conflict:
|
|
return conflict
|
|
|
|
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, conflict = _enqueue_or_conflict(
|
|
OperationType.INSTALL, plugin_id, install_callback)
|
|
if conflict:
|
|
return conflict
|
|
|
|
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
|
|
})
|