Improve remote session management and Windows support

This commit is contained in:
2026-09-03 12:58:24 +00:00
parent 6c2c9672e1
commit 02d83a04bd
12 changed files with 2342 additions and 146 deletions
+393 -65
View File
@@ -1,27 +1,45 @@
from __future__ import annotations
import argparse
from contextlib import contextmanager
import curses
from dataclasses import dataclass, field
from dataclasses import dataclass, field, replace
import fcntl
from functools import wraps
import hashlib
import json
import os
from pathlib import Path
import re
import shlex
import shutil
import stat
import subprocess
import sys
from typing import Callable, Iterable, Sequence
import tempfile
import threading
from typing import Callable, Iterable, Mapping, Sequence
from urllib.parse import quote
from .aliases import AliasRegistry, AliasRegistryError, default_alias_registry_path
from .domain import validate_session_name
from .remotes import (
RegistryError,
RemoteEntry,
RemoteRegistry,
default_registry_path,
delete_remote_session,
ssh_destination_arguments,
stop_remote_session,
validate_target,
)
from .selector import CatalogWindow, SelectorError, SelectorResult, SessionSelector
from .selector import (
CatalogWindow,
DeleteSessionOutcome,
SelectorError,
SelectorResult,
SessionSelector,
StopSessionOutcome,
)
DEFAULT_SOCKET_NAME = "herdr-hub"
@@ -30,11 +48,9 @@ DEFAULT_SESSION_NAME = "default"
BOOTSTRAP_WINDOW_NAME = "__herdr_hub_bootstrap__"
WINDOW_KEY_OPTION = "@herdr_hub_key"
WINDOW_COMMAND_OPTION = "@herdr_hub_command"
WINDOW_LABEL_OPTION = "@herdr_hub_label"
WINDOW_ENDPOINT_OPTION = "@herdr_hub_endpoint"
WINDOW_SHORT_LABEL_OPTION = "@herdr_hub_short_label"
SESSION_DISMISSED_OPTION = "@herdr_hub_dismissed"
SESSION_NAME_RE = re.compile(r"^[A-Za-z0-9._-]{1,64}$")
class HubError(RuntimeError):
@@ -47,6 +63,7 @@ class Endpoint:
session: str = DEFAULT_SESSION_NAME
target: str | None = None
remote_keybindings: str = "local"
remote_transport: str = "native"
def __post_init__(self) -> None:
if self.kind not in {"local", "remote"}:
@@ -55,10 +72,14 @@ class Endpoint:
if self.kind == "local":
if self.target is not None:
raise ValueError("a local endpoint cannot have a remote target")
if self.remote_transport != "native":
raise ValueError("a local endpoint cannot use a remote transport")
return
validate_remote_target(self.target)
if self.remote_keybindings not in {"local", "server"}:
raise ValueError("remote keybindings must be 'local' or 'server'")
if self.remote_transport not in {"native", "ssh"}:
raise ValueError("remote transport must be 'native' or 'ssh'")
@classmethod
def local(cls, session: str = DEFAULT_SESSION_NAME) -> Endpoint:
@@ -70,12 +91,14 @@ class Endpoint:
target: str,
session: str = DEFAULT_SESSION_NAME,
remote_keybindings: str = "local",
remote_transport: str = "native",
) -> Endpoint:
return cls(
kind="remote",
target=target,
session=session,
remote_keybindings=remote_keybindings,
remote_transport=remote_transport,
)
@property
@@ -105,10 +128,24 @@ class Endpoint:
label += f"/{self.session}"
return truncate_label(label, 32)
def command(self, herdr_bin: str) -> list[str]:
def command(self, herdr_bin: str, ssh_bin: str = "ssh") -> list[str]:
command = [herdr_bin]
if self.kind == "remote":
assert self.target is not None
if self.remote_transport == "ssh":
remote_command = ["herdr"]
if self.session != DEFAULT_SESSION_NAME:
remote_command.extend(["--session", self.session])
return [
ssh_bin,
"-tt",
"-o",
"ServerAliveInterval=15",
"-o",
"ServerAliveCountMax=3",
*ssh_destination_arguments(self.target),
shlex.join(remote_command),
]
command.extend(["--remote", self.target])
if self.remote_keybindings != "local":
command.extend(["--remote-keybindings", self.remote_keybindings])
@@ -140,11 +177,13 @@ class ManagedWindow:
RunFunction = Callable[..., subprocess.CompletedProcess[str]]
def validate_session_name(name: str) -> None:
if not SESSION_NAME_RE.fullmatch(name):
raise ValueError(
"session name must be 1-64 ASCII letters, numbers, '.', '_' or '-'"
)
def serialized_hub_mutation(method): # type: ignore[no-untyped-def]
@wraps(method)
def wrapper(self, *args, **kwargs): # type: ignore[no-untyped-def]
with self.mutation_lock():
return method(self, *args, **kwargs)
return wrapper
def validate_remote_target(target: str | None) -> None:
@@ -292,6 +331,7 @@ def serialize_endpoint(endpoint: Endpoint) -> str:
"session": endpoint.session,
"target": endpoint.target,
"remote_keybindings": endpoint.remote_keybindings,
"remote_transport": endpoint.remote_transport,
},
ensure_ascii=False,
separators=(",", ":"),
@@ -308,14 +348,17 @@ def deserialize_endpoint(metadata: str, command: str = "") -> Endpoint | None:
session = payload.get("session")
target = payload.get("target")
keybindings = payload.get("remote_keybindings", "local")
transport = payload.get("remote_transport", "native")
if not isinstance(kind, str) or not isinstance(session, str):
raise ValueError
if not isinstance(keybindings, str):
raise ValueError
if not isinstance(transport, str):
raise ValueError
if kind == "local":
return Endpoint.local(session)
if kind == "remote" and isinstance(target, str):
return Endpoint.remote(target, session, keybindings)
return Endpoint.remote(target, session, keybindings, transport)
raise ValueError
except (json.JSONDecodeError, TypeError, ValueError):
pass
@@ -393,20 +436,84 @@ class TmuxHub:
self,
*,
tmux_bin: str,
ssh_bin: str = "ssh",
socket_name: str = DEFAULT_SOCKET_NAME,
session_name: str = DEFAULT_TMUX_SESSION,
config_file: str | None = None,
script_path: str | None = None,
python_bin: str | None = None,
aliases: Mapping[str, str] | None = None,
run: RunFunction = subprocess.run,
) -> None:
self.tmux_bin = tmux_bin
self.ssh_bin = ssh_bin
self.socket_name = socket_name
self.session_name = session_name
self.config_file = config_file
self.script_path = script_path or str(Path(sys.argv[0]).absolute())
self.python_bin = python_bin or sys.executable
self.aliases = dict(aliases or {})
self._run = run
self._local_mutation_lock = threading.RLock()
self._mutation_depth = 0
@property
def mutation_lock_path(self) -> Path:
identity = f"{os.getuid()}\0{self.socket_name}\0{self.session_name}"
digest = hashlib.sha256(identity.encode("utf-8")).hexdigest()[:20]
roots = []
if configured_runtime := os.environ.get("XDG_RUNTIME_DIR"):
roots.append(Path(configured_runtime).expanduser())
temporary_root = Path(tempfile.gettempdir())
if temporary_root not in roots:
roots.append(temporary_root)
failures: list[str] = []
for runtime_root in roots:
lock_directory = runtime_root / f"herdr-hub-{os.getuid()}"
try:
lock_directory.mkdir(mode=0o700, parents=False, exist_ok=True)
metadata = lock_directory.lstat()
if (
not stat.S_ISDIR(metadata.st_mode)
or metadata.st_uid != os.getuid()
):
failures.append(f"unsafe directory {lock_directory}")
continue
os.chmod(lock_directory, 0o700)
return lock_directory / f"{digest}.lock"
except OSError as error:
failures.append(f"{lock_directory}: {error}")
raise HubError("could not prepare Hub lock directory: " + "; ".join(failures))
@contextmanager
def mutation_lock(self): # type: ignore[no-untyped-def]
"""Serialize state-changing Hub commands across CLI processes."""
with self._local_mutation_lock:
if self._mutation_depth:
self._mutation_depth += 1
try:
yield
finally:
self._mutation_depth -= 1
return
descriptor = os.open(
self.mutation_lock_path,
os.O_CREAT | os.O_RDWR,
0o600,
)
try:
os.fchmod(descriptor, 0o600)
fcntl.flock(descriptor, fcntl.LOCK_EX)
self._mutation_depth = 1
try:
yield
finally:
self._mutation_depth = 0
fcntl.flock(descriptor, fcntl.LOCK_UN)
finally:
os.close(descriptor)
def base_command(self) -> list[str]:
command = [self.tmux_bin, "-L", self.socket_name]
@@ -443,6 +550,7 @@ class TmuxHub:
)
return result.returncode == 0
@serialized_hub_mutation
def ensure_server(self) -> bool:
if self.exists():
self.configure()
@@ -478,8 +586,8 @@ class TmuxHub:
"-g",
"status-left",
"#{?#{<:#{client_width},50},"
"#[bold,fg=colour81] S #[default],"
"#[bold,fg=colour81] Sessions #[default]}",
"#[bold#,fg=colour81] S #[default],"
"#[bold#,fg=colour81] Sessions #[default]}",
],
[
"set-option",
@@ -487,7 +595,7 @@ class TmuxHub:
"status-right",
"#{?#{<:#{client_width},80},,"
"#[dim]#{?pane_dead,连接已断开 · C-a R 重连,"
"C-a g choose · & close · r refresh}#[default]}",
"C-a g choose · q close · r refresh}#[default]}",
],
["set-option", "-g", "status-style", "bg=colour235,fg=colour250"],
["set-window-option", "-g", "window-status-separator", ""],
@@ -530,6 +638,10 @@ class TmuxHub:
[self.python_bin, self.script_path, "close", "--window", "#{window_id}"]
)
confirm_close = shlex.join(["run-shell", "-b", close])
# Remove tmux's default/legacy '&' binding when reconfiguring both new
# and already-running Hub servers.
self.run(["unbind-key", "-T", "prefix", "&"], check=False)
self.run(["unbind-key", "-T", "prefix", "Q"], check=False)
binding_commands = [
["bind-key", "-T", "prefix", "C-a", "send-prefix"],
["bind-key", "-T", "prefix", "r", "run-shell", "-b", refresh],
@@ -539,7 +651,7 @@ class TmuxHub:
"prefix",
"g",
"display-popup",
"-E",
"-EE",
"-w",
"90%",
"-h",
@@ -554,7 +666,7 @@ class TmuxHub:
"root",
"MouseDown1StatusLeft",
"display-popup",
"-E",
"-EE",
"-w",
"90%",
"-h",
@@ -568,7 +680,7 @@ class TmuxHub:
"bind-key",
"-T",
"prefix",
"&",
"q",
"confirm-before",
"-p",
"关闭当前 Session Tab? (y/n)",
@@ -578,7 +690,7 @@ class TmuxHub:
for command in binding_commands:
self.run(command)
self.refresh_short_labels()
self.refresh_labels()
def managed_windows(self) -> list[ManagedWindow]:
if not self.exists():
@@ -611,23 +723,42 @@ class TmuxHub:
def window_for_key(self, key: str) -> ManagedWindow | None:
return next((window for window in self.managed_windows() if window.key == key), None)
def refresh_short_labels(self) -> None:
def refresh_labels(self) -> None:
for window in self.managed_windows():
label = (
window.endpoint.short_label
if window.endpoint is not None
else truncate_label(window.name, 32)
)
self.run(
[
"set-option",
"-w",
"-t",
window.window_id,
WINDOW_SHORT_LABEL_OPTION,
label,
]
self._apply_window_label(window)
def _apply_window_label(self, window: ManagedWindow) -> None:
alias = self.aliases.get(window.key)
display_label = truncate_label(alias) if alias else window.name
short_label = truncate_label(alias, 32) if alias else truncate_label(window.name, 32)
if window.endpoint is not None:
display_label = truncate_label(alias) if alias else window.endpoint.label
short_label = (
truncate_label(alias, 32) if alias else window.endpoint.short_label
)
if window.name != display_label:
self.run(["rename-window", "-t", window.window_id, display_label])
self.run(
[
"set-option",
"-w",
"-t",
window.window_id,
WINDOW_SHORT_LABEL_OPTION,
short_label,
]
)
@serialized_hub_mutation
def apply_alias(self, key: str, label: str) -> ManagedWindow | None:
if label:
self.aliases[key] = label
else:
self.aliases.pop(key, None)
window = self.window_for_key(key)
if window is not None:
self._apply_window_label(window)
return self.window_for_key(key)
def dismissed_keys(self) -> set[str]:
result = self.run(
@@ -669,12 +800,17 @@ class TmuxHub:
dismissed.remove(key)
self.set_dismissed_keys(dismissed)
@serialized_hub_mutation
def create_window(self, endpoint: Endpoint, herdr_bin: str) -> ManagedWindow:
existing = self.window_for_key(endpoint.key)
if existing is not None:
return existing
was_dismissed = endpoint.key in self.dismissed_keys()
self.restore_endpoint(endpoint.key)
command = "exec " + shlex.join(endpoint.command(herdr_bin))
command = "exec " + shlex.join(endpoint.command(herdr_bin, self.ssh_bin))
alias = self.aliases.get(endpoint.key)
display_label = truncate_label(alias) if alias else endpoint.label
short_label = truncate_label(alias, 32) if alias else endpoint.short_label
result = self.run(
[
"new-window",
@@ -685,31 +821,39 @@ class TmuxHub:
"-t",
f"{self.session_name}:",
"-n",
endpoint.label,
display_label,
command,
]
)
window_id = result.stdout.strip()
if not window_id:
if was_dismissed:
self.set_dismissed_keys({*self.dismissed_keys(), endpoint.key})
raise HubError("tmux did not return the new window id")
for option, value in [
(WINDOW_KEY_OPTION, endpoint.key),
(WINDOW_COMMAND_OPTION, command),
(WINDOW_LABEL_OPTION, endpoint.label),
(WINDOW_ENDPOINT_OPTION, serialize_endpoint(endpoint)),
(WINDOW_SHORT_LABEL_OPTION, endpoint.short_label),
]:
self.run(["set-option", "-w", "-t", window_id, option, value])
self.run(["set-window-option", "-t", window_id, "remain-on-exit", "on"])
try:
for option, value in [
(WINDOW_KEY_OPTION, endpoint.key),
(WINDOW_COMMAND_OPTION, command),
(WINDOW_ENDPOINT_OPTION, serialize_endpoint(endpoint)),
(WINDOW_SHORT_LABEL_OPTION, short_label),
]:
self.run(["set-option", "-w", "-t", window_id, option, value])
self.run(["set-window-option", "-t", window_id, "remain-on-exit", "on"])
except HubError:
self.run(["kill-window", "-t", window_id], check=False)
if was_dismissed:
self.set_dismissed_keys({*self.dismissed_keys(), endpoint.key})
raise
return ManagedWindow(
window_id=window_id,
key=endpoint.key,
name=endpoint.label,
name=display_label,
dead=False,
active=False,
endpoint=endpoint,
)
@serialized_hub_mutation
def add_missing(self, endpoints: Sequence[Endpoint], herdr_bin: str) -> list[ManagedWindow]:
existing_keys = {window.key for window in self.managed_windows()}
dismissed_keys = self.dismissed_keys()
@@ -724,6 +868,7 @@ class TmuxHub:
self.remove_bootstrap()
return added
@serialized_hub_mutation
def remove_bootstrap(self) -> None:
result = self.run(
[
@@ -745,6 +890,7 @@ class TmuxHub:
def select_window(self, window_id: str) -> None:
self.run(["select-window", "-t", window_id])
@serialized_hub_mutation
def dismiss_window(self, window_id: str | None = None) -> ManagedWindow:
window_id = window_id or self.current_window_id()
windows = self.managed_windows()
@@ -785,6 +931,7 @@ class TmuxHub:
raise HubError("could not identify the current hub window")
return window_id
@serialized_hub_mutation
def reconnect(self, window_id: str | None = None) -> None:
window_id = window_id or self.current_window_id()
command_result = self.run(
@@ -796,11 +943,59 @@ class TmuxHub:
raise HubError("the selected window is not managed by herdr-hub")
self.run(["respawn-window", "-k", "-t", window_id, command])
@serialized_hub_mutation
def replace_endpoint(
self,
window: ManagedWindow,
endpoint: Endpoint,
herdr_bin: str,
) -> ManagedWindow:
command = "exec " + shlex.join(endpoint.command(herdr_bin, self.ssh_bin))
alias = self.aliases.get(endpoint.key)
short_label = truncate_label(alias, 32) if alias else endpoint.short_label
replacements = [
(WINDOW_COMMAND_OPTION, command),
(WINDOW_ENDPOINT_OPTION, serialize_endpoint(endpoint)),
(WINDOW_SHORT_LABEL_OPTION, short_label),
]
previous: dict[str, str] = {}
for option, _ in replacements:
result = self.run(
["show-options", "-w", "-v", "-t", window.window_id, option],
check=False,
)
if result.returncode == 0:
previous[option] = result.stdout.rstrip("\n")
try:
for option, value in replacements:
self.run(
["set-option", "-w", "-t", window.window_id, option, value]
)
self.run(["respawn-window", "-k", "-t", window.window_id, command])
except HubError:
for option, value in previous.items():
self.run(
["set-option", "-w", "-t", window.window_id, option, value],
check=False,
)
raise
replaced = self.window_for_key(endpoint.key)
if replaced is None:
raise HubError("the selected Session window disappeared during reconnect")
self._apply_window_label(replaced)
return self.window_for_key(endpoint.key) or replaced
@serialized_hub_mutation
def activate_endpoint(self, endpoint: Endpoint, herdr_bin: str) -> ManagedWindow:
window = self.window_for_key(endpoint.key)
if window is None:
window = self.create_window(endpoint, herdr_bin)
self.remove_bootstrap()
elif window.endpoint is not None and (
window.endpoint.remote_transport != endpoint.remote_transport
or window.endpoint.remote_keybindings != endpoint.remote_keybindings
):
window = self.replace_endpoint(window, endpoint, herdr_bin)
elif window.dead:
self.reconnect(window.window_id)
self.select_window(window.window_id)
@@ -825,6 +1020,7 @@ class TmuxHub:
def display_message(self, message: str) -> None:
self.run(["display-message", message], check=False)
@serialized_hub_mutation
def stop(self) -> None:
if not self.exists():
return
@@ -858,14 +1054,22 @@ def invocation_script_path() -> str:
return str(invoked)
def make_hub(arguments: argparse.Namespace) -> TmuxHub:
def make_hub(
arguments: argparse.Namespace,
*,
load_aliases: bool = True,
) -> TmuxHub:
tmux_bin = resolve_binary("tmux", os.environ.get("HERDR_HUB_TMUX_BIN"))
ssh_bin = os.environ.get("HERDR_HUB_SSH_BIN") or shutil.which("ssh") or "ssh"
aliases = make_alias_registry().load() if load_aliases else {}
return TmuxHub(
tmux_bin=tmux_bin,
ssh_bin=ssh_bin,
socket_name=os.environ.get("HERDR_HUB_SOCKET", DEFAULT_SOCKET_NAME),
session_name=os.environ.get("HERDR_HUB_SESSION", DEFAULT_TMUX_SESSION),
config_file=os.environ.get("HERDR_HUB_TMUX_CONFIG"),
script_path=invocation_script_path(),
aliases=aliases,
)
@@ -885,16 +1089,24 @@ def make_registry() -> RemoteRegistry:
return RemoteRegistry(default_registry_path())
def make_alias_registry() -> AliasRegistry:
return AliasRegistry(default_alias_registry_path())
def remember_remote_endpoints(endpoints: Iterable[Endpoint]) -> str | None:
entries = [
RemoteEntry(endpoint.target, endpoint.remote_keybindings)
RemoteEntry(
endpoint.target,
endpoint.remote_keybindings,
endpoint.remote_transport,
)
for endpoint in endpoints
if endpoint.kind == "remote" and endpoint.target is not None
]
if not entries:
return None
try:
make_registry().upsert_many(entries)
make_registry().remember_many(entries)
except RegistryError as error:
return f"remote registry: {error}"
return None
@@ -911,6 +1123,14 @@ def catalog_windows(windows: Iterable[ManagedWindow]) -> list[CatalogWindow]:
kind=window.endpoint.kind if window.endpoint is not None else None,
target=window.endpoint.target if window.endpoint is not None else None,
session=window.endpoint.session if window.endpoint is not None else None,
default_label=(
window.endpoint.label if window.endpoint is not None else window.name
),
remote_transport=(
window.endpoint.remote_transport
if window.endpoint is not None
else "native"
),
)
for window in windows
]
@@ -926,8 +1146,7 @@ def command_start(arguments: argparse.Namespace) -> int:
added = hub.add_missing(report.endpoints, herdr_bin)
windows = hub.managed_windows()
if not windows:
if created:
hub.stop()
hub.stop()
detail = "; ".join(report.warnings)
suffix = f" ({detail})" if detail else ""
raise HubError(
@@ -953,7 +1172,10 @@ def command_refresh(arguments: argparse.Namespace) -> int:
message = f"herdr-hub: added {len(added)} session(s)"
if report.warnings:
message += f"; {report.warnings[0]}"
if inside_socket(hub.socket_name):
has_windows = bool(hub.managed_windows())
if not has_windows:
hub.stop()
if inside_socket(hub.socket_name) and has_windows:
hub.display_message(message)
else:
print(message)
@@ -968,6 +1190,7 @@ def command_open(arguments: argparse.Namespace) -> int:
arguments.remote,
arguments.session,
arguments.remote_keybindings,
arguments.transport,
)
if arguments.remote is not None
else Endpoint.local(arguments.session)
@@ -975,9 +1198,17 @@ def command_open(arguments: argparse.Namespace) -> int:
except ValueError as error:
raise HubError(str(error)) from error
if endpoint.kind == "remote":
warning = remember_remote_endpoints([endpoint])
if warning:
print(f"herdr-hub: warning: {warning}", file=sys.stderr)
assert endpoint.target is not None
try:
make_registry().upsert(
RemoteEntry(
endpoint.target,
endpoint.remote_keybindings,
endpoint.remote_transport,
)
)
except RegistryError as error:
print(f"herdr-hub: warning: remote registry: {error}", file=sys.stderr)
hub = make_hub(arguments)
hub.ensure_server()
hub.activate_endpoint(endpoint, herdr_bin)
@@ -991,21 +1222,82 @@ def command_choose(arguments: argparse.Namespace) -> int:
raise HubError("herdr-hub is not running")
windows = hub.managed_windows()
local_report = discover_local_sessions(resolve_herdr())
remember_remote_endpoints(
registry_warning = remember_remote_endpoints(
window.endpoint for window in windows if window.endpoint is not None
)
alias_registry = make_alias_registry()
remote_registry = make_registry()
ssh_bin = resolve_ssh()
def rename_session(endpoint_key: str, alias: str) -> None:
alias_registry.set(endpoint_key, alias)
if hub.apply_alias(endpoint_key, alias) is None:
raise HubError("the selected Session is no longer open")
def delete_session(target: str, session: str) -> DeleteSessionOutcome:
delete_remote_session(ssh_bin, RemoteEntry(target), session)
endpoint_key = Endpoint.remote(target, session).key
warning: str | None = None
try:
alias_registry.set(endpoint_key, "")
except AliasRegistryError as error:
warning = f"Session 已删除,但显示名称清理失败:{error}"
hub.aliases.pop(endpoint_key, None)
return DeleteSessionOutcome(warning)
def stop_session(
window_id: str,
target: str,
session: str,
) -> StopSessionOutcome:
stop_remote_session(ssh_bin, RemoteEntry(target), session)
warning: str | None = None
tab_closed = False
try:
hub.dismiss_window(window_id)
tab_closed = True
except HubError as error:
warning = f"Session 已停止,但 Tab 未关闭:{error}"
updated = catalog_windows(hub.managed_windows())
if not tab_closed:
updated = [
replace(window, dead=True)
if window.window_id == window_id
else window
for window in updated
]
return StopSessionOutcome(tuple(updated), tab_closed, warning)
try:
selector = SessionSelector(
catalog_windows(windows),
make_registry(),
resolve_ssh(),
remote_registry,
ssh_bin,
local_sessions=(endpoint.session for endpoint in local_report.endpoints),
aliases=hub.aliases,
rename_handler=rename_session,
delete_session_handler=delete_session,
stop_session_handler=stop_session,
)
startup_warnings = [*local_report.warnings]
if registry_warning:
startup_warnings.append(registry_warning)
if startup_warnings:
selector.message = "; ".join(startup_warnings)
result = selector.run()
except (RegistryError, SelectorError) as error:
raise HubError(str(error)) from error
if result is None:
return 0
if result.kind == "rename":
assert result.endpoint_key is not None and result.alias is not None
rename_session(result.endpoint_key, result.alias)
window = hub.window_for_key(result.endpoint_key)
assert window is not None
action = "renamed" if result.alias else "restored the default name for"
hub.display_message(f"herdr-hub: {action} {window.name}")
return 0
if result.kind == "close":
assert result.window_id is not None
hub.dismiss_window(result.window_id)
@@ -1025,7 +1317,18 @@ def command_choose(arguments: argparse.Namespace) -> int:
result.target,
result.session,
result.remote_keybindings,
result.remote_transport,
)
try:
remote_registry.upsert(
RemoteEntry(
endpoint.target,
endpoint.remote_keybindings,
endpoint.remote_transport,
)
)
except RegistryError as error:
hub.display_message(f"herdr-hub: remote registry: {error}")
hub.activate_endpoint(endpoint, resolve_herdr())
return 0
@@ -1039,10 +1342,16 @@ def command_remote(arguments: argparse.Namespace) -> int:
print("no registered remote targets")
return 0
for entry in entries:
print(f"{entry.target}\t{entry.remote_keybindings}")
print(
f"{entry.target}\t{entry.remote_keybindings}\t{entry.remote_transport}"
)
return 0
if action == "add":
entry = RemoteEntry(arguments.target, arguments.remote_keybindings)
entry = RemoteEntry(
arguments.target,
arguments.remote_keybindings,
arguments.transport,
)
changed = registry.upsert(entry)
print(f"{'registered' if changed else 'already registered'} {entry.target}")
return 0
@@ -1054,7 +1363,7 @@ def command_remote(arguments: argparse.Namespace) -> int:
def command_reconnect(arguments: argparse.Namespace) -> int:
hub = make_hub(arguments)
hub = make_hub(arguments, load_aliases=False)
if not hub.exists():
raise HubError("herdr-hub is not running")
hub.reconnect(arguments.window)
@@ -1064,7 +1373,7 @@ def command_reconnect(arguments: argparse.Namespace) -> int:
def command_close(arguments: argparse.Namespace) -> int:
hub = make_hub(arguments)
hub = make_hub(arguments, load_aliases=False)
if not hub.exists():
raise HubError("herdr-hub is not running")
try:
@@ -1083,7 +1392,7 @@ def command_close(arguments: argparse.Namespace) -> int:
def command_stop(arguments: argparse.Namespace) -> int:
hub = make_hub(arguments)
hub = make_hub(arguments, load_aliases=False)
if hub.exists():
hub.stop()
print("stopped herdr-hub; Herdr servers were left running")
@@ -1119,6 +1428,13 @@ def command_doctor(arguments: argparse.Namespace) -> int:
except RegistryError as error:
print(f"remotes: invalid ({error})")
failures += 1
alias_registry = make_alias_registry()
try:
aliases = alias_registry.load()
print(f"aliases: {len(aliases)} configured ({alias_registry.path})")
except AliasRegistryError as error:
print(f"aliases: invalid ({error})")
failures += 1
proc_root = configured_proc_root()
print(f"proc: {proc_root} ({'available' if proc_root.is_dir() else 'missing'})")
@@ -1153,6 +1469,12 @@ def build_parser() -> argparse.ArgumentParser:
choices=["local", "server"],
default="local",
)
open_parser.add_argument(
"--transport",
choices=["native", "ssh"],
default="native",
help="remote attach transport; Windows targets require ssh",
)
subparsers.add_parser("choose", help="browse and open local or remote sessions")
remote_parser = subparsers.add_parser("remote", help="manage remembered remote targets")
@@ -1165,6 +1487,12 @@ def build_parser() -> argparse.ArgumentParser:
choices=["local", "server"],
default="local",
)
remote_add_parser.add_argument(
"--transport",
choices=["native", "ssh"],
default="native",
help="preferred attach transport; use ssh for Windows targets",
)
remote_remove_parser = remote_subparsers.add_parser(
"remove", help="forget a remote target"
)
@@ -1199,7 +1527,7 @@ def main(argv: Sequence[str] | None = None) -> int:
}
try:
return handlers[command](arguments)
except (HubError, RegistryError, ValueError) as error:
except (AliasRegistryError, HubError, RegistryError, ValueError) as error:
print(f"herdr-hub: {error}", file=sys.stderr)
return 1
except KeyboardInterrupt: