5 Commits

Author SHA1 Message Date
ChuckBuilds
8aab15d83c fix(web): use HTMX for Plugin Manager tab loading instead of custom fetch
The Plugin Manager tab was the only tab using a custom window.loadPluginsTab()
function with plain fetch() instead of HTMX. This caused a race condition where
plugins_manager.js listened for htmx:afterSwap to initialize, but that event
never fired for the custom fetch. Users had to navigate to a plugin config tab
and back to trigger initialization.

Changes:
- Switch plugins tab to hx-get/hx-trigger="revealed" matching all other tabs
- Remove ~560 lines of dead code (script extraction for a partial with no scripts,
  nested retry intervals, inline HTML card rendering fallbacks)
- Add simple loadPluginsDirect() fallback for when HTMX fails to load
- Remove typeof htmx guard on afterSwap listener so it registers unconditionally
- Tighten afterSwap target check to avoid spurious re-init from other tab swaps

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 09:36:27 -04:00
ChuckBuilds
6ff7fcba8d fix: add thread-safe locking to PluginManager and fix reconciliation retry
PluginManager thread safety:
- Add RLock protecting plugin_manifests and plugin_directories
- Build scan results locally in _scan_directory_for_plugins, then update
  shared state under lock
- Protect reads in get_plugin_info, get_all_plugin_info,
  get_plugin_directory, get_plugin_display_modes, find_plugin_for_mode
- Protect manifest mutation in reload_plugin
- Prevents races between background reconciliation thread and request
  handlers reading plugin state

Reconciliation retry:
- Clear _reconciliation_started on exception so next request retries
- Check result.reconciliation_successful before marking done
- Reset _reconciliation_started on non-success results to allow retry

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 15:47:05 -04:00
ChuckBuilds
73109f73f7 fix: address code review findings for plugin auto-repair
- Move backup-folder filter from _get_config_state to _get_disk_state
  where the artifact actually lives
- Run startup reconciliation in a background thread so requests aren't
  blocked by plugin reinstallation
- Set _reconciliation_done only after success so failures allow retries
- Replace print() with proper logger in reconciliation
- Wrap load_schema in try/except so exceptions fall through to
  conservative masking instead of 500
- Handle list values in _conservative_mask_config for nested secrets
- Remove duplicate import re

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 15:19:55 -04:00
ChuckBuilds
3d96026e87 fix: handle ledmatrix- prefix in plugin updates and reconciliation
The store registry uses unprefixed IDs (e.g., 'weather') while older
installs used prefixed config keys (e.g., 'ledmatrix-weather'). Both
update_plugin() and auto-repair now try the unprefixed ID as a fallback
when the prefixed one isn't found in the registry.

Also filters system config keys (schedule, display, etc.) from
reconciliation to avoid false positives.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 14:54:52 -04:00
ChuckBuilds
2619c0d893 fix: auto-repair missing plugins and graceful config fallback
Plugins whose directories are missing (failed update, migration, etc.)
now get automatically reinstalled from the store on startup. The config
endpoint no longer returns a hard 500 when a schema is unavailable —
it falls back to conservative key-name-based masking so the settings
page stays functional.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 14:49:36 -04:00
7 changed files with 256 additions and 631 deletions

View File

@@ -14,6 +14,7 @@ import importlib.util
import sys
import subprocess
import time
import threading
from pathlib import Path
from typing import Dict, List, Optional, Any
import logging
@@ -74,6 +75,10 @@ class PluginManager:
self.state_manager = PluginStateManager(logger=self.logger)
self.schema_manager = SchemaManager(plugins_dir=self.plugins_dir, logger=self.logger)
# Lock protecting plugin_manifests and plugin_directories from
# concurrent mutation (background reconciliation) and reads (requests).
self._discovery_lock = threading.RLock()
# Active plugins
self.plugins: Dict[str, Any] = {}
self.plugin_manifests: Dict[str, Dict[str, Any]] = {}
@@ -94,23 +99,27 @@ class PluginManager:
def _scan_directory_for_plugins(self, directory: Path) -> List[str]:
"""
Scan a directory for plugins.
Args:
directory: Directory to scan
Returns:
List of plugin IDs found
"""
plugin_ids = []
if not directory.exists():
return plugin_ids
# Build new state locally before acquiring lock
new_manifests: Dict[str, Dict[str, Any]] = {}
new_directories: Dict[str, Path] = {}
try:
for item in directory.iterdir():
if not item.is_dir():
continue
manifest_path = item / "manifest.json"
if manifest_path.exists():
try:
@@ -119,18 +128,21 @@ class PluginManager:
plugin_id = manifest.get('id')
if plugin_id:
plugin_ids.append(plugin_id)
self.plugin_manifests[plugin_id] = manifest
# Store directory mapping
if not hasattr(self, 'plugin_directories'):
self.plugin_directories = {}
self.plugin_directories[plugin_id] = item
new_manifests[plugin_id] = manifest
new_directories[plugin_id] = item
except (json.JSONDecodeError, PermissionError, OSError) as e:
self.logger.warning("Error reading manifest from %s: %s", manifest_path, e, exc_info=True)
continue
except (OSError, PermissionError) as e:
self.logger.error("Error scanning directory %s: %s", directory, e, exc_info=True)
# Update shared state under lock
with self._discovery_lock:
self.plugin_manifests.update(new_manifests)
if not hasattr(self, 'plugin_directories'):
self.plugin_directories = {}
self.plugin_directories.update(new_directories)
return plugin_ids
def discover_plugins(self) -> List[str]:
@@ -459,7 +471,9 @@ class PluginManager:
if manifest_path.exists():
try:
with open(manifest_path, 'r', encoding='utf-8') as f:
self.plugin_manifests[plugin_id] = json.load(f)
manifest = json.load(f)
with self._discovery_lock:
self.plugin_manifests[plugin_id] = manifest
except Exception as e:
self.logger.error("Error reading manifest: %s", e, exc_info=True)
return False
@@ -506,10 +520,11 @@ class PluginManager:
Returns:
Dict with plugin information or None if not found
"""
manifest = self.plugin_manifests.get(plugin_id)
with self._discovery_lock:
manifest = self.plugin_manifests.get(plugin_id)
if not manifest:
return None
info = manifest.copy()
# Add runtime information if plugin is loaded
@@ -533,7 +548,9 @@ class PluginManager:
Returns:
List of plugin info dictionaries
"""
return [info for info in [self.get_plugin_info(pid) for pid in self.plugin_manifests.keys()] if info]
with self._discovery_lock:
pids = list(self.plugin_manifests.keys())
return [info for info in [self.get_plugin_info(pid) for pid in pids] if info]
def get_plugin_directory(self, plugin_id: str) -> Optional[str]:
"""
@@ -545,8 +562,9 @@ class PluginManager:
Returns:
Directory path as string or None if not found
"""
if hasattr(self, 'plugin_directories') and plugin_id in self.plugin_directories:
return str(self.plugin_directories[plugin_id])
with self._discovery_lock:
if hasattr(self, 'plugin_directories') and plugin_id in self.plugin_directories:
return str(self.plugin_directories[plugin_id])
plugin_dir = self.plugins_dir / plugin_id
if plugin_dir.exists():
@@ -568,10 +586,11 @@ class PluginManager:
Returns:
List of display mode names
"""
manifest = self.plugin_manifests.get(plugin_id)
with self._discovery_lock:
manifest = self.plugin_manifests.get(plugin_id)
if not manifest:
return []
display_modes = manifest.get('display_modes', [])
if isinstance(display_modes, list):
return display_modes
@@ -588,12 +607,14 @@ class PluginManager:
Plugin identifier or None if not found.
"""
normalized_mode = mode.strip().lower()
for plugin_id, manifest in self.plugin_manifests.items():
with self._discovery_lock:
manifests_snapshot = dict(self.plugin_manifests)
for plugin_id, manifest in manifests_snapshot.items():
display_modes = manifest.get('display_modes')
if isinstance(display_modes, list) and display_modes:
if any(m.lower() == normalized_mode for m in display_modes):
return plugin_id
return None
def _get_plugin_update_interval(self, plugin_id: str, plugin_instance: Any) -> Optional[float]:

