aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/ops.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/ops.py')
-rw-r--r--packages/meshbay-node/src/meshbay_node/ops.py217
1 files changed, 201 insertions, 16 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/ops.py b/packages/meshbay-node/src/meshbay_node/ops.py
index 20345bb..2a76290 100644
--- a/packages/meshbay-node/src/meshbay_node/ops.py
+++ b/packages/meshbay-node/src/meshbay_node/ops.py
@@ -23,6 +23,7 @@ in the adapter.
from __future__ import annotations
import logging
+import re
from dataclasses import asdict
from pathlib import Path
from typing import Any
@@ -93,9 +94,11 @@ async def read_roster(state: dict, group_id: str = "") -> dict:
roster = state.get("roster")
if not roster:
return {"identities": [], "members": [], "invites": []}
+ members = await roster.list_members(group_id or None)
+ members = [m for m in members if m.get("pk_ed25519") is not None]
return {
"identities": await roster.list_identities(),
- "members": await roster.list_members(group_id or None),
+ "members": members,
"invites": await roster.list_invites(),
}
@@ -387,6 +390,125 @@ async def attach_group(state: dict, name: str, shared_dir: str,
return result
+async def detach_group(state: dict, name: str) -> dict:
+ """
+ Remove a [[groups]] block from node.toml.
+
+ Does not touch the hub — only stops this node from hosting the group
+ after the next reload or restart.
+ """
+ if not name:
+ raise OpError("group name or id is required")
+ config = _config(state)
+
+ match = [g for g in config.groups if g.id == name or g.name == name]
+ if not match:
+ raise OpError(f"No hosted group matches {name!r}", status=404,
+ extra={"available": [{"name": g.name, "id": g.id}
+ for g in config.groups]})
+ group = match[0]
+
+ conf_path = Path(state.get("config_path") or DEFAULT_CONFIG_PATH)
+ text = conf_path.read_text()
+ lines = text.split("\n")
+
+ rng = _find_group_range(lines, group.id)
+ if rng is None:
+ raise OpError(f"Group {group.id[:8]} not found in {conf_path}")
+
+ start, end = rng
+ while end < len(lines) and lines[end].strip() == "":
+ end += 1
+
+ new_lines = lines[:start] + lines[end:]
+ conf_path.write_text("\n".join(new_lines))
+ log.info("Group detached: %s (%s) removed from %s", group.name, group.id[:8], conf_path)
+
+ return {"group_id": group.id, "name": group.name, "config": str(conf_path),
+ "note": "restart the node to stop hosting it"}
+
+
+def _find_group_range(lines: list[str], group_id: str) -> tuple[int, int] | None:
+ """Line range of a [[groups]] block by id: (start, end_exclusive)."""
+ id_re = re.compile(r'^\s*id\s*=\s*"([^"]*)"')
+ block_starts: list[int] = []
+ for i, line in enumerate(lines):
+ if line.strip() == "[[groups]]":
+ block_starts.append(i)
+
+ for j, start in enumerate(block_starts):
+ boundary = block_starts[j + 1] if j + 1 < len(block_starts) else len(lines)
+ for k in range(start + 1, boundary):
+ s = lines[k].strip()
+ if s.startswith("[") and s != "[[groups.roots]]":
+ boundary = k
+ break
+ for k in range(start + 1, boundary):
+ m = id_re.match(lines[k])
+ if m and m.group(1) == group_id:
+ return (start, boundary)
+ return None
+
+
+def _insert_roots_block(conf_path: Path, group_id: str,
+ root_block: str) -> None:
+ """Append a [[groups.roots]] block inside the matching [[groups]] section."""
+ text = conf_path.read_text()
+ lines = text.split("\n")
+
+ rng = _find_group_range(lines, group_id)
+ if rng is None:
+ raise OpError(f"Group {group_id[:8]} not found in {conf_path}")
+
+ _start, end = rng
+ insert_at = end
+ while insert_at > _start + 1 and lines[insert_at - 1].strip() == "":
+ insert_at -= 1
+
+ new_lines = (lines[:insert_at]
+ + [""]
+ + root_block.rstrip("\n").split("\n")
+ + lines[insert_at:])
+ conf_path.write_text("\n".join(new_lines))
+
+
+def _remove_roots_block(conf_path: Path, group_id: str,
+ resolved_path: str) -> None:
+ """Remove a [[groups.roots]] block whose resolved path matches."""
+ text = conf_path.read_text()
+ lines = text.split("\n")
+
+ rng = _find_group_range(lines, group_id)
+ if rng is None:
+ raise OpError(f"Group {group_id[:8]} not found in {conf_path}")
+
+ start, end = rng
+ path_re = re.compile(r'^\s*path\s*=\s*"([^"]*)"')
+ roots_starts: list[int] = []
+ for i in range(start + 1, end):
+ if lines[i].strip() == "[[groups.roots]]":
+ roots_starts.append(i)
+
+ for j, rs in enumerate(roots_starts):
+ rs_end = roots_starts[j + 1] if j + 1 < len(roots_starts) else end
+ for k in range(rs, rs_end):
+ m = path_re.match(lines[k])
+ if m:
+ try:
+ p = str(Path(m.group(1)).expanduser().resolve())
+ except OSError:
+ continue
+ if p == resolved_path:
+ rm_start = rs
+ if rm_start > 0 and lines[rm_start - 1].strip() == "":
+ rm_start -= 1
+ new_lines = lines[:rm_start] + lines[rs_end:]
+ conf_path.write_text("\n".join(new_lines))
+ return
+
+ raise OpError(f"Root path not found in config", status=404)
+
+
async def add_root(state: dict, group_id: str, path: str, *,
name: str = "", kind: str = "generic",
upload: bool = False) -> dict:
@@ -410,22 +532,85 @@ async def add_root(state: dict, group_id: str, path: str, *,
raise OpError(str(e)) from e
added = built.roots[-1]
+
+ try:
+ added.path.mkdir(parents=True, exist_ok=True)
+ except OSError as e:
+ raise OpError(f"Cannot create {added.path}: {e}") from e
+
conf_path = Path(state.get("config_path") or DEFAULT_CONFIG_PATH)
- raise OpError(
- # Writing into the middle of a hand-written TOML file means finding the
- # right [[groups]] block and appending inside it, which a text append
- # cannot do. Until that is written, say so plainly rather than appending
- # to the wrong group.
- f"Add this to {conf_path} under the [[groups]] block for "
- f"{cfg.name!r}, then restart the node:\n\n"
- f' [[groups.roots]]\n'
- f' path = "{added.path}"\n'
- + (f' name = "{added.name}"\n' if name else "")
- + (f' kind = "{added.kind}"\n' if kind != "generic" else "")
- + (f' upload = true\n' if upload else ""),
- status=501,
- extra={"validated": True, "name": added.name, "path": str(added.path)},
- )
+ root_block = f' [[groups.roots]]\n path = "{added.path}"'
+ if name:
+ root_block += f'\n name = "{added.name}"'
+ if kind != "generic":
+ root_block += f'\n kind = "{added.kind}"'
+ if upload:
+ root_block += f'\n upload = true'
+ _insert_roots_block(conf_path, group_id, root_block)
+
+ from meshbay_node.config import RootSpec
+ cfg.roots.append(RootSpec(
+ path=str(added.path), name=added.name, kind=added.kind,
+ upload=added.upload, direct=added.direct))
+ groups_ctx = state.get("groups_ctx", {})
+ if group_id in groups_ctx:
+ groups_ctx[group_id]["roots"] = built
+
+ log.info("Root added: %s → group %s", added.name, group_id[:8])
+ return {"status": "added", "name": added.name, "path": str(added.path),
+ "group_id": group_id, "roots": built.describe()}
+
+
+async def remove_root(state: dict, group_id: str, root_name: str) -> dict:
+ """Remove a named root from a group. At least one root must remain."""
+ config = _config(state)
+ cfg = next((g for g in config.groups if g.id == group_id), None)
+ if cfg is None:
+ raise OpError("Group not configured on this node", status=404)
+
+ from meshbay_common.paths import fold
+ from meshbay_node.roots import derive_name
+ target = fold(root_name)
+ match_idx = None
+ for i, r in enumerate(cfg.roots):
+ try:
+ rname = r.name or derive_name(Path(r.path).expanduser().resolve())
+ except Exception:
+ continue
+ if fold(rname) == target:
+ match_idx = i
+ break
+
+ if match_idx is None:
+ raise OpError(f"No root named {root_name!r} in this group", status=404)
+ if len(cfg.roots) < 2:
+ raise OpError("Cannot remove the only root", status=400)
+
+ removed = cfg.roots[match_idx]
+ if removed.upload:
+ raise OpError(
+ "Cannot remove the upload root — file uploads and chat "
+ "attachments are stored there", status=400)
+ resolved = str(Path(removed.path).expanduser().resolve())
+
+ conf_path = Path(state.get("config_path") or DEFAULT_CONFIG_PATH)
+ _remove_roots_block(conf_path, group_id, resolved)
+
+ cfg.roots.pop(match_idx)
+ remaining = [asdict(r) for r in cfg.roots]
+ try:
+ built = RootSet.build(remaining)
+ except RootError:
+ built = None
+ if built is not None:
+ groups_ctx = state.get("groups_ctx", {})
+ if group_id in groups_ctx:
+ groups_ctx[group_id]["roots"] = built
+
+ log.info("Root removed: %s from group %s", root_name, group_id[:8])
+ return {"status": "removed", "name": root_name, "group_id": group_id,
+ "roots": built.describe() if built else [],
+ "note": "restart recommended to update the file index"}
# ── Files ────────────────────────────────────────────────────────────────────