Files
LEDMatrix/scripts/dev/dev_plugin_setup.sh
Chuck 781224591f fix: post-audit follow-up code fixes (cache, fonts, icons, dev script) (#307)
* fix: post-audit follow-up code fixes (cache, fonts, icons, dev script, CI)

The docs refresh effort (#306, ledmatrix-plugins#92) surfaced seven
code bugs that were intentionally left out of the docs PRs because
they required code changes rather than doc fixes. This PR addresses
the six that belong in LEDMatrix (the seventh — a lacrosse-scoreboard
mode rename — lives in the plugins repo).

Bug 1: cache_manager.delete() AttributeError
  src/common/api_helper.py:287 and
  src/plugin_system/resource_monitor.py:343 both call
  cache_manager.delete(key), which doesn't exist — only
  clear_cache(key=None). Added a delete() alias method on
  CacheManager that forwards to clear_cache(key). Reverts the
  "There is no delete() method" wording in DEVELOPER_QUICK_REFERENCE,
  .cursorrules so the docs match the new shim.

Bug 2: dev_plugin_setup.sh PROJECT_ROOT resolution
  scripts/dev/dev_plugin_setup.sh:9 set PROJECT_ROOT to SCRIPT_DIR
  instead of walking up two levels to the repo root, so PLUGINS_DIR
  resolved to scripts/dev/plugins/ and created symlinks under the
  script's own directory. Fixed the path and removed the stray
  scripts/dev/plugins/of-the-day symlink left by earlier runs.

Bug 3: plugin custom icons regressed from v2 to v3
  web_interface/blueprints/api_v3.py built the /plugins/installed
  response without including the manifest's "icon" field, and
  web_interface/templates/v3/base.html hardcoded
  fas fa-puzzle-piece in all three plugin-tab render sites. Pass
  the icon through the API and read it from the templates with a
  puzzle-piece fallback. Reverts the "currently broken" banners in
  docs/PLUGIN_CUSTOM_ICONS.md and docs/PLUGIN_CUSTOM_ICONS_FEATURE.md.

Bug 4: register_plugin_fonts was never wired up
  src/font_manager.py:150 defines register_plugin_fonts(plugin_id,
  font_manifest) but nothing called it, so plugin manifests with a
  "fonts" block were silently no-ops. Wired the call into
  PluginManager.load_plugin() right after plugin_loader.load_plugin
  returns. Reverts the "not currently wired" warning in
  docs/FONT_MANAGER.md's "For Plugin Developers" section.

Bug 5: dead web_interface_v2 import pattern (LEDMatrix half)
  src/base_odds_manager.py had a try/except importing
  web_interface_v2.increment_api_counter, falling back to a no-op
  stub. The module doesn't exist anywhere in the v3 codebase and
  no API metrics dashboard reads it. Deleted the import block and
  the single call site; the plugins-repo half of this cleanup lands
  in ledmatrix-plugins#<next>.

Bug 7: no CI test workflow
  .github/workflows/ only contained security-audit.yml; pytest ran
  locally but was not gated on PRs. Added
  .github/workflows/tests.yml running pytest against Python 3.10,
  3.11, 3.12 in EMULATOR=true mode, skipping tests marked hardware
  or slow. Updated docs/HOW_TO_RUN_TESTS.md to reflect that the
  workflow now exists.

Verification done locally:
  - CacheManager.delete(key) round-trips with set/get
  - base_odds_manager imports without the v2 module present
  - dev_plugin_setup.sh PROJECT_ROOT resolves to repo root
  - api_v3 and plugin_manager compile clean
  - tests.yml YAML parses
  - Script syntax check on dev_plugin_setup.sh

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address CodeRabbit review comments on #307

- src/cache_manager.py: clear_cache(key) treated empty string as
  "wipe all" because of `if key:`. Switched to `key is None`
  branching, made delete(key) and clear_cache(key) reject empty
  strings and None outright with ValueError, and updated both
  docstrings to make the contract explicit. Verified locally
  with a round-trip test that clear_cache() (no arg) still
  wipes everything but clear_cache("") and delete("") raise.

- src/plugin_system/plugin_manager.py: was reaching for the
  font manager via getattr(self.display_manager, 'font_manager',
  None). PluginManager already takes a dedicated font_manager
  parameter (line 54) and stores it as self.font_manager
  (line 69), so the old path was both wrong and could miss the
  font manager entirely when the host injects them separately.
  Switched to self.font_manager directly with the same try/except
  warning behavior.

- web_interface/templates/v3/base.html: in the full plugin-tab
  renderer, the icon was injected with
  `<i class="${escapeHtml(plugin.icon)}">` — but escapeHtml only
  escapes <, >, and &, not double quotes, so a manifest with a
  quote in its icon string could break out of the class
  attribute. Replaced the innerHTML template with createElement
  for the <i> tag, set className from plugin.icon directly
  (no string interpolation), and used a text node for the
  label. Same fix shape would also harden the two stub-renderer
  sites at line 515 / 774, but those already escape `"` to
  &quot; and CodeRabbit only flagged this site, so leaving them
  for now.

- docs/FONT_MANAGER.md: clarified that the Manual Font Overrides
  *workflow* (set_override / remove_override / font_overrides.json)
  is the supported override path today, and only the Fonts tab
  in the web UI is the placeholder. Previous wording conflated
  the two and made it sound like overrides themselves were
  broken.

- docs/HOW_TO_RUN_TESTS.md: replaced the vague "see the PR
  adding it" with a concrete link to #307 and a note that the
  workflow file itself is held back pending the workflow scope.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 09:25:12 -04:00

526 lines
15 KiB
Bash
Executable File

#!/bin/bash
# LEDMatrix Plugin Development Setup Script
# Manages symbolic links between plugin repositories and the plugins/ directory
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
PLUGINS_DIR="$PROJECT_ROOT/plugins"
CONFIG_FILE="$PROJECT_ROOT/dev_plugins.json"
DEFAULT_DEV_DIR="$HOME/.ledmatrix-dev-plugins"
GITHUB_USER="ChuckBuilds"
GITHUB_PATTERN="ledmatrix-"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Logging functions
log_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
log_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
log_warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# Load configuration file
load_config() {
if [[ -f "$CONFIG_FILE" ]]; then
DEV_PLUGINS_DIR=$(jq -r '.dev_plugins_dir // "'"$DEFAULT_DEV_DIR"'"' "$CONFIG_FILE" 2>/dev/null || echo "$DEFAULT_DEV_DIR")
# Expand ~ in path
DEV_PLUGINS_DIR="${DEV_PLUGINS_DIR/#\~/$HOME}"
else
DEV_PLUGINS_DIR="$DEFAULT_DEV_DIR"
fi
mkdir -p "$DEV_PLUGINS_DIR"
}
# Validate plugin structure
validate_plugin() {
local plugin_path="$1"
if [[ ! -f "$plugin_path/manifest.json" ]]; then
log_error "Plugin directory does not contain manifest.json: $plugin_path"
return 1
fi
return 0
}
# Get plugin ID from manifest
get_plugin_id() {
local plugin_path="$1"
if [[ -f "$plugin_path/manifest.json" ]]; then
jq -r '.id // empty' "$plugin_path/manifest.json" 2>/dev/null || echo ""
fi
}
# Check if path is a symlink
is_symlink() {
[[ -L "$1" ]]
}
# Check if plugin directory exists
plugin_exists() {
[[ -e "$PLUGINS_DIR/$1" ]]
}
# Get symlink target
get_symlink_target() {
if is_symlink "$PLUGINS_DIR/$1"; then
readlink -f "$PLUGINS_DIR/$1"
else
echo ""
fi
}
# Link a local plugin repository
link_plugin() {
local plugin_name="$1"
local repo_path="$2"
if [[ -z "$plugin_name" ]] || [[ -z "$repo_path" ]]; then
log_error "Usage: $0 link <plugin-name> <repo-path>"
exit 1
fi
# Resolve absolute path
if [[ ! "$repo_path" = /* ]]; then
repo_path="$(cd "$(dirname "$repo_path")" && pwd)/$(basename "$repo_path")"
fi
if [[ ! -d "$repo_path" ]]; then
log_error "Repository path does not exist: $repo_path"
exit 1
fi
# Validate plugin structure
if ! validate_plugin "$repo_path"; then
exit 1
fi
# Check for existing plugin
if plugin_exists "$plugin_name"; then
if is_symlink "$PLUGINS_DIR/$plugin_name"; then
local target=$(get_symlink_target "$plugin_name")
if [[ "$target" == "$repo_path" ]]; then
log_info "Plugin $plugin_name is already linked to $repo_path"
return 0
else
log_warn "Plugin $plugin_name exists as symlink to $target"
read -p "Replace existing symlink? (y/N): " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
log_info "Aborted"
exit 0
fi
rm "$PLUGINS_DIR/$plugin_name"
fi
else
log_warn "Plugin directory exists but is not a symlink: $PLUGINS_DIR/$plugin_name"
read -p "Backup and replace? (y/N): " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
log_info "Aborted"
exit 0
fi
mv "$PLUGINS_DIR/$plugin_name" "$PLUGINS_DIR/${plugin_name}.backup.$(date +%Y%m%d%H%M%S)"
fi
fi
# Create symlink
ln -s "$repo_path" "$PLUGINS_DIR/$plugin_name"
local plugin_id=$(get_plugin_id "$repo_path")
if [[ -n "$plugin_id" ]] && [[ "$plugin_id" != "$plugin_name" ]]; then
log_warn "Plugin ID in manifest ($plugin_id) differs from directory name ($plugin_name)"
fi
log_success "Linked $plugin_name to $repo_path"
}
# Clone repository from GitHub
clone_from_github() {
local repo_url="$1"
local target_dir="$2"
local branch="${3:-}"
log_info "Cloning $repo_url to $target_dir"
local clone_cmd=("git" "clone")
if [[ -n "$branch" ]]; then
clone_cmd+=("--branch" "$branch")
fi
clone_cmd+=("--depth" "1" "$repo_url" "$target_dir")
if ! "${clone_cmd[@]}"; then
log_error "Failed to clone repository"
return 1
fi
log_success "Cloned repository successfully"
return 0
}
# Link plugin from GitHub
link_github_plugin() {
local plugin_name="$1"
local repo_url="${2:-}"
if [[ -z "$plugin_name" ]]; then
log_error "Usage: $0 link-github <plugin-name> [repo-url]"
exit 1
fi
load_config
# Construct repo URL if not provided
if [[ -z "$repo_url" ]]; then
repo_url="https://github.com/${GITHUB_USER}/${GITHUB_PATTERN}${plugin_name}.git"
log_info "Using default GitHub URL: $repo_url"
fi
# Determine target directory name from URL
local repo_name=$(basename "$repo_url" .git)
local target_dir="$DEV_PLUGINS_DIR/$repo_name"
# Check if already cloned
if [[ -d "$target_dir" ]]; then
log_info "Repository already exists at $target_dir"
if [[ -d "$target_dir/.git" ]]; then
log_info "Updating repository..."
(cd "$target_dir" && git pull --rebase || true)
fi
else
# Clone the repository
if ! clone_from_github "$repo_url" "$target_dir"; then
exit 1
fi
fi
# Validate plugin structure
if ! validate_plugin "$target_dir"; then
log_error "Cloned repository does not appear to be a valid plugin"
exit 1
fi
# Link the plugin
link_plugin "$plugin_name" "$target_dir"
}
# Unlink a plugin
unlink_plugin() {
local plugin_name="$1"
if [[ -z "$plugin_name" ]]; then
log_error "Usage: $0 unlink <plugin-name>"
exit 1
fi
if ! plugin_exists "$plugin_name"; then
log_error "Plugin does not exist: $plugin_name"
exit 1
fi
if ! is_symlink "$PLUGINS_DIR/$plugin_name"; then
log_warn "Plugin $plugin_name is not a symlink. Cannot unlink."
exit 1
fi
local target=$(get_symlink_target "$plugin_name")
rm "$PLUGINS_DIR/$plugin_name"
log_success "Unlinked $plugin_name (repository preserved at $target)"
}
# List all plugins
list_plugins() {
if [[ ! -d "$PLUGINS_DIR" ]]; then
log_error "Plugins directory does not exist: $PLUGINS_DIR"
exit 1
fi
echo -e "${BLUE}Plugin Status:${NC}"
echo "==============="
echo
local has_plugins=false
for item in "$PLUGINS_DIR"/*; do
[[ -e "$item" ]] || continue
[[ -d "$item" ]] || continue
local plugin_name=$(basename "$item")
[[ "$plugin_name" =~ ^\.|^_ ]] && continue
has_plugins=true
if is_symlink "$item"; then
local target=$(get_symlink_target "$plugin_name")
echo -e "${GREEN}${NC} ${BLUE}$plugin_name${NC} (symlink)"
echo "$target"
# Check git status if it's a git repo
if [[ -d "$target/.git" ]]; then
local branch=$(cd "$target" && git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
local status=$(cd "$target" && git status --porcelain 2>/dev/null | head -1)
if [[ -n "$status" ]]; then
echo -e " ${YELLOW}⚠ Git repo has uncommitted changes${NC} (branch: $branch)"
else
echo -e " ${GREEN}✓ Git repo is clean${NC} (branch: $branch)"
fi
fi
else
echo -e "${YELLOW}${NC} ${BLUE}$plugin_name${NC} (regular directory)"
fi
echo
done
if [[ "$has_plugins" == false ]]; then
log_info "No plugins found in $PLUGINS_DIR"
fi
}
# Check status of all linked plugins
check_status() {
if [[ ! -d "$PLUGINS_DIR" ]]; then
log_error "Plugins directory does not exist: $PLUGINS_DIR"
exit 1
fi
echo -e "${BLUE}Plugin Development Status:${NC}"
echo "========================="
echo
local broken_count=0
local clean_count=0
local dirty_count=0
for item in "$PLUGINS_DIR"/*; do
[[ -e "$item" ]] || continue
[[ -d "$item" ]] || continue
local plugin_name=$(basename "$item")
[[ "$plugin_name" =~ ^\.|^_ ]] && continue
if is_symlink "$item"; then
if [[ ! -e "$item" ]]; then
echo -e "${RED}${NC} ${BLUE}$plugin_name${NC} - ${RED}BROKEN SYMLINK${NC}"
broken_count=$((broken_count + 1))
continue
fi
local target=$(get_symlink_target "$plugin_name")
echo -e "${GREEN}${NC} ${BLUE}$plugin_name${NC}"
echo " Path: $target"
if [[ -d "$target/.git" ]]; then
local branch=$(cd "$target" && git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
local remote=$(cd "$target" && git remote get-url origin 2>/dev/null || echo "no remote")
local commits_behind=$(cd "$target" && git rev-list --count HEAD..@{upstream} 2>/dev/null || echo "0")
local commits_ahead=$(cd "$target" && git rev-list --count @{upstream}..HEAD 2>/dev/null || echo "0")
local status=$(cd "$target" && git status --porcelain 2>/dev/null)
echo " Branch: $branch"
echo " Remote: $remote"
if [[ -n "$status" ]]; then
echo -e " ${YELLOW}Status: Has uncommitted changes${NC}"
dirty_count=$((dirty_count + 1))
elif [[ "$commits_behind" != "0" ]] || [[ "$commits_ahead" != "0" ]]; then
if [[ "$commits_behind" != "0" ]]; then
echo -e " ${YELLOW}Status: $commits_behind commit(s) behind remote${NC}"
fi
if [[ "$commits_ahead" != "0" ]]; then
echo -e " ${GREEN}Status: $commits_ahead commit(s) ahead of remote${NC}"
fi
dirty_count=$((dirty_count + 1))
else
echo -e " ${GREEN}Status: Clean and up to date${NC}"
clean_count=$((clean_count + 1))
fi
else
echo " (Not a git repository)"
fi
echo
fi
done
echo "Summary:"
echo " ${GREEN}Clean: $clean_count${NC}"
echo " ${YELLOW}Needs attention: $dirty_count${NC}"
[[ $broken_count -gt 0 ]] && echo -e " ${RED}Broken: $broken_count${NC}"
}
# Update plugin(s)
update_plugins() {
local plugin_name="${1:-}"
load_config
if [[ -n "$plugin_name" ]]; then
# Update single plugin
if ! plugin_exists "$plugin_name"; then
log_error "Plugin does not exist: $plugin_name"
exit 1
fi
if ! is_symlink "$PLUGINS_DIR/$plugin_name"; then
log_error "Plugin $plugin_name is not a symlink"
exit 1
fi
local target=$(get_symlink_target "$plugin_name")
if [[ ! -d "$target/.git" ]]; then
log_error "Plugin repository is not a git repository: $target"
exit 1
fi
log_info "Updating $plugin_name from $target"
(cd "$target" && git pull --rebase)
log_success "Updated $plugin_name"
else
# Update all linked plugins
log_info "Updating all linked plugins..."
local updated=0
local failed=0
for item in "$PLUGINS_DIR"/*; do
[[ -e "$item" ]] || continue
[[ -d "$item" ]] || continue
local name=$(basename "$item")
[[ "$name" =~ ^\.|^_ ]] && continue
if is_symlink "$item"; then
local target=$(get_symlink_target "$name")
if [[ -d "$target/.git" ]]; then
log_info "Updating $name..."
if (cd "$target" && git pull --rebase); then
log_success "Updated $name"
updated=$((updated + 1))
else
log_error "Failed to update $name"
failed=$((failed + 1))
fi
fi
fi
done
echo
log_info "Update complete: $updated succeeded, $failed failed"
fi
}
# Show usage
show_usage() {
cat << EOF
LEDMatrix Plugin Development Setup
Usage: $0 <command> [options]
Commands:
link <plugin-name> <repo-path>
Link a local plugin repository to the plugins directory
link-github <plugin-name> [repo-url]
Clone and link a plugin from GitHub
If repo-url is not provided, uses: https://github.com/${GITHUB_USER}/${GITHUB_PATTERN}<plugin-name>.git
unlink <plugin-name>
Remove symlink for a plugin (preserves repository)
list
List all plugins and their link status
status
Check status of all linked plugins (git status, branch, etc.)
update [plugin-name]
Update plugin(s) from git repository
If plugin-name is omitted, updates all linked plugins
help
Show this help message
Examples:
# Link a local plugin
$0 link music ../ledmatrix-music
# Link from GitHub (auto-detects URL)
$0 link-github music
# Link from GitHub with custom URL
$0 link-github stocks https://github.com/ChuckBuilds/ledmatrix-stocks.git
# Check status
$0 status
# Update all plugins
$0 update
Configuration:
Create dev_plugins.json in project root to customize:
- dev_plugins_dir: Where to clone GitHub repos (default: ~/.ledmatrix-dev-plugins)
- plugins: Plugin definitions (optional, for auto-discovery)
EOF
}
# Main command dispatcher
main() {
# Ensure plugins directory exists
mkdir -p "$PLUGINS_DIR"
case "${1:-}" in
link)
shift
link_plugin "$@"
;;
link-github)
shift
link_github_plugin "$@"
;;
unlink)
shift
unlink_plugin "$@"
;;
list)
list_plugins
;;
status)
check_status
;;
update)
shift
update_plugins "$@"
;;
help|--help|-h|"")
show_usage
;;
*)
log_error "Unknown command: $1"
echo
show_usage
exit 1
;;
esac
}
main "$@"