View File

@@ -67,21 +67,24 @@ class StateReconciliation:
state_manager: PluginStateManager,
config_manager,
plugin_manager,
plugins_dir: Path
plugins_dir: Path,
store_manager=None
):
"""
Initialize reconciliation system.
Args:
state_manager: PluginStateManager instance
config_manager: ConfigManager instance
plugin_manager: PluginManager instance
plugins_dir: Path to plugins directory
store_manager: Optional PluginStoreManager for auto-repair
"""
self.state_manager = state_manager
self.config_manager = config_manager
self.plugin_manager = plugin_manager
self.plugins_dir = Path(plugins_dir)
self.store_manager = store_manager
self.logger = get_logger(__name__)
def reconcile_state(self) -> ReconciliationResult:
@@ -160,18 +163,32 @@ class StateReconciliation:
message=f"Reconciliation failed: {str(e)}"
)
# Top-level config keys that are NOT plugins
_SYSTEM_CONFIG_KEYS = frozenset({
'web_display_autostart', 'timezone', 'location', 'display',
'plugin_system', 'vegas_scroll_speed', 'vegas_separator_width',
'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order',
'vegas_excluded_plugins', 'vegas_scroll_enabled', 'logging',
'dim_schedule', 'network', 'system', 'schedule',
})
def _get_config_state(self) -> Dict[str, Dict[str, Any]]:
"""Get plugin state from config file."""
state = {}
try:
config = self.config_manager.load_config()
for plugin_id, plugin_config in config.items():
if isinstance(plugin_config, dict):
state[plugin_id] = {
'enabled': plugin_config.get('enabled', False),
'version': plugin_config.get('version'),
'exists_in_config': True
}
if not isinstance(plugin_config, dict):
continue
if plugin_id in self._SYSTEM_CONFIG_KEYS:
continue
if 'enabled' not in plugin_config:
continue
state[plugin_id] = {
'enabled': plugin_config.get('enabled', False),
'version': plugin_config.get('version'),
'exists_in_config': True
}
except Exception as e:
self.logger.warning(f"Error reading config state: {e}")
return state
@@ -184,6 +201,8 @@ class StateReconciliation:
for plugin_dir in self.plugins_dir.iterdir():
if plugin_dir.is_dir():
plugin_id = plugin_dir.name
if '.standalone-backup-' in plugin_id:
continue
manifest_path = plugin_dir / "manifest.json"
if manifest_path.exists():
import json
@@ -263,14 +282,15 @@ class StateReconciliation:
# Check: Plugin in config but not on disk
if config.get('exists_in_config') and not disk.get('exists_on_disk'):
can_repair = self.store_manager is not None
inconsistencies.append(Inconsistency(
plugin_id=plugin_id,
inconsistency_type=InconsistencyType.PLUGIN_MISSING_ON_DISK,
description=f"Plugin {plugin_id} in config but not on disk",
fix_action=FixAction.MANUAL_FIX_REQUIRED,
fix_action=FixAction.AUTO_FIX if can_repair else FixAction.MANUAL_FIX_REQUIRED,
current_state={'exists_on_disk': False},
expected_state={'exists_on_disk': True},
can_auto_fix=False
can_auto_fix=can_repair
))
# Check: Enabled state mismatch
@@ -303,6 +323,9 @@ class StateReconciliation:
self.logger.info(f"Fixed: Added {inconsistency.plugin_id} to config")
return True
elif inconsistency.inconsistency_type == InconsistencyType.PLUGIN_MISSING_ON_DISK:
return self._auto_repair_missing_plugin(inconsistency.plugin_id)
elif inconsistency.inconsistency_type == InconsistencyType.PLUGIN_ENABLED_MISMATCH:
# Sync enabled state from state manager to config
expected_enabled = inconsistency.expected_state.get('enabled')
@@ -317,6 +340,34 @@ class StateReconciliation:
except Exception as e:
self.logger.error(f"Error fixing inconsistency: {e}", exc_info=True)
return False
return False
def _auto_repair_missing_plugin(self, plugin_id: str) -> bool:
"""Attempt to reinstall a missing plugin from the store."""
if not self.store_manager:
return False
# Try the plugin_id as-is, then without 'ledmatrix-' prefix
candidates = [plugin_id]
if plugin_id.startswith('ledmatrix-'):
candidates.append(plugin_id[len('ledmatrix-'):])
for candidate_id in candidates:
try:
self.logger.info("[AutoRepair] Attempting to reinstall missing plugin: %s", candidate_id)
result = self.store_manager.install_plugin(candidate_id)
if isinstance(result, dict):
success = result.get('success', False)
else:
success = bool(result)
if success:
self.logger.info("[AutoRepair] Successfully reinstalled plugin: %s (config key: %s)", candidate_id, plugin_id)
return True
except Exception as e:
self.logger.error("[AutoRepair] Error reinstalling %s: %s", candidate_id, e, exc_info=True)
self.logger.warning("[AutoRepair] Could not reinstall %s from store", plugin_id)
return False

