Fixes two productization gaps from the phone Threads work, reusing the existing update mechanisms (no new updater). Profiles (install-once / enable-per-profile / pair-once): - plugin/profiles.py + `hermes relay profiles list|enable [--all|NAME]` enumerate the default config + every profiles/<name>/config.yaml and bulk-enable hermes-relay in plugins.enabled (backing up each rewritten file to .bak, skipping already-enabled configs). Pairing is unaffected — one relay, pair once. Update discovery: - plugin/update_check.py + `hermes relay update-check` + a dashboard "Plugin version" card compare plugin.relay.__version__ against the latest plugin-v* GitHub release and surface the right command (hermes-relay-update vs `hermes plugins update hermes-relay`). Dashboard route GET /api/plugins/hermes-relay/update-check caches the GitHub fetch 1h and degrades softly offline. Docs: configuration.md gains "Profiles & the relay" + "Keeping the relay plugin updated". Tests: +39 (profiles discover/state/enable; semver/tag-pick/command- detect/build-result; dashboard update-check available/up-to-date/error). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
106 lines
4.4 KiB
Python
106 lines
4.4 KiB
Python
"""Tests for per-profile plugin-enablement helpers (``plugin/profiles.py``).
|
|
|
|
Runs under plain ``unittest``. Skips cleanly when pyyaml is absent from the
|
|
interpreter (the live hermes venv always has it).
|
|
|
|
python -m unittest plugin.tests.test_profiles
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
try:
|
|
import yaml
|
|
except ImportError: # pragma: no cover - guarded by skip
|
|
yaml = None
|
|
|
|
from plugin import profiles
|
|
|
|
|
|
def _write(path: Path, data: dict) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8")
|
|
|
|
|
|
@unittest.skipIf(yaml is None, "pyyaml not installed")
|
|
class DiscoverTests(unittest.TestCase):
|
|
def test_discovers_root_and_profiles(self) -> None:
|
|
with tempfile.TemporaryDirectory() as d:
|
|
home = Path(d)
|
|
_write(home / "config.yaml", {"plugins": {"enabled": ["hermes-relay"]}})
|
|
_write(home / "profiles" / "gary" / "config.yaml", {"plugins": {"enabled": []}})
|
|
_write(home / "profiles" / "lucy" / "config.yaml", {"plugins": {}})
|
|
configs = profiles.discover_profile_configs(str(home))
|
|
labels = [label for label, _ in configs]
|
|
self.assertEqual(labels[0], "(default)") # default first
|
|
self.assertIn("gary", labels)
|
|
self.assertIn("lucy", labels)
|
|
|
|
def test_missing_home_is_empty(self) -> None:
|
|
with tempfile.TemporaryDirectory() as d:
|
|
self.assertEqual(profiles.discover_profile_configs(str(Path(d) / "nope")), [])
|
|
|
|
|
|
@unittest.skipIf(yaml is None, "pyyaml not installed")
|
|
class StateTests(unittest.TestCase):
|
|
def test_states(self) -> None:
|
|
with tempfile.TemporaryDirectory() as d:
|
|
home = Path(d)
|
|
en = home / "a.yaml"
|
|
_write(en, {"plugins": {"enabled": ["hermes-relay"]}})
|
|
dis = home / "b.yaml"
|
|
_write(dis, {"plugins": {"disabled": ["hermes-relay"]}})
|
|
ab = home / "c.yaml"
|
|
_write(ab, {"plugins": {"enabled": ["other"]}})
|
|
none = home / "d.yaml"
|
|
_write(none, {"model": "x"})
|
|
self.assertEqual(profiles.relay_state(en), "enabled")
|
|
self.assertEqual(profiles.relay_state(dis), "disabled")
|
|
self.assertEqual(profiles.relay_state(ab), "absent")
|
|
self.assertEqual(profiles.relay_state(none), "absent")
|
|
|
|
|
|
@unittest.skipIf(yaml is None, "pyyaml not installed")
|
|
class EnableTests(unittest.TestCase):
|
|
def test_enable_adds_and_backs_up(self) -> None:
|
|
with tempfile.TemporaryDirectory() as d:
|
|
p = Path(d) / "config.yaml"
|
|
_write(p, {"plugins": {"enabled": ["other"], "disabled": ["hermes-relay"]}})
|
|
self.assertTrue(profiles.enable_relay(p))
|
|
data = yaml.safe_load(p.read_text(encoding="utf-8"))
|
|
self.assertIn("hermes-relay", data["plugins"]["enabled"])
|
|
self.assertNotIn("hermes-relay", data["plugins"].get("disabled", []))
|
|
self.assertTrue(p.with_suffix(".yaml.bak").exists()) # backed up
|
|
|
|
def test_enable_idempotent_preserves_file(self) -> None:
|
|
with tempfile.TemporaryDirectory() as d:
|
|
p = Path(d) / "config.yaml"
|
|
original = "plugins:\n enabled:\n - hermes-relay # keep me\n"
|
|
p.write_text(original, encoding="utf-8")
|
|
self.assertFalse(profiles.enable_relay(p)) # already enabled → no change
|
|
self.assertEqual(p.read_text(encoding="utf-8"), original) # comment intact
|
|
self.assertFalse(p.with_suffix(".yaml.bak").exists()) # no needless rewrite
|
|
|
|
def test_dry_run_does_not_write(self) -> None:
|
|
with tempfile.TemporaryDirectory() as d:
|
|
p = Path(d) / "config.yaml"
|
|
_write(p, {"plugins": {"enabled": []}})
|
|
before = p.read_text(encoding="utf-8")
|
|
self.assertTrue(profiles.enable_relay(p, dry_run=True)) # would change
|
|
self.assertEqual(p.read_text(encoding="utf-8"), before) # but didn't
|
|
|
|
def test_creates_plugins_section_when_absent(self) -> None:
|
|
with tempfile.TemporaryDirectory() as d:
|
|
p = Path(d) / "config.yaml"
|
|
_write(p, {"model": "x"})
|
|
self.assertTrue(profiles.enable_relay(p))
|
|
data = yaml.safe_load(p.read_text(encoding="utf-8"))
|
|
self.assertIn("hermes-relay", data["plugins"]["enabled"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|