diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 5501a50..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "env": { - "browser": true, - "es2021": true - }, - "plugins": ["html"], - "settings": { - "html/html-extensions": [".html"] - }, - "root": true, - "rules": { - "no-undef": "warn", - "no-unused-vars": "warn", - "no-console": "off", - "semi": ["warn", "always"], - "eqeqeq": ["warn", "always"], - "no-eval": "error", - "no-implied-eval": "error" - }, - "globals": { - "io": "readonly", - "bootstrap": "readonly", - "Chart": "readonly", - "L": "readonly", - "escapeHtml": "writable", - "socket": "writable" - } -} diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 56ccee8..6d96a73 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -28,21 +28,21 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 # QEMU is required for cross-platform builds (arm64, arm/v7). # Without it, BuildKit cannot emulate non-native architectures. - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 with: platforms: linux/arm64,linux/arm/v7 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Log in to Container Registry if: github.event_name != 'pull_request' - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -50,7 +50,7 @@ jobs: - name: Extract metadata (tags, labels) for Docker id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | @@ -72,7 +72,7 @@ jobs: fi - name: Build and push Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . # Push on all non-PR events; PRs only validate the build. diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index b5a6459..6022102 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -14,10 +14,10 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version: "3.x" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a170910..391fc30 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,12 +18,12 @@ jobs: name: Lint Frontend (HTMLHint + ESLint) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: - node-version: "20" + node-version: "24" cache: npm - name: Install frontend lint tools @@ -39,7 +39,7 @@ jobs: name: Lint Shell Scripts (ShellCheck) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Install ShellCheck run: sudo apt-get install -y shellcheck @@ -58,10 +58,10 @@ jobs: name: Lint (ruff) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version: "3.11" cache: pip @@ -92,10 +92,10 @@ jobs: name: Type check (mypy) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version: "3.11" cache: pip @@ -113,10 +113,10 @@ jobs: name: Build and smoke-test distributions runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version: "3.11" cache: pip @@ -145,10 +145,10 @@ jobs: matrix: python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} cache: pip diff --git a/CHANGELOG.md b/CHANGELOG.md index 13425f3..8a7ff51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ semantic versioning. ### Added +- Local commands and local services (dropped into `local/commands` and + `local/service_plugins`) now appear in the web viewer's Plugins settings page + alongside the built-in ones, tagged with `source: "local"`. Settings edited + there route to `local/config.ini` rather than the base `config.ini`, matching + the local overlay the bot already merges at startup via `[Bot] local_dir_path` + — a section already tracked in the base config keeps saving there. The web + viewer previously only read/wrote against `config.ini`, so any local plugin + settings actually stored in the overlay were invisible and unsavable from the + UI (#272). + - `[Test_Command] distance_unit` (`auto`, `km`, `mi`) for `{path_distance}` and `{firstlast_distance}` (#275). `auto` (the default) follows the reply language: miles for `en` / `en-US`, kilometres for every other locale, including `en-GB`, diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..2cd8309 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,34 @@ +import html from "eslint-plugin-html"; +import globals from "globals"; + +export default [ + { + files: ["**/*.{html,js}"], + plugins: { html }, + languageOptions: { + ecmaVersion: 2021, + sourceType: "script", + globals: { + ...globals.browser, + io: "readonly", + bootstrap: "readonly", + Chart: "readonly", + L: "readonly", + escapeHtml: "writable", + socket: "writable", + }, + }, + settings: { + "html/html-extensions": [".html"], + }, + rules: { + "no-undef": "warn", + "no-unused-vars": "warn", + "no-console": "off", + semi: ["warn", "always"], + eqeqeq: ["warn", "always"], + "no-eval": "error", + "no-implied-eval": "error", + }, + }, +]; diff --git a/modules/settings_schema.py b/modules/settings_schema.py index aac0a28..307ff02 100644 --- a/modules/settings_schema.py +++ b/modules/settings_schema.py @@ -243,23 +243,106 @@ def _discover_classes(base_class: type, package: str, directory: str, logger=Non return found +def _discover_local_classes( + base_class: type, + directory: str, + *, + package_name: str, + package_paths: Optional[list[str]] = None, + local_base_module: Optional[str] = None, + logger=None, +) -> list[type]: + """Import local plugin modules and collect ``base_class`` subclasses. + + ``package_name`` mirrors the runtime namespaces (``local_plugins`` for + commands and ``local_services`` for services), keeping their relative + imports isolated even when files in both directories share a name. + """ + import importlib.util + import sys + import types + from pathlib import Path + + found: list[type] = [] + if not os.path.isdir(directory): + return found + + local_path = Path(directory) + + # Ensure the synthetic parent package exists for relative imports. Refresh + # its search path in case local_dir_path changed during a config reload. + pkg = sys.modules.get(package_name) + if pkg is None: + pkg = types.ModuleType(package_name) + sys.modules[package_name] = pkg + pkg.__path__ = [str(local_path), *(package_paths or [])] + + for fpath in sorted(local_path.glob("*.py")): + if fpath.name == "__init__.py": + continue + stem = fpath.stem + module_name = f"{package_name}.{stem}" + + try: + spec = importlib.util.spec_from_file_location(module_name, fpath) + if spec is None or spec.loader is None: + if logger: + logger.warning("Could not create spec for local plugin %s", fpath) + continue + + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + + bases = [base_class] + if local_base_module: + local_base = getattr( + sys.modules.get(f"{package_name}.{local_base_module}"), + base_class.__name__, + None, + ) + if inspect.isclass(local_base): + bases.append(local_base) + + for _n, obj in inspect.getmembers(module, inspect.isclass): + if ( + obj not in bases + and any(issubclass(obj, candidate) for candidate in bases) + and obj.__module__ == module_name + ): + found.append(obj) + break + except Exception as exc: # noqa: BLE001 - never let one bad plugin break the list + if logger: + logger.warning("Could not import local plugin %s for settings discovery: %s", stem, exc) + continue + + return found + + def build_plugin_settings_view( config: configparser.ConfigParser, *, logger=None, commands_dir: Optional[str] = None, services_dir: Optional[str] = None, + local_commands_dir: Optional[str] = None, + local_services_dir: Optional[str] = None, ) -> list[dict]: """Assemble the per-plugin settings view for the web UI. Returns a list of dicts, one per discovered command/service:: {name, kind, section, label, description, category, - enabled, has_schema, fields, values} + enabled, source, has_schema, fields, values} ``fields`` is the plugin's ``settings_schema`` with a resolved ``value`` on each field. ``values`` is the raw current config section (minus ``enabled``) used by the generic editor for plugins without a schema. + + ``local_commands_dir`` and ``local_services_dir`` auto-detect the + ``local/commands`` and ``local/service_plugins`` directories (relative to + the project root) when not supplied, and are tagged with ``source="local"``. """ # Import bases lazily so this module stays import-light. from modules.commands.base_command import BaseCommand @@ -287,11 +370,51 @@ def build_plugin_settings_view( # declare themselves opt-in (e.g. Announcements, Greeter read # 'enabled' with fallback=False in __init__). enabled_default=bool(getattr(cls, "settings_enabled_default", True)), + source="base", )) except Exception as exc: # noqa: BLE001 - one bad plugin must not break the list if logger: logger.warning("Skipping command %s in settings view: %s", name, exc) + # --- Local Commands (from local/commands directory) --- + if local_commands_dir is None: + # Auto-detect local/commands directory relative to the project root + local_path = os.path.join(here, "..", "local", "commands") + if os.path.isdir(local_path): + local_commands_dir = local_path + + if local_commands_dir and os.path.isdir(local_commands_dir): + for cls in _discover_local_classes( + BaseCommand, + local_commands_dir, + package_name="local_plugins", + local_base_module="base_command", + logger=logger, + ): + name = getattr(cls, "name", "") or cls.__name__.lower().replace("command", "") + if not name: + continue + if any(entry["kind"] == "command" and entry["name"] == name for entry in view): + if logger: + logger.warning( + "Local command %s is already present in the settings view; skipping", + name, + ) + continue + section = command_section_name(name) + try: + view.append(_assemble_entry( + config, cls, kind="command", name=name, section=section, + label=name.replace("_", " ").title(), + description=getattr(cls, "description", "") or "", + category=getattr(cls, "category", "general") or "general", + enabled_default=bool(getattr(cls, "settings_enabled_default", True)), + source="local", + )) + except Exception as exc: # noqa: BLE001 - one bad plugin must not break the list + if logger: + logger.warning("Skipping local command %s in settings view: %s", name, exc) + # --- Services (default enabled = False; must opt in) --- for cls in _discover_classes(BaseServicePlugin, "modules.service_plugins", services_dir, logger): section = service_section_name(cls) @@ -303,11 +426,50 @@ def build_plugin_settings_view( description=getattr(cls, "description", "") or "", category="service", enabled_default=bool(getattr(cls, "settings_enabled_default", False)), + source="base", )) except Exception as exc: # noqa: BLE001 - one bad plugin must not break the list if logger: logger.warning("Skipping service %s in settings view: %s", name, exc) + # --- Local Services (from local/service_plugins directory) --- + if local_services_dir is None: + # Auto-detect local/service_plugins directory relative to the project root + local_services_path = os.path.join(here, "..", "local", "service_plugins") + if os.path.isdir(local_services_path): + local_services_dir = local_services_path + + if local_services_dir and os.path.isdir(local_services_dir): + for cls in _discover_local_classes( + BaseServicePlugin, + local_services_dir, + package_name="local_services", + package_paths=[os.path.join(here, "service_plugins"), here], + local_base_module="base_service", + logger=logger, + ): + section = service_section_name(cls) + name = getattr(cls, "name", "") or cls.__name__.lower().replace("service", "") + if any(entry["kind"] == "service" and entry["name"] == name for entry in view): + if logger: + logger.warning( + "Local service %s is already present in the settings view; skipping", + name, + ) + continue + try: + view.append(_assemble_entry( + config, cls, kind="service", name=name, section=section, + label=section.replace("_", " "), + description=getattr(cls, "description", "") or "", + category="service", + enabled_default=bool(getattr(cls, "settings_enabled_default", False)), + source="local", + )) + except Exception as exc: # noqa: BLE001 - one bad plugin must not break the list + if logger: + logger.warning("Skipping local service %s in settings view: %s", name, exc) + view.sort(key=lambda e: (e["kind"], e["label"].lower())) return view @@ -323,6 +485,7 @@ def _assemble_entry( description: str, category: str, enabled_default: bool, + source: str = "base", ) -> dict: schema = list(getattr(cls, "settings_schema", []) or []) fields: list[dict] = [] @@ -377,6 +540,7 @@ def _assemble_entry( "description": description, "category": category, "enabled": read_enabled(config, section, enabled_default), + "source": source, "has_schema": bool(fields), "fields": fields, "values": values, diff --git a/modules/web_viewer/app.py b/modules/web_viewer/app.py index 3c1352c..bed4da6 100644 --- a/modules/web_viewer/app.py +++ b/modules/web_viewer/app.py @@ -257,9 +257,6 @@ class BotDataViewer: if not os.path.isabs(config_path): config_path = str(self.bot_root / config_path) - # Load configuration before logging so [Logging] log_file can select - # journal/console-only vs file logging (same rules as the main bot). - self.config = self._load_config(config_path) self.config_path = config_path # kept for config.ini write-back endpoints # Resolve db_path relative to the config file's directory — matches core.py's bot_root @@ -269,6 +266,10 @@ class BotDataViewer: # because the web viewer and bot opened different database files. self._config_base = Path(config_path).parent.resolve() if os.path.exists(config_path) else self.bot_root + # Load configuration before logging so [Logging] log_file can select + # journal/console-only vs file logging (same rules as the main bot). + self.config = self._load_merged_config() + self._setup_logging() self.app = Flask( @@ -508,6 +509,37 @@ class BotDataViewer: config.read(config_path) return config + def _load_merged_config(self): + """Load base config.ini plus its local overlay, mirroring core.py. + + core.py's ``_read_config_snapshot`` reads the base ``config.ini``, + looks up ``[Bot] local_dir_path`` (fallback ``"local"``), resolves it + relative to the bot root, and — if ``/config.ini`` + exists — reads it into the *same* parser so it overlays the base + values section-by-section/key-by-key. The web viewer needs the same + merged view so settings edited via the local overlay show up here. + """ + base_parser = configparser.ConfigParser() + if os.path.exists(self.config_path): + base_parser.read(self.config_path, encoding="utf-8") + base_sections = set(base_parser.sections()) + + local_dir_path_str = base_parser.get("Bot", "local_dir_path", fallback="local") + self.local_dir = Path(resolve_path(local_dir_path_str, self._config_base)) + self.local_config_path = str(self.local_dir / "config.ini") + + local_only = configparser.ConfigParser() + if os.path.exists(self.local_config_path): + local_only.read(self.local_config_path, encoding="utf-8") + local_sections = set(local_only.sections()) + + if os.path.exists(self.local_config_path): + base_parser.read(self.local_config_path, encoding="utf-8") + + self._base_sections = base_sections + self._local_sections = local_sections + return base_parser + def _get_version_info(self) -> dict[str, str | None]: """Get version info for footer via centralized version resolver. Never raises.""" info = resolve_application_version() @@ -1418,8 +1450,13 @@ class BotDataViewer: """Return the settings view for every discovered command/service.""" try: # Re-read config from disk so the UI reflects external edits. - self.config = self._load_config(self.config_path) - view = build_plugin_settings_view(self.config, logger=self.logger) + self.config = self._load_merged_config() + view = build_plugin_settings_view( + self.config, + logger=self.logger, + local_commands_dir=str(self.local_dir / "commands"), + local_services_dir=str(self.local_dir / "service_plugins"), + ) return jsonify({'plugins': view}) except Exception: self.logger.exception("Error building plugin settings view") @@ -1435,8 +1472,13 @@ class BotDataViewer: try: data = request.get_json(silent=True) or {} # Locate the plugin entry so we have its schema + section. - self.config = self._load_config(self.config_path) - view = build_plugin_settings_view(self.config, logger=self.logger) + self.config = self._load_merged_config() + view = build_plugin_settings_view( + self.config, + logger=self.logger, + local_commands_dir=str(self.local_dir / "commands"), + local_services_dir=str(self.local_dir / "service_plugins"), + ) entry = next( (e for e in view if e['kind'] == kind and e['name'] == name), None, @@ -1551,7 +1593,26 @@ class BotDataViewer: if brx.match(k) and k.lower() not in written: deletes.setdefault(section, []).append(k) - store = get_settings_store(self.config, self.config_path, self.db_manager) + if section in self._local_sections: + target_path = self.local_config_path + elif section in self._base_sections: + target_path = self.config_path + else: + # Brand-new section: local commands default into the local + # overlay, everything else into the base config. + target_path = ( + self.local_config_path if entry.get('source') == 'local' else self.config_path + ) + + if target_path == self.local_config_path and not os.path.exists(target_path): + # update_ini_values() requires the target file to already + # exist (it reads + backs up before writing) — local/config.ini + # may not exist yet on a fresh install. + os.makedirs(os.path.dirname(target_path), exist_ok=True) + with open(target_path, 'w', encoding='utf-8') as f: + f.write('') + + store = get_settings_store(self.config, target_path, self.db_manager) result = store.write_sections(updates, deletes) backup_path = result.get('backup_path', '') if isinstance(result, dict) else '' diff --git a/package-lock.json b/package-lock.json index 38fe38a..652a7d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,9 @@ "name": "meshcore-bot-frontend", "version": "0.0.1", "devDependencies": { - "eslint": "^8.57.0", - "eslint-plugin-html": "^8.1.1", + "eslint": "^10.8.0", + "eslint-plugin-html": "^8.1.4", + "globals": "^16.5.0", "htmlhint": "^1.1.4" } }, @@ -42,54 +43,107 @@ "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/js": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", - "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/@humanwhocodes/config-array": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", - "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", - "deprecated": "Use @eslint/config-array instead", + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanwhocodes/object-schema": "^2.0.3", + "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", - "minimatch": "^3.0.5" + "minimatch": "^10.2.4" }, "engines": { - "node": ">=10.10.0" + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" } }, "node_modules/@humanwhocodes/module-importer": { @@ -106,51 +160,40 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", - "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", - "deprecated": "Use @eslint/object-schema instead", + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" }, - "engines": { - "node": ">= 8" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } + "license": "MIT" }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" }, "node_modules/@types/sarif": { "version": "2.1.7", @@ -159,17 +202,10 @@ "dev": true, "license": "MIT" }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", - "dev": true, - "license": "ISC" - }, "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", "bin": { @@ -190,9 +226,9 @@ } }, "node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -206,16 +242,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -232,13 +258,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -247,31 +266,26 @@ "license": "MIT" }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, "license": "MIT", "engines": { - "node": ">=6" + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" } }, "node_modules/chalk": { @@ -321,13 +335,6 @@ "node": ">=16" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -368,19 +375,6 @@ "dev": true, "license": "MIT" }, - "node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -480,60 +474,62 @@ } }, "node_modules/eslint": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", - "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", - "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz", + "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", "dev": true, "license": "MIT", + "workspaces": [ + "packages/*" + ], "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.1", - "@humanwhocodes/config-array": "^0.13.0", + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "@ungap/structured-clone": "^1.2.0", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", "debug": "^4.3.2", - "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "esquery": "^1.4.2", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", + "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", + "minimatch": "^10.2.5", "natural-compare": "^1.4.0", - "optionator": "^0.9.3", - "strip-ansi": "^6.0.1", - "text-table": "^0.2.0" + "optionator": "^0.9.3" }, "bin": { "eslint": "bin/eslint.js" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } } }, "node_modules/eslint-plugin-html": { @@ -550,17 +546,19 @@ } }, "node_modules/eslint-scope": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -579,19 +577,45 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/espree": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.9.0", + "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" + "eslint-visitor-keys": "^5.0.1" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -664,27 +688,17 @@ "dev": true, "license": "MIT" }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, "node_modules/file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dev": true, "license": "MIT", "dependencies": { - "flat-cache": "^3.0.4" + "flat-cache": "^4.0.0" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": ">=16.0.0" } }, "node_modules/find-up": { @@ -705,24 +719,23 @@ } }, "node_modules/flat-cache": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", - "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "dev": true, "license": "MIT", "dependencies": { "flatted": "^3.2.9", - "keyv": "^4.5.3", - "rimraf": "^3.0.2" + "keyv": "^4.5.4" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": ">=16" } }, "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", "dev": true, "license": "ISC" }, @@ -741,13 +754,6 @@ "node": ">=14.14" } }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, - "license": "ISC" - }, "node_modules/glob": { "version": "13.0.6", "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", @@ -779,56 +785,14 @@ "node": ">=10.13.0" } }, - "node_modules/glob/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", "dev": true, "license": "MIT", - "dependencies": { - "type-fest": "^0.20.2" - }, "engines": { - "node": ">=8" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -841,13 +805,6 @@ "dev": true, "license": "ISC" }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -915,23 +872,6 @@ "node": ">= 4" } }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -942,25 +882,6 @@ "node": ">=0.8.19" } }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC" - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -984,16 +905,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -1001,19 +912,6 @@ "dev": true, "license": "ISC" }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -1088,13 +986,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, "node_modules/lru-cache": { "version": "11.2.7", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", @@ -1106,16 +997,19 @@ } }, "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^5.0.8" }, "engines": { - "node": "*" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/minipass": { @@ -1156,16 +1050,6 @@ "node": ">=18" } }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -1216,19 +1100,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -1239,16 +1110,6 @@ "node": ">=8" } }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -1296,111 +1157,6 @@ "node": ">=6" } }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -1424,19 +1180,6 @@ "node": ">=8" } }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -1463,13 +1206,6 @@ "node": ">=8" } }, - "node_modules/text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true, - "license": "MIT" - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -1483,19 +1219,6 @@ "node": ">= 0.8.0" } }, - "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -1542,13 +1265,6 @@ "node": ">=0.10.0" } }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, - "license": "ISC" - }, "node_modules/xml": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", diff --git a/package.json b/package.json index 51771bf..b3daf34 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,9 @@ "test:contacts": "node --test tests/js/contacts_manager.test.mjs" }, "devDependencies": { - "eslint": "^8.57.0", - "eslint-plugin-html": "^8.1.1", + "eslint": "^10.8.0", + "eslint-plugin-html": "^8.1.4", + "globals": "^16.5.0", "htmlhint": "^1.1.4" } } diff --git a/tests/test_settings_ui.py b/tests/test_settings_ui.py index f75d6ab..899b633 100644 --- a/tests/test_settings_ui.py +++ b/tests/test_settings_ui.py @@ -254,6 +254,162 @@ class TestBuildView: f"{e['name']} is missing the injected channels field" ) + def test_builtin_commands_and_services_are_base_source(self, view): + ping = self._entry(view, "command", "ping") + earthquake = self._entry(view, "service", "earthquake") + assert ping is not None and ping["source"] == "base" + assert earthquake is not None and earthquake["source"] == "base" + + +class TestBuildViewLocalCommandSource: + """Local commands (discovered via _discover_local_classes) must be tagged + source='local' so the web viewer's save endpoint writes to the local + config overlay instead of the base config.ini.""" + + def test_local_command_gets_local_source(self, tmp_path): + local_dir = tmp_path / "local_commands" + local_dir.mkdir() + (local_dir / "dummy_command.py").write_text( + "from modules.commands.base_command import BaseCommand\n\n" + "class DummyCommand(BaseCommand):\n" + " name = 'dummy'\n" + " keywords = ['dummy']\n\n" + " async def execute(self, message):\n" + " return None\n", + encoding="utf-8", + ) + cfg = configparser.ConfigParser() + view = build_plugin_settings_view(cfg, local_commands_dir=str(local_dir)) + entry = next( + (e for e in view if e["kind"] == "command" and e["name"] == "dummy"), None + ) + assert entry is not None + assert entry["source"] == "local" + # Sanity: built-ins discovered in the same view stay 'base'. + ping = next( + (e for e in view if e["kind"] == "command" and e["name"] == "ping"), None + ) + assert ping is not None and ping["source"] == "base" + + +class TestBuildViewLocalServiceSource: + """Local services (discovered via _discover_local_classes) must be tagged + source='local' so the web viewer's save endpoint writes to the local + config overlay instead of the base config.ini.""" + + def test_local_service_gets_local_source(self, tmp_path): + local_dir = tmp_path / "local_services" + local_dir.mkdir() + (local_dir / "dummy_service.py").write_text( + "from modules.service_plugins.base_service import BaseServicePlugin\n\n" + "class DummyService(BaseServicePlugin):\n" + " name = 'dummy'\n\n" + " async def start(self):\n" + " return None\n\n" + " async def stop(self):\n" + " return None\n", + encoding="utf-8", + ) + cfg = configparser.ConfigParser() + view = build_plugin_settings_view(cfg, local_services_dir=str(local_dir)) + entry = next( + (e for e in view if e["kind"] == "service" and e["name"] == "dummy"), None + ) + assert entry is not None + assert entry["source"] == "local" + # Sanity: built-ins discovered in the same view stay 'base'. + earthquake = next( + (e for e in view if e["kind"] == "service" and e["name"] == "earthquake"), + None, + ) + assert earthquake is not None and earthquake["source"] == "base" + + +class TestBuildViewLocalCommandAndServiceCoexist: + """Local commands and local services can be discovered together in a + single view build without interfering with each other.""" + + def test_local_command_and_service_both_appear(self, tmp_path): + local_commands_dir = tmp_path / "local_commands" + local_commands_dir.mkdir() + (local_commands_dir / "common.py").write_text( + "PLUGIN_NAME = 'dummycmd'\n", + encoding="utf-8", + ) + (local_commands_dir / "dummy_command.py").write_text( + "from modules.commands.base_command import BaseCommand\n" + "from .common import PLUGIN_NAME\n\n" + "class DummyCommand(BaseCommand):\n" + " name = PLUGIN_NAME\n" + " keywords = ['dummycmd']\n\n" + " async def execute(self, message):\n" + " return None\n", + encoding="utf-8", + ) + + local_services_dir = tmp_path / "local_services" + local_services_dir.mkdir() + (local_services_dir / "common.py").write_text( + "PLUGIN_NAME = 'dummysvc'\n", + encoding="utf-8", + ) + (local_services_dir / "dummy_service.py").write_text( + "from modules.service_plugins.base_service import BaseServicePlugin\n" + "from .common import PLUGIN_NAME\n\n" + "class DummyService(BaseServicePlugin):\n" + " name = PLUGIN_NAME\n\n" + " async def start(self):\n" + " return None\n\n" + " async def stop(self):\n" + " return None\n", + encoding="utf-8", + ) + + cfg = configparser.ConfigParser() + view = build_plugin_settings_view( + cfg, + local_commands_dir=str(local_commands_dir), + local_services_dir=str(local_services_dir), + ) + + command_entry = next( + (e for e in view if e["kind"] == "command" and e["name"] == "dummycmd"), + None, + ) + service_entry = next( + (e for e in view if e["kind"] == "service" and e["name"] == "dummysvc"), + None, + ) + assert command_entry is not None and command_entry["source"] == "local" + assert service_entry is not None and service_entry["source"] == "local" + + def test_local_name_collisions_match_runtime_and_are_skipped(self, tmp_path): + local_commands_dir = tmp_path / "local_commands" + local_commands_dir.mkdir() + (local_commands_dir / "ping_local.py").write_text( + "from modules.commands.base_command import BaseCommand\n\n" + "class PingLocalCommand(BaseCommand):\n" + " name = 'ping'\n" + " keywords = ['ping-local']\n\n" + " async def execute(self, message):\n" + " return None\n", + encoding="utf-8", + ) + + view = build_plugin_settings_view( + configparser.ConfigParser(), + local_commands_dir=str(local_commands_dir), + local_services_dir=str(tmp_path / "no-services"), + ) + + ping_entries = [ + entry + for entry in view + if entry["kind"] == "command" and entry["name"] == "ping" + ] + assert len(ping_entries) == 1 + assert ping_entries[0]["source"] == "base" + class TestBuildViewPasswordRedaction: """Password-typed fields must never leak their stored value to the UI; @@ -408,6 +564,32 @@ class TestPluginsApi: ) assert rows and rows[0]["status"] == "pending" + def test_new_local_command_saves_to_local_overlay(self, viewer, tmp_path): + commands_dir = tmp_path / "local" / "commands" + commands_dir.mkdir(parents=True) + (commands_dir / "localonly_command.py").write_text( + "from modules.commands.base_command import BaseCommand\n\n" + "class LocalOnlyCommand(BaseCommand):\n" + " name = 'localonly'\n" + " keywords = ['localonly']\n\n" + " async def execute(self, message):\n" + " return None\n", + encoding="utf-8", + ) + + client = viewer.app.test_client() + resp = client.post( + "/api/plugins/command/localonly", + json={"enabled": True, "values": {"message": "local value"}}, + ) + + assert resp.status_code == 200, resp.get_json() + local_text = (tmp_path / "local" / "config.ini").read_text(encoding="utf-8") + base_text = (tmp_path / "config.ini").read_text(encoding="utf-8") + assert "[Localonly_Command]" in local_text + assert "message = local value" in local_text + assert "[Localonly_Command]" not in base_text + def test_omitted_dynamic_sections_do_not_wipe_managed_keys(self, viewer, tmp_path): """A payload without dynamic_sections must leave [Channels_List] alone.""" client = viewer.app.test_client()