View File

@@ -1784,6 +1784,12 @@ class PluginStoreManager:
# Try to get remote info from registry (optional)
self.fetch_registry(force_refresh=True)
plugin_info_remote = self.get_plugin_info(plugin_id, fetch_latest_from_github=True, force_refresh=True)
# Try without 'ledmatrix-' prefix (monorepo migration)
if not plugin_info_remote and plugin_id.startswith('ledmatrix-'):
alt_id = plugin_id[len('ledmatrix-'):]
plugin_info_remote = self.get_plugin_info(alt_id, fetch_latest_from_github=True, force_refresh=True)
if plugin_info_remote:
self.logger.info(f"Plugin {plugin_id} found in registry as {alt_id}")
remote_branch = None
remote_sha = None
@@ -2058,7 +2064,16 @@ class PluginStoreManager:
self.logger.info(f"Plugin {plugin_id} is not a git repository, checking registry...")
self.fetch_registry(force_refresh=True)
plugin_info_remote = self.get_plugin_info(plugin_id, fetch_latest_from_github=True, force_refresh=True)
# If not found, try without 'ledmatrix-' prefix (monorepo migration)
registry_id = plugin_id
if not plugin_info_remote and plugin_id.startswith('ledmatrix-'):
alt_id = plugin_id[len('ledmatrix-'):]
plugin_info_remote = self.get_plugin_info(alt_id, fetch_latest_from_github=True, force_refresh=True)
if plugin_info_remote:
registry_id = alt_id
self.logger.info(f"Plugin {plugin_id} found in registry as {alt_id}")
# If not in registry but we have a repo URL, try reinstalling from that URL
if not plugin_info_remote and repo_url:
self.logger.info(f"Plugin {plugin_id} not in registry but has git remote URL. Reinstalling from {repo_url} to enable updates...")
@@ -2111,13 +2126,13 @@ class PluginStoreManager:
self.logger.debug(f"Could not compare versions for {plugin_id}: {e}")
# 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")
self.logger.info(f"Plugin {plugin_id} not installed via git; re-installing latest archive (registry id: {registry_id})")
# Remove directory and reinstall fresh
if not self._safe_remove_directory(plugin_path):
self.logger.error(f"Failed to remove old plugin directory for {plugin_id}")
return False
return self.install_plugin(plugin_id)
return self.install_plugin(registry_id)
except Exception as e:
import traceback

View File

@@ -651,12 +651,49 @@ def _initialize_health_monitor():
_health_monitor_initialized = True
# Initialize health monitor on first request (using before_request for compatibility)
_reconciliation_done = False
_reconciliation_started = False
def _run_startup_reconciliation():
"""Run state reconciliation in background to auto-repair missing plugins."""
global _reconciliation_done, _reconciliation_started
from src.logging_config import get_logger
_logger = get_logger('reconciliation')
try:
from src.plugin_system.state_reconciliation import StateReconciliation
reconciler = StateReconciliation(
state_manager=plugin_state_manager,
config_manager=config_manager,
plugin_manager=plugin_manager,
plugins_dir=plugins_dir,
store_manager=plugin_store_manager
)
result = reconciler.reconcile_state()
if result.inconsistencies_found:
_logger.info("[Reconciliation] %s", result.message)
if result.reconciliation_successful:
if result.inconsistencies_fixed:
plugin_manager.discover_plugins()
_reconciliation_done = True
else:
_logger.warning("[Reconciliation] Finished with unresolved issues, will retry")
_reconciliation_started = False
except Exception as e:
_logger.error("[Reconciliation] Error: %s", e, exc_info=True)
_reconciliation_started = False
# Initialize health monitor and run reconciliation on first request
@app.before_request
def check_health_monitor():
"""Ensure health monitor is initialized on first request."""
"""Ensure health monitor is initialized; launch reconciliation in background."""
global _reconciliation_started
if not _health_monitor_initialized:
_initialize_health_monitor()
if not _reconciliation_started:
_reconciliation_started = True
import threading
threading.Thread(target=_run_startup_reconciliation, daemon=True).start()
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=True)

View File

