aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/daemon.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-29 11:15:54 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-29 11:15:54 +0200
commitb7733e812fadd6007976d262bd1d793572a36ba7 (patch)
tree52e2ac07c9ba4929fa50b6ba8d19f94f86e8b304 /packages/meshbay-node/src/meshbay_node/daemon.py
parent09a007937f03fad04a390323f3817f1ae7d7c2a9 (diff)
downloadmeshbay-b7733e812fadd6007976d262bd1d793572a36ba7.tar.gz
feat: node workflow redesign — wizard auto-config, reset, MusicBrainz contact
Wizard (Electron): - Auto-provisions node config (hub URL + username) from logged-in user - node:start handles both cold start and restart of misconfigured daemon - Waits for daemon to reach 'running', auto-links node key on hub - probeNode accepts intermediate states for wizard progress feedback Reset (meshbay-node reset): - Unlinks node key from hub (DELETE /me/node_key, best-effort) - Stops and disables daemon (systemctl --user disable --now) - Erases ~/.config/meshbay, ~/.local/share/meshbay, ~/.local/state/meshbay MusicBrainz contact: - Resolved from owner's hub email instead of per-node roster config - Removed musicbrainz_contact UI and WebRTC handshake field - Removed set_musicbrainz_contact/musicbrainz_contact from roster Node pairing: - Added operator pairing banner on NodePage - Added operator_paired flag to list_groups Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/daemon.py')
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py190
1 files changed, 176 insertions, 14 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py
index 74ab7b1..779ad91 100644
--- a/packages/meshbay-node/src/meshbay_node/daemon.py
+++ b/packages/meshbay-node/src/meshbay_node/daemon.py
@@ -240,6 +240,11 @@ class NodeDaemon:
session = await self._login_with_retry(hub)
self._state["endpoint_hint"] = session.node_id
+ try:
+ session.email = await hub.fetch_owner_email()
+ except Exception as e:
+ log.warning("Could not fetch owner email: %s", e)
+
# 4. Bundle store (P2P GEK bundles)
data_dir = self._config.data_dir
data_dir.mkdir(parents=True, exist_ok=True)
@@ -433,12 +438,11 @@ class NodeDaemon:
# 6c. Music app (docs/musicbay.md) — same media_cache.db, its own
# enricher (mutagen, not ffmpeg) and its own MusicBrainz client.
- # No token to read here (§3.1): only a contact string, and unset
- # simply means the client makes no calls (musicbrainz.py).
+ # The User-Agent contact is the owner's hub email, resolved at
+ # login — no roster setting or env var needed any more.
self._audio_enricher = AudioEnricher(self._media_cache)
- self._musicbrainz_client = MusicBrainzClient(roster=self._roster)
- musicbrainz_contact = await self._roster.musicbrainz_contact()
- self._state["musicbrainz_contact_configured"] = bool(musicbrainz_contact)
+ self._musicbrainz_client = MusicBrainzClient(owner_email=session.email)
+ self._state["musicbrainz_contact_configured"] = bool(session.email)
# 6d. Photos app (docs/photos.md) — same media_cache.db, its own
# enricher (Pillow, not ffmpeg/mutagen). No credential, no
@@ -1570,11 +1574,12 @@ def main() -> None:
parser = argparse.ArgumentParser(description="MeshBay Node daemon")
parser.add_argument("command", nargs="?",
- choices=["init", "status", "ui", "gek-init", "gek",
- "operator", "member", "group", "file",
+ choices=["init", "reset", "status", "ui", "gek-init",
+ "gek", "operator", "member", "group", "file",
"denylist", "reload", "restart-daemon",
"calibrate-argon2"],
- help="init: write example config | status: node state and keys "
+ help="init: provision config + keystore | reset: erase all "
+ "node state | status: node state and keys "
"| ui: print the admin UI URL | operator pair: pair a "
"browser with this node | member list|invite|revoke|unpin "
"| group list|add|remove | gek init|rotate | file list|rm "
@@ -1590,6 +1595,10 @@ def main() -> None:
help="username for member invite|revoke|unpin; group name "
"for group add; file id for file rm; identifier for "
"denylist clear")
+ parser.add_argument("--hub-url", default=None,
+ help="hub URL, for init (e.g. https://meshbay.org)")
+ parser.add_argument("--username", default=None,
+ help="hub username, for init")
parser.add_argument("--dir", default=None,
help="shared directory, for group add")
parser.add_argument("--upload-dir", default=None,
@@ -1607,7 +1616,7 @@ def main() -> None:
# Query commands print a report; library logging would interleave with it.
quiet = args.command in ("status", "ui", "gek-init", "gek", "operator",
"member", "group", "file", "denylist", "reload",
- "restart-daemon")
+ "restart-daemon", "reset")
logging.basicConfig(
level=logging.ERROR if quiet else getattr(logging, args.log_level),
format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
@@ -1615,19 +1624,145 @@ def main() -> None:
if args.command == "init":
cfg_path = args.config or DEFAULT_CONFIG_PATH
- if not cfg_path.exists():
- write_example_config(cfg_path)
- print(f"Config written to {cfg_path}")
+ config_dir = cfg_path.parent
+ config_dir.mkdir(parents=True, exist_ok=True)
+
+ hub_url = args.hub_url
+ username = args.username
+
+ if not hub_url:
+ hub_url = input("Hub URL [https://meshbay.org]: ").strip() or "https://meshbay.org"
+ if not username:
+ username = input("Hub username: ").strip()
+ if not username:
+ print("Username is required.")
+ sys.exit(1)
+
+ if cfg_path.exists():
+ existing = cfg_path.read_text()
+ import re as _re
+ m = _re.search(r'username\s*=\s*"([^"]*)"', existing)
+ existing_user = m.group(1) if m else ""
+ if existing_user and existing_user != "myusername" and existing_user != username:
+ print(f"Config already exists with username {existing_user!r}.")
+ print("This node belongs to another operator. Use 'meshbay-node reset' first.")
+ sys.exit(1)
+ if existing_user in ("", "myusername"):
+ updated = _re.sub(
+ r'(username\s*=\s*)"[^"]*"', rf'\1"{username}"', existing)
+ updated = _re.sub(
+ r'(url\s*=\s*)"[^"]*"', rf'\1"{hub_url}"', updated, count=1)
+ cfg_path.write_text(updated)
+ print(f"Config updated: username={username}, hub={hub_url}")
+ else:
+ print(f"Config already exists: {cfg_path}")
else:
- print(f"Config already exists: {cfg_path}")
+ unlock_file = config_dir / "unlock.key"
+ toml_lines = [
+ "[hub]",
+ f'url = "{hub_url}"',
+ f'username = "{username}"',
+ "",
+ "[node]",
+ "quic_port = 19010",
+ "ui_port = 18000",
+ "",
+ "[keystore]",
+ f'unlock_file = "{unlock_file}"',
+ "",
+ ]
+ cfg_path.write_text("\n".join(toml_lines) + "\n")
+ os.chmod(cfg_path, 0o600)
+ print(f"Config written to {cfg_path}")
+
+ unlock_file = config_dir / "unlock.key"
+ if not unlock_file.exists():
+ import secrets
+ key = secrets.token_urlsafe(32)
+ unlock_file.write_text(key + "\n")
+ os.chmod(unlock_file, 0o600)
+ print(f"Unlock key created: {unlock_file}")
+
cfg = load_config(cfg_path)
if cfg.keystore.path.exists():
print(f"Keystore already exists: {cfg.keystore.path}")
+ keys = load_keystore(
+ path=cfg.keystore.path, unlock_file=cfg.keystore.unlock_file)
else:
keys = create_keystore(
path=cfg.keystore.path, unlock_file=cfg.keystore.unlock_file)
print(f"Keystore created: {cfg.keystore.path}")
- print(f"Node key: {keys.pk_ed25519_b64}")
+
+ print(f"Node key: {keys.pk_ed25519_b64}")
+ print()
+ print("Next steps:")
+ print(f" 1. Link this node key on {hub_url} → Settings → Link Node")
+ print(" 2. systemctl --user enable --now meshbay-node")
+ print(" 3. meshbay-node group add <name> --dir /path/to/files")
+ print(" 4. meshbay-node gek init")
+ print(" 5. meshbay-node operator pair")
+ return
+
+ if args.command == "reset":
+ import shutil
+
+ config_dir = Path.home() / ".config" / "meshbay"
+ data_dir = Path.home() / ".local" / "share" / "meshbay"
+ state_dir = Path.home() / ".local" / "state" / "meshbay"
+
+ items = []
+ for d in (config_dir, data_dir):
+ if d.exists():
+ for child in sorted(d.iterdir()):
+ items.append(child)
+
+ if not items:
+ print("Nothing to reset — no node state found.")
+ return
+
+ print("This will permanently erase all node state:")
+ for p in items:
+ print(f" {p}")
+ print()
+ print("WARNING: a new keystore means a new identity. All group")
+ print("memberships, operator pairings, and invitations are lost.")
+
+ if not args.yes:
+ answer = input("\nProceed? [y/N] ").strip().lower()
+ if answer != "y":
+ print("Aborted.")
+ return
+
+ import subprocess as _sp
+ import json as _json
+ import urllib.request
+ import urllib.error
+
+ token_file = data_dir / "ui-token"
+ if token_file.exists():
+ try:
+ cfg = Config(config_dir / "node.toml")
+ tok = token_file.read_text().strip()
+ url = (f"http://127.0.0.1:{cfg.node.ui_port}"
+ f"/api/unlink?t={tok}")
+ req = urllib.request.Request(url, method="DELETE")
+ with urllib.request.urlopen(req, timeout=5) as r:
+ _json.loads(r.read())
+ print("Unlinked node key from hub.")
+ except Exception:
+ print("Could not unlink from hub (daemon not reachable).")
+
+ _sp.run(["systemctl", "--user", "disable", "--now", "meshbay-node"],
+ capture_output=True)
+
+ for d in (config_dir, data_dir):
+ if d.exists():
+ shutil.rmtree(d)
+ print(f"Removed {d}")
+ if state_dir.is_dir():
+ shutil.rmtree(state_dir)
+ print(f"Removed {state_dir}")
+ print("Node state erased. Run 'meshbay-node init' to start over.")
return
if args.command == "calibrate-argon2":
@@ -1668,6 +1803,33 @@ def main() -> None:
f" files {live.get('total_files', 0)}"
f" peers {live.get('webrtc_peers', 0)}")
print(f"admin UI meshbay-node ui")
+
+ needs = live.get("needs", [])
+ if needs:
+ _GUIDANCE = {
+ "node_key_link": (
+ "Link node key",
+ f"Copy the node key above and paste it in Settings → Link Node on {cfg.hub.url}"),
+ "group_add": (
+ "Add a group",
+ "meshbay-node group add <name> --dir /path/to/files"),
+ "operator_pair": (
+ "Pair as operator",
+ "meshbay-node operator pair"),
+ }
+ print()
+ print("action needed:")
+ for need in needs:
+ if need.startswith("gek_init:"):
+ name = need.split(":", 1)[1]
+ print(f" → Initialize group key for {name}")
+ print(f" meshbay-node gek init --group \"{name}\"")
+ elif need in _GUIDANCE:
+ label, hint = _GUIDANCE[need]
+ print(f" → {label}")
+ print(f" {hint}")
+ else:
+ print(f" → {need}")
else:
print("daemon not running")