Improve remote session management and Windows support
This commit is contained in:
+393
-65
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user