@@ -33,6 +33,29 @@ from src.web_interface.secret_helpers import (
separate_secrets,
)
_SECRET_KEY_PATTERN = re.compile(
r'(api_key|api_secret|password|secret|token|auth_key|credential)',
re.IGNORECASE,
)
def _conservative_mask_config(config, _parent_key=None):
"""Mask string values whose keys look like secrets (no schema available)."""
if isinstance(config, list):
return [
_conservative_mask_config(item, _parent_key) if isinstance(item, (dict, list))
else ('' if isinstance(item, str) and item and _parent_key and _SECRET_KEY_PATTERN.search(_parent_key) else item)
for item in config
]
result = dict(config)
for key, value in result.items():
if isinstance(value, dict):
result[key] = _conservative_mask_config(value)
elif isinstance(value, list):
result[key] = _conservative_mask_config(value, key)
elif isinstance(value, str) and value and _SECRET_KEY_PATTERN.search(key):
result[key] = ''
return result
# Will be initialized when blueprint is registered
config_manager = None
plugin_manager = None
@@ -2505,24 +2528,19 @@ def get_plugin_config():
}
# Mask secret fields before returning to prevent exposing API keys
# Fail closed — if schema unavailable, refuse to return unmasked config
schema_mgr = api_v3.schema_manager
if not schema_mgr:
return error_response(
ErrorCode.CONFIG_LOAD_FAILED,
f"Cannot safely return config for {plugin_id}: schema manager unavailable",
status_code=500
)
schema_for_mask = None
if schema_mgr:
try:
schema_for_mask = schema_mgr.load_schema(plugin_id, use_cache=True)
except Exception as e:
logger.error("[PluginConfig] Error loading schema for %s: %s", plugin_id, e, exc_info=True)
schema_for_mask = schema_mgr.load_schema(plugin_id, use_cache=True)
if not schema_for_mask or 'properties' not in schema_for_mask:
return error_response(
ErrorCode.CONFIG_LOAD_FAILED,
f"Cannot safely return config for {plugin_id}: schema unavailable for secret masking",
status_code=500
)
plugin_config = mask_secret_fields(plugin_config, schema_for_mask['properties'])
if schema_for_mask and 'properties' in schema_for_mask:
plugin_config = mask_secret_fields(plugin_config, schema_for_mask['properties'])
else:
logger.warning("[PluginConfig] Schema unavailable for %s, applying conservative masking", plugin_id)
plugin_config = _conservative_mask_config(plugin_config)
return success_response(data=plugin_config)
except Exception as e:

View File

@@ -1157,21 +1157,20 @@ function initializePluginPageWhenReady() {
// Strategy 3: HTMX afterSwap event (for HTMX-loaded content)
// This is the primary way plugins content is loaded
if (typeof htmx !== 'undefined') {
document.body.addEventListener('htmx:afterSwap', function(event) {
const target = event.detail.target;
// Check if plugins content was swapped in
if (target.id === 'plugins-content' ||
target.querySelector('#installed-plugins-grid') ||
document.getElementById('installed-plugins-grid')) {
console.log('HTMX swap detected for plugins, initializing...');
// Reset initialization flag to allow re-initialization after HTMX swap
window.pluginManager.initialized = false;
window.pluginManager.initializing = false;
initTimer = setTimeout(attemptInit, 100);
}
}, { once: false }); // Allow multiple swaps
}
// Register unconditionally — HTMX may load after this script (loaded dynamically from CDN)
// CustomEvent listeners work even before HTMX is available
document.body.addEventListener('htmx:afterSwap', function(event) {
const target = event.detail.target;
// Check if plugins content was swapped in (only match direct plugins content targets)
if (target.id === 'plugins-content' ||
target.querySelector('#installed-plugins-grid')) {
console.log('HTMX swap detected for plugins, initializing...');
// Reset initialization flag to allow re-initialization after HTMX swap
window.pluginManager.initialized = false;
window.pluginManager.initializing = false;
initTimer = setTimeout(attemptInit, 100);
}
}, { once: false }); // Allow multiple swaps
})();
// Initialization guard to prevent multiple initializations

View File

@@ -391,566 +391,51 @@
icon.classList.add('fa-chevron-right');
}
};
// Function to load plugins tab
window.loadPluginsTab = function() {
const content = document.getElementById('plugins-content');
if (content && !content.hasAttribute('data-loaded')) {
content.setAttribute('data-loaded', 'true');
console.log('Loading plugins directly via fetch...');
fetch('/v3/partials/plugins')
.then(r => r.text())
.then(html => {
// Parse HTML into a temporary container to extract scripts
const tempDiv = document.createElement('div');
tempDiv.innerHTML = html;
// Extract scripts BEFORE inserting into DOM (browser may remove them)
const scripts = Array.from(tempDiv.querySelectorAll('script'));
console.log('Found', scripts.length, 'scripts to execute');
// Insert content WITHOUT scripts first
const scriptsToExecute = [];
scripts.forEach(script => {
scriptsToExecute.push({
content: script.textContent || script.innerHTML,
src: script.src,
type: script.type
});
script.remove(); // Remove from temp div
});
// Now insert the HTML (without scripts)
content.innerHTML = tempDiv.innerHTML;
console.log('Plugins HTML loaded, executing', scriptsToExecute.length, 'scripts...');
// Execute scripts manually - ensure they run properly
if (scriptsToExecute.length > 0) {
try {
scriptsToExecute.forEach((scriptData, index) => {
try {
// Skip if script has no content and no src
const scriptContent = scriptData.content ? scriptData.content.trim() : '';
if (!scriptContent && !scriptData.src) {
return;
}
// Log script info for debugging
if (scriptContent) {
const preview = scriptContent.substring(0, 100).replace(/\n/g, ' ');
console.log(`[SCRIPT ${index + 1}] Content preview: ${preview}... (${scriptContent.length} chars)`);
// Check if this script defines our critical functions
if (scriptContent.includes('window.configurePlugin') || scriptContent.includes('window.togglePlugin')) {
console.log(`[SCRIPT ${index + 1}] ⚠️ This script should define configurePlugin/togglePlugin!`);
}
}
// Only execute if we have valid content
if (scriptContent || scriptData.src) {
// For inline scripts, use appendChild for reliable execution
if (scriptContent && !scriptData.src) {
// For very large scripts (>100KB), try fallback methods first
// as appendChild can sometimes have issues with large scripts
const isLargeScript = scriptContent.length > 100000;
if (isLargeScript) {
console.log(`[SCRIPT ${index + 1}] Large script detected (${scriptContent.length} chars), trying fallback methods first...`);
// Try Function constructor first for large scripts
let executed = false;
try {
const func = new Function('window', scriptContent);
func(window);
console.log(`[SCRIPT ${index + 1}] ✓ Executed large script via Function constructor`);
executed = true;
} catch (funcError) {
console.warn(`[SCRIPT ${index + 1}] Function constructor failed:`, funcError.message);
}
// If Function constructor failed, try indirect eval
if (!executed) {
try {
(0, eval)(scriptContent);
console.log(`[SCRIPT ${index + 1}] ✓ Executed large script via indirect eval`);
executed = true;
} catch (evalError) {
console.warn(`[SCRIPT ${index + 1}] Indirect eval failed:`, evalError.message);
}
}
// If both fallbacks worked, skip appendChild
if (executed) {
// Verify functions were defined
setTimeout(() => {
console.log(`[SCRIPT ${index + 1}] After fallback execution:`, {
configurePlugin: typeof window.configurePlugin,
togglePlugin: typeof window.togglePlugin
});
}, 50);
return; // Skip to next script (use return, not continue, in forEach)
}
}
try {
// Create new script element and append to head/body
// This ensures proper execution context and window attachment
const newScript = document.createElement('script');
if (scriptData.type) {
newScript.type = scriptData.type;
}
// Wrap in a promise to wait for execution
const scriptPromise = new Promise((resolve, reject) => {
// Set up error handler
newScript.onerror = (error) => {
reject(error);
};
// For inline scripts, execution happens synchronously when appended
// But we'll use a small delay to ensure it completes
try {
// Set textContent (not innerHTML) to avoid execution issues
// Note: We can't wrap in try-catch here as it would interfere with the script
// Instead, we rely on the script's own error handling
newScript.textContent = scriptContent;
// Append to head for better execution context
const target = document.head || document.body;
if (target) {
// Set up error handler to catch execution errors
newScript.onerror = (error) => {
console.error(`[SCRIPT ${index + 1}] Execution error:`, error);
reject(error);
};
// Check before execution
const beforeConfigurePlugin = typeof window.configurePlugin === 'function';
const beforeTogglePlugin = typeof window.togglePlugin === 'function';
// Declare variables in outer scope so setTimeout can access them
let afterConfigurePlugin = beforeConfigurePlugin;
let afterTogglePlugin = beforeTogglePlugin;
// Append and execute (execution is synchronous for inline scripts)
// Wrap in try-catch to catch any execution errors
try {
target.appendChild(newScript);
// Check immediately after append (inline scripts execute synchronously)
afterConfigurePlugin = typeof window.configurePlugin === 'function';
afterTogglePlugin = typeof window.togglePlugin === 'function';
console.log(`[SCRIPT ${index + 1}] Immediate check after appendChild:`, {
configurePlugin: { before: beforeConfigurePlugin, after: afterConfigurePlugin },
togglePlugin: { before: beforeTogglePlugin, after: afterTogglePlugin }
});
} catch (appendError) {
console.error(`[SCRIPT ${index + 1}] Error during appendChild:`, appendError);
console.error(`[SCRIPT ${index + 1}] Error message:`, appendError.message);
console.error(`[SCRIPT ${index + 1}] Error stack:`, appendError.stack);
// Try fallback execution methods immediately
console.warn(`[SCRIPT ${index + 1}] Attempting fallback execution methods...`);
let executed = false;
// Method 1: Function constructor
try {
const func = new Function('window', scriptContent);
func(window);
console.log(`[SCRIPT ${index + 1}] ✓ Executed via Function constructor (fallback)`);
executed = true;
} catch (funcError) {
console.warn(`[SCRIPT ${index + 1}] Function constructor failed:`, funcError.message);
if (funcError.stack) {
console.warn(`[SCRIPT ${index + 1}] Function constructor stack:`, funcError.stack);
}
// Try to find the line number if available
if (funcError.message.includes('line')) {
const lineMatch = funcError.message.match(/line (\d+)/);
if (lineMatch) {
const lineNum = parseInt(lineMatch[1]);
const lines = scriptContent.split('\n');
const start = Math.max(0, lineNum - 5);
const end = Math.min(lines.length, lineNum + 5);
console.warn(`[SCRIPT ${index + 1}] Context around error (lines ${start}-${end}):`,
lines.slice(start, end).join('\n'));
}
}
}
// Method 2: Indirect eval
if (!executed) {
try {
(0, eval)(scriptContent);
console.log(`[SCRIPT ${index + 1}] ✓ Executed via indirect eval (fallback)`);
executed = true;
} catch (evalError) {
console.warn(`[SCRIPT ${index + 1}] Indirect eval failed:`, evalError.message);
if (evalError.stack) {
console.warn(`[SCRIPT ${index + 1}] Indirect eval stack:`, evalError.stack);
}
}
}
// Check if functions are now defined
const fallbackConfigurePlugin = typeof window.configurePlugin === 'function';
const fallbackTogglePlugin = typeof window.togglePlugin === 'function';
console.log(`[SCRIPT ${index + 1}] After fallback attempts:`, {
configurePlugin: fallbackConfigurePlugin,
togglePlugin: fallbackTogglePlugin,
executed: executed
});
if (!executed) {
reject(appendError);
} else {
resolve();
}
}
// Also check after a small delay to catch any async definitions
setTimeout(() => {
const delayedConfigurePlugin = typeof window.configurePlugin === 'function';
const delayedTogglePlugin = typeof window.togglePlugin === 'function';
// Use the variables from the outer scope
if (delayedConfigurePlugin !== afterConfigurePlugin || delayedTogglePlugin !== afterTogglePlugin) {
console.log(`[SCRIPT ${index + 1}] Functions appeared after delay:`, {
configurePlugin: { immediate: afterConfigurePlugin, delayed: delayedConfigurePlugin },
togglePlugin: { immediate: afterTogglePlugin, delayed: delayedTogglePlugin }
});
}
resolve();
}, 100); // Small delay to catch any async definitions
} else {
reject(new Error('No target found for script execution'));
}
} catch (appendError) {
reject(appendError);
}
});
// Wait for script to execute (with timeout)
Promise.race([
scriptPromise,
new Promise((_, reject) => setTimeout(() => reject(new Error('Script execution timeout')), 1000))
]).catch(error => {
console.warn(`[SCRIPT ${index + 1}] Script execution issue, trying fallback:`, error);
// Fallback: try multiple execution methods
let executed = false;
// Method 1: Function constructor with window in scope
try {
const func = new Function('window', scriptContent);
func(window);
console.log(`[SCRIPT ${index + 1}] Executed via Function constructor (fallback method 1)`);
executed = true;
} catch (funcError) {
console.warn(`[SCRIPT ${index + 1}] Function constructor failed:`, funcError);
}
// Method 2: Direct eval in global scope (if method 1 failed)
if (!executed) {
try {
// Use indirect eval to execute in global scope
(0, eval)(scriptContent);
console.log(`[SCRIPT ${index + 1}] Executed via indirect eval (fallback method 2)`);
executed = true;
} catch (evalError) {
console.warn(`[SCRIPT ${index + 1}] Indirect eval failed:`, evalError);
}
}
// Verify functions after fallback
setTimeout(() => {
console.log(`[SCRIPT ${index + 1}] After fallback execution:`, {
configurePlugin: typeof window.configurePlugin,
togglePlugin: typeof window.togglePlugin,
executed: executed
});
}, 10);
if (!executed) {
console.error(`[SCRIPT ${index + 1}] All script execution methods failed`);
console.error(`[SCRIPT ${index + 1}] Script content (first 500 chars):`, scriptContent.substring(0, 500));
}
});
} catch (appendError) {
console.error('Failed to execute script:', appendError);
}
} else if (scriptData.src) {
// For external scripts, use appendChild
const newScript = document.createElement('script');
newScript.src = scriptData.src;
if (scriptData.type) {
newScript.type = scriptData.type;
}
const target = document.head || document.body;
if (target) {
target.appendChild(newScript);
}
console.log('Loaded external script', index + 1, 'of', scriptsToExecute.length);
}
}
} catch (scriptError) {
console.warn('Error executing script', index + 1, ':', scriptError);
}
});
// Wait a moment for scripts to execute, then verify functions are available
// Use multiple checks to ensure scripts have time to execute
let checkCount = 0;
const maxChecks = 10;
const checkInterval = setInterval(() => {
checkCount++;
const funcs = {
configurePlugin: typeof window.configurePlugin,
togglePlugin: typeof window.togglePlugin,
updatePlugin: typeof window.updatePlugin,
uninstallPlugin: typeof window.uninstallPlugin,
initializePlugins: typeof window.initializePlugins,
loadInstalledPlugins: typeof window.loadInstalledPlugins,
renderInstalledPlugins: typeof window.renderInstalledPlugins
};
if (checkCount === 1 || checkCount === maxChecks) {
console.log('Verifying plugin functions after script execution (check', checkCount, '):', funcs);
}
// Stop checking once critical functions are available or max checks reached
if ((funcs.configurePlugin === 'function' && funcs.togglePlugin === 'function') || checkCount >= maxChecks) {
clearInterval(checkInterval);
if (funcs.configurePlugin !== 'function' || funcs.togglePlugin !== 'function') {
console.error('Critical plugin functions not available after', checkCount, 'checks');
}
}
}, 100);
} catch (executionError) {
console.error('Script execution error:', executionError);
}
} else {
console.log('No scripts found in loaded HTML');
}
// Wait for scripts to execute, then load plugins
// CRITICAL: Wait for configurePlugin and togglePlugin to be defined before proceeding
let attempts = 0;
const maxAttempts = 20; // Increased to give more time
const checkInterval = setInterval(() => {
attempts++;
// First, ensure critical functions are available
const criticalFunctionsReady =
window.configurePlugin && typeof window.configurePlugin === 'function' &&
window.togglePlugin && typeof window.togglePlugin === 'function';
if (!criticalFunctionsReady && attempts < maxAttempts) {
if (attempts % 5 === 0) { // Log every 5th attempt
console.log(`Waiting for critical functions... (attempt ${attempts}/${maxAttempts})`, {
configurePlugin: typeof window.configurePlugin,
togglePlugin: typeof window.togglePlugin
});
}
return; // Keep waiting
}
if (!criticalFunctionsReady) {
console.error('Critical functions (configurePlugin, togglePlugin) not available after', maxAttempts, 'attempts');
clearInterval(checkInterval);
return;
}
console.log('Critical functions ready, proceeding with plugin initialization...');
clearInterval(checkInterval);
// Now try to call initializePlugins first (loads both installed and store)
if (window.initializePlugins && typeof window.initializePlugins === 'function') {
console.log('Found initializePlugins, calling it...');
window.initializePlugins();
} else if (window.loadInstalledPlugins && typeof window.loadInstalledPlugins === 'function') {
console.log('Found loadInstalledPlugins, calling it...');
window.loadInstalledPlugins();
// Also try to load plugin store
if (window.searchPluginStore && typeof window.searchPluginStore === 'function') {
setTimeout(() => window.searchPluginStore(true), 500);
}
} else if (window.pluginManager && window.pluginManager.loadInstalledPlugins) {
console.log('Found pluginManager.loadInstalledPlugins, calling it...');
window.pluginManager.loadInstalledPlugins();
// Also try to load plugin store
setTimeout(() => {
const searchFn = window.searchPluginStore ||
(window.pluginManager && window.pluginManager.searchPluginStore);
if (searchFn && typeof searchFn === 'function') {
console.log('Loading plugin store...');
searchFn(true);
} else {
console.warn('searchPluginStore not available');
}
}, 500);
} else if (attempts >= maxAttempts) {
console.log('loadInstalledPlugins not found after', maxAttempts, 'attempts, fetching and rendering directly...');
clearInterval(checkInterval);
// Load both installed plugins and plugin store
Promise.all([
// Use batched API requests for better performance
window.PluginAPI && window.PluginAPI.batch ?
window.PluginAPI.batch([
{endpoint: '/plugins/installed', method: 'GET'},
{endpoint: '/plugins/store/list?fetch_commit_info=true', method: 'GET'}
]).then(([installedRes, storeRes]) => {
return [installedRes, storeRes];
}) :
Promise.all([
getInstalledPluginsSafe(),
fetch('/api/v3/plugins/store/list?fetch_commit_info=true').then(r => r.json())
])
]).then(([installedData, storeData]) => {
console.log('Fetched plugins:', installedData);
console.log('Fetched store:', storeData);
// Render installed plugins
if (installedData.status === 'success') {
const plugins = installedData.data.plugins || [];
const container = document.getElementById('installed-plugins-grid');
const countEl = document.getElementById('installed-count');
// Try renderInstalledPlugins one more time
if (window.renderInstalledPlugins && typeof window.renderInstalledPlugins === 'function') {
console.log('Using renderInstalledPlugins...');
window.renderInstalledPlugins(plugins);
} else if (container) {
console.log('renderInstalledPlugins not available, rendering full plugin cards manually...');
// Render full plugin cards with all information
const escapeHtml = function(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
};
const escapeAttr = function(text) {
return (text || '').replace(/'/g, "\\'").replace(/"/g, '&quot;');
};
const escapeJs = function(text) {
return JSON.stringify(text || '');
};
const formatCommit = function(commit, branch) {
if (!commit && !branch) return 'Unknown';
const shortCommit = commit ? String(commit).substring(0, 7) : '';
const branchText = branch ? String(branch) : '';
if (branchText && shortCommit) return branchText + ' · ' + shortCommit;
if (branchText) return branchText;
if (shortCommit) return shortCommit;
return 'Unknown';
};
const formatDate = function(dateString) {
if (!dateString) return 'Unknown';
try {
const date = new Date(dateString);
if (isNaN(date.getTime())) return 'Unknown';
const now = new Date();
const diffDays = Math.ceil(Math.abs(now - date) / (1000 * 60 * 60 * 24));
if (diffDays < 1) return 'Today';
if (diffDays < 2) return 'Yesterday';
if (diffDays < 7) return diffDays + ' days ago';
if (diffDays < 30) {
const weeks = Math.floor(diffDays / 7);
return weeks + (weeks === 1 ? ' week' : ' weeks') + ' ago';
}
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
} catch (e) {
return 'Unknown';
}
};
container.innerHTML = plugins.map(function(p) {
const name = escapeHtml(p.name || p.id);
const desc = escapeHtml(p.description || 'No description available');
const author = escapeHtml(p.author || 'Unknown');
const category = escapeHtml(p.category || 'General');
const enabled = p.enabled ? 'checked' : '';
const enabledBool = Boolean(p.enabled);
const escapedId = escapeAttr(p.id);
const verified = p.verified ? '<span class="badge badge-success"><i class="fas fa-check-circle mr-1"></i>Verified</span>' : '';
const tags = (p.tags && p.tags.length > 0) ? '<div class="flex flex-wrap gap-1.5 mb-4">' + p.tags.map(function(tag) { return '<span class="badge badge-info">' + escapeHtml(tag) + '</span>'; }).join('') + '</div>' : '';
const escapedJsId = escapeJs(p.id);
return '<div class="plugin-card"><div class="flex items-start justify-between mb-4"><div class="flex-1 min-w-0"><div class="flex items-center flex-wrap gap-2 mb-2"><h4 class="font-semibold text-gray-900 text-base">' + name + '</h4>' + verified + '</div><div class="text-sm text-gray-600 space-y-1.5 mb-3"><p class="flex items-center"><i class="fas fa-user mr-2 text-gray-400 w-4"></i>' + author + '</p><p class="flex items-center"><i class="fas fa-code-branch mr-2 text-gray-400 w-4"></i>' + formatCommit(p.last_commit, p.branch) + '</p><p class="flex items-center"><i class="fas fa-calendar mr-2 text-gray-400 w-4"></i>' + formatDate(p.last_updated) + '</p><p class="flex items-center"><i class="fas fa-folder mr-2 text-gray-400 w-4"></i>' + category + '</p></div><p class="text-sm text-gray-700 leading-relaxed">' + desc + '</p></div><div class="flex-shrink-0 ml-4"><label class="relative inline-flex items-center cursor-pointer group"><input type="checkbox" class="sr-only peer" id="toggle-' + escapedId + '" ' + enabled + ' data-plugin-id="' + escapedId + '" data-action="toggle" onchange=\'if(window.togglePlugin){window.togglePlugin(' + escapedJsId + ', this.checked)}else{console.error("togglePlugin not available")}\'><div class="flex items-center gap-2 px-3 py-1.5 rounded-lg border-2 transition-all duration-200 ' + (enabledBool ? 'bg-green-50 border-green-500' : 'bg-gray-50 border-gray-300') + ' hover:shadow-md group-hover:scale-105"><div class="relative w-14 h-7 ' + (enabledBool ? 'bg-green-500' : 'bg-gray-300') + ' rounded-full peer peer-checked:bg-green-500 transition-colors duration-200 ease-in-out shadow-inner"><div class="absolute top-[3px] left-[3px] bg-white ' + (enabledBool ? 'translate-x-full' : '') + ' border-2 ' + (enabledBool ? 'border-green-500' : 'border-gray-400') + ' rounded-full h-5 w-5 transition-all duration-200 ease-in-out shadow-sm flex items-center justify-center">' + (enabledBool ? '<i class="fas fa-check text-green-600 text-xs"></i>' : '<i class="fas fa-times text-gray-400 text-xs"></i>') + '</div></div><span class="text-sm font-semibold ' + (enabledBool ? 'text-green-700' : 'text-gray-600') + ' flex items-center gap-1.5"><i class="fas ' + (enabledBool ? 'fa-toggle-on text-green-600' : 'fa-toggle-off text-gray-400') + '"></i><span>' + (enabledBool ? 'Enabled' : 'Disabled') + '</span></span></div></label></div></div>' + tags + '<div class="flex flex-wrap gap-2 mt-4 pt-4 border-t border-gray-200"><button onclick=\'if(window.configurePlugin){window.configurePlugin(' + escapedJsId + ')}else{console.error("configurePlugin not available")}\' class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm flex-1 font-semibold" data-plugin-id="' + escapedId + '" data-action="configure"><i class="fas fa-cog mr-2"></i>Configure</button><button onclick=\'if(window.updatePlugin){window.updatePlugin(' + escapedJsId + ')}else{console.error("updatePlugin not available")}\' class="btn bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm flex-1 font-semibold" data-plugin-id="' + escapedId + '" data-action="update"><i class="fas fa-sync mr-2"></i>Update</button><button onclick=\'if(window.uninstallPlugin){window.uninstallPlugin(' + escapedJsId + ')}else{console.error("uninstallPlugin not available")}\' class="btn bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-md text-sm flex-1 font-semibold" data-plugin-id="' + escapedId + '" data-action="uninstall"><i class="fas fa-trash mr-2"></i>Uninstall</button></div></div>';
}).join('');
if (countEl) countEl.textContent = plugins.length + ' installed';
window.installedPlugins = plugins;
console.log('Rendered', plugins.length, 'plugins with full cards');
} else {
console.error('installed-plugins-grid container not found');
}
}
// Render plugin store
if (storeData.status === 'success') {
const storePlugins = storeData.data.plugins || [];
const storeContainer = document.getElementById('plugin-store-grid');
const storeCountEl = document.getElementById('store-count');
if (storeContainer) {
// Try renderPluginStore if available
if (window.renderPluginStore && typeof window.renderPluginStore === 'function') {
console.log('Using renderPluginStore...');
window.renderPluginStore(storePlugins);
} else {
// Manual rendering fallback
console.log('renderPluginStore not available, rendering manually...');
const escapeHtml = function(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
};
const escapeJs = function(text) {
return JSON.stringify(text || '');
};
storeContainer.innerHTML = storePlugins.map(function(p) {
const name = escapeHtml(p.name || p.id);
const desc = escapeHtml(p.description || 'No description available');
const author = escapeHtml(p.author || 'Unknown');
const category = escapeHtml(p.category || 'General');
const stars = p.stars || 0;
const verified = p.verified ? '<span class="badge badge-success"><i class="fas fa-check-circle mr-1"></i>Verified</span>' : '';
const escapedJsId = escapeJs(p.id);
return '<div class="plugin-card"><div class="flex items-start justify-between mb-4"><div class="flex-1 min-w-0"><div class="flex items-center flex-wrap gap-2 mb-2"><h4 class="font-semibold text-gray-900 text-base">' + name + '</h4>' + verified + '</div><div class="text-sm text-gray-600 space-y-1.5 mb-3"><p class="flex items-center"><i class="fas fa-user mr-2 text-gray-400 w-4"></i>' + author + '</p><p class="flex items-center"><i class="fas fa-folder mr-2 text-gray-400 w-4"></i>' + category + '</p>' + (stars > 0 ? '<p class="flex items-center"><i class="fas fa-star mr-2 text-gray-400 w-4"></i>' + stars + ' stars</p>' : '') + '</div><p class="text-sm text-gray-700 leading-relaxed">' + desc + '</p></div></div><div class="flex flex-wrap gap-2 mt-4 pt-4 border-t border-gray-200"><button onclick=\'if(window.installPlugin){window.installPlugin(' + escapedJsId + ')}else{console.error("installPlugin not available")}\' class="btn bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md text-sm flex-1 font-semibold"><i class="fas fa-download mr-2"></i>Install</button></div></div>';
}).join('');
}
if (storeCountEl) {
storeCountEl.innerHTML = storePlugins.length + ' available';
}
console.log('Rendered', storePlugins.length, 'store plugins');
} else {
console.error('plugin-store-grid container not found');
}
} else {
console.error('Failed to load plugin store:', storeData.message);
const storeCountEl = document.getElementById('store-count');
if (storeCountEl) {
storeCountEl.innerHTML = '<span class="text-red-600">Error loading store</span>';
}
}
})
.catch(err => {
console.error('Error fetching plugins/store:', err);
// Still try to render installed plugins if store fails
});
}
}, 100); // Reduced from 200ms to 100ms for faster retries
})
.catch(err => console.error('Error loading plugins:', err));
}
};
})();
</script>
<!-- Fallback for loading plugins if HTMX fails -->
<script>
function loadPluginsDirect() {
const content = document.getElementById('plugins-content');
if (content && !content.hasAttribute('data-loaded')) {
content.setAttribute('data-loaded', 'true');
console.log('Loading plugins directly via fetch (HTMX fallback)...');
fetch('/v3/partials/plugins')
.then(r => r.text())
.then(html => {
content.innerHTML = html;
// Trigger full initialization chain
if (window.pluginManager) {
window.pluginManager.initialized = false;
window.pluginManager.initializing = false;
}
if (window.initPluginsPage) {
window.initPluginsPage();
}
})
.catch(err => {
console.error('Failed to load plugins:', err);
content.removeAttribute('data-loaded');
content.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4"><p class="text-red-800">Failed to load Plugin Manager. Please refresh the page.</p></div>';
});
}
}
// Fallback if HTMX doesn't load within 5 seconds
setTimeout(() => {
if (typeof htmx === 'undefined') {
console.warn('HTMX not loaded after 5 seconds, using direct fetch for plugins');
const appElement = document.querySelector('[x-data="app()"]');
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
const activeTab = appElement._x_dataStack[0].activeTab;
if (activeTab === 'plugins') {
loadPluginsDirect();
}
}
}
}, 5000);
</script>
<!-- Alpine.js app function - defined early so it's available when Alpine initializes -->
<script>
// Helper function to get installed plugins with fallback
@@ -1491,7 +976,7 @@
<!-- Second row - Plugin tabs (populated dynamically) -->
<div id="plugin-tabs-row" class="border-b border-gray-200">
<nav class="-mb-px flex flex-wrap gap-y-2 gap-x-2 lg:gap-x-3 xl:gap-x-4">
<button @click="activeTab = 'plugins'; $nextTick(() => { if (typeof htmx !== 'undefined' && !document.getElementById('plugins-content').hasAttribute('data-loaded')) { htmx.trigger('#plugins-content', 'load'); } })"
<button @click="activeTab = 'plugins'"
:class="activeTab === 'plugins' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-plug"></i>Plugin Manager
@@ -1683,10 +1168,9 @@
</div>
<!-- Plugins tab -->
<div x-show="activeTab === 'plugins'"
x-transition
x-effect="if (activeTab === 'plugins') { window.loadPluginsTab && window.loadPluginsTab(); }">
<div id="plugins-content">
<div x-show="activeTab === 'plugins'" x-transition>
<div id="plugins-content" hx-get="/v3/partials/plugins" hx-trigger="revealed" hx-swap="innerHTML"
hx-on::htmx:response-error="loadPluginsDirect()">
<div class="animate-pulse">
<div class="bg-white rounded-lg shadow p-6">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>