v1.5.0: network interface binding, SSH fixes, terminal, release script

- Add network interface selection per server (VPN/multi-NIC support)
- Fix "Install Everything" button hanging on error
- Add interactive SSH terminal with PTY (pyte + xterm-256color)
- Add release.py for automated versioning and changelog generation
- Add CLAUDE.md with project instructions
- Add screenshots and release binaries for v1.1–v1.4

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chrome-storm-c442
2026-02-23 14:06:41 -05:00
parent 0c89e77417
commit a83a97c9d5
33 changed files with 1221 additions and 173 deletions

View File

@@ -1,5 +1,41 @@
# Changelog
## [1.5.0] - 2026-02-23
### Added
- **Network interface binding** — choose which NIC (IP) to use per server (VPN, multi-NIC setups)
- Dropdown in server add/edit dialog (via `psutil.net_if_addrs()`)
- `bind_interface` saved per server, used in SSH connect via `socket.bind()`
- Works on Windows/macOS/Linux without admin rights
- Status checker and terminal automatically use the bound interface
- `psutil` dependency in requirements.txt
### Fixed
- **"Install Everything" button** no longer hangs on error — try/except/finally ensures button always resets
- `generate_ssh_key()` handles missing paramiko gracefully (returns error message instead of crashing thread)
- `install_all()` catches per-step exceptions — all steps run even if one fails
- `_gen_key()` in Setup tab now catches and displays errors in the log
### Changed
- `check_connection()` refactored — uses shared `_connect_client()` instead of duplicated logic
- `_connect_client()` supports `bind_interface` with socket binding and proper socket recreation on auth retry
- Logging added to `install_ssh_script()`, `install_skill()`, `generate_ssh_key()`, `install_all()`
- `build.py` — added `--hidden-import psutil`
- `CLAUDE.md` — added version sync checklist
- `version.py` → 1.5.0
## [1.4.0] - 2026-02-23
### Added
- Interactive SSH terminal with PTY (xterm-256color)
- `pyte` terminal emulator integration
- `ShellSession` class for persistent shell sessions
- Configurable monitoring intervals (30s, 60s, 2min, 5min)
- Skip status check option per server
### Changed
- `version.py` → 1.4.0
## [1.3.0] - 2026-02-23
### Added

17
CLAUDE.md Normal file
View File

@@ -0,0 +1,17 @@
# CLAUDE.md — инструкции для Claude Code
## Текущая версия: 1.5.0
## Версионирование
Файл версии: `version.py``__version__`
Semver: **MAJOR** (ломающие изменения) | **MINOR** (новая фича) | **PATCH** (багфикс, мелкие правки)
**Порядок релиза:**
1. Внести изменения в код и закоммитить
2. `python release.py minor` (или `patch` / `major` / `2.0.0`)
3. `python build.py --clean`
4. Закоммитить и запушить
Скрипт `release.py` автоматически обновляет все 4 файла (`version.py`, `CHANGELOG.md`, `README.md`, `CLAUDE.md`) и генерирует changelog из git log.

View File

@@ -53,7 +53,7 @@ pip install pyinstaller
python build.py
```
Output goes to `releases/ServerManager-v1.3.0-{platform}.exe`
Output goes to `releases/ServerManager-v1.5.0-{platform}.exe`
### Usage
@@ -223,7 +223,7 @@ pip install pyinstaller
python build.py
```
Результат в `releases/ServerManager-v1.3.0-{платформа}.exe`
Результат в `releases/ServerManager-v1.5.0-{платформа}.exe`
### Использование
@@ -363,7 +363,7 @@ pip install pyinstaller
python build.py
```
输出至 `releases/ServerManager-v1.3.0-{平台}.exe`
输出至 `releases/ServerManager-v1.5.0-{平台}.exe`
### 使用方法

View File

@@ -79,6 +79,8 @@ def build():
"--hidden-import", "customtkinter",
"--hidden-import", "PIL",
"--hidden-import", "pyotp",
"--hidden-import", "pyte",
"--hidden-import", "psutil",
"--collect-all", "customtkinter",
])

View File

@@ -7,6 +7,7 @@ for Claude Code to manage servers via the shared servers.json.
import os
import sys
import shutil
from core.logger import log
SHARED_DIR = os.path.expanduser("~/.server-connections")
@@ -47,6 +48,7 @@ def install_ssh_script() -> str:
dst = os.path.join(SHARED_DIR, "ssh.py")
if os.path.exists(SSH_SCRIPT_SRC):
shutil.copy2(SSH_SCRIPT_SRC, dst)
log.info(f"ssh.py installed: {dst}")
results.append(f"ssh.py installed: {dst}")
elif os.path.exists(dst):
results.append(f"ssh.py already exists: {dst}")
@@ -57,6 +59,7 @@ def install_ssh_script() -> str:
enc_dst = os.path.join(SHARED_DIR, "encryption.py")
if os.path.exists(ENCRYPTION_SRC):
shutil.copy2(ENCRYPTION_SRC, enc_dst)
log.info(f"encryption.py installed: {enc_dst}")
results.append(f"encryption.py installed: {enc_dst}")
elif os.path.exists(enc_dst):
results.append(f"encryption.py already exists: {enc_dst}")
@@ -71,6 +74,7 @@ def install_skill() -> str:
os.makedirs(SKILL_DST_DIR, exist_ok=True)
if os.path.exists(SKILL_SRC):
shutil.copy2(SKILL_SRC, SKILL_DST)
log.info(f"Skill installed: {SKILL_DST}")
return f"Skill installed: {SKILL_DST}"
# Fallback: check existing
if os.path.exists(SKILL_DST):
@@ -79,6 +83,7 @@ def install_skill() -> str:
skill_content = _generate_skill_content()
with open(SKILL_DST, "w", encoding="utf-8") as f:
f.write(skill_content)
log.info(f"Skill generated: {SKILL_DST}")
return f"Skill generated: {SKILL_DST}"
@@ -89,7 +94,13 @@ def generate_ssh_key() -> str:
os.makedirs(os.path.dirname(SSH_KEY_PATH), exist_ok=True)
import paramiko
try:
import paramiko
except ImportError:
msg = "ERROR: paramiko is not installed — cannot generate SSH key"
log.error(msg)
return msg
key = paramiko.Ed25519Key.generate()
key.write_private_key_file(SSH_KEY_PATH)
@@ -97,15 +108,30 @@ def generate_ssh_key() -> str:
with open(SSH_KEY_PATH + ".pub", "w") as f:
f.write(pub_key + "\n")
log.info(f"SSH key generated: {SSH_KEY_PATH}")
return f"Key generated: {SSH_KEY_PATH}"
def install_all() -> list[str]:
"""Full setup — install everything."""
results = []
results.append(install_ssh_script())
results.append(install_skill())
results.append(generate_ssh_key())
steps = [
("ssh_script", install_ssh_script),
("skill", install_skill),
("ssh_key", generate_ssh_key),
]
for name, func in steps:
try:
log.info(f"install_all: running {name}")
result = func()
results.append(result)
except Exception as e:
msg = f"ERROR ({name}): {e}"
log.error(msg)
results.append(msg)
return results

View File

@@ -107,6 +107,12 @@ _EN = {
"clear": "Clear",
"no_server_selected": "[!] No server selected",
"server_not_found": "[!] Server '{alias}' not found",
"term_connecting": "Connecting to {alias}...",
"term_connected": "Connected to {alias}",
"term_disconnected": "Disconnected",
"term_reconnecting": "Reconnecting ({n}/{max})...",
"term_connect_failed": "Connection failed: {error}",
"term_reconnect_fail": "Disconnected (reconnect failed)",
# Files
"upload": "Upload",
@@ -209,6 +215,21 @@ _EN = {
"totp_secret_dialog": "TOTP Secret",
"placeholder_totp_secret": "Base32 secret (optional)",
"port_out_of_range": "Port must be 1-65535",
# Monitoring
"monitoring": "Monitoring",
"check_interval": "Check interval",
"skip_check": "Skip status checks",
"skip_check_desc": "Don't check this server's availability",
"interval_30s": "30s",
"interval_60s": "60s",
"interval_120s": "2min",
"interval_300s": "5min",
"status_disabled": "disabled",
# Network interface
"network_interface": "Network Interface",
"auto_default": "Auto (default)",
}
_RU = {
@@ -293,6 +314,12 @@ _RU = {
"clear": "Очистить",
"no_server_selected": "[!] Сервер не выбран",
"server_not_found": "[!] Сервер '{alias}' не найден",
"term_connecting": "Подключение к {alias}...",
"term_connected": "Подключено к {alias}",
"term_disconnected": "Отключено",
"term_reconnecting": "Переподключение ({n}/{max})...",
"term_connect_failed": "Ошибка подключения: {error}",
"term_reconnect_fail": "Отключено (не удалось переподключиться)",
# Files
"upload": "Загрузить",
@@ -395,6 +422,21 @@ _RU = {
"totp_secret_dialog": "TOTP-секрет",
"placeholder_totp_secret": "Base32 секрет (необязательно)",
"port_out_of_range": "Порт должен быть от 1 до 65535",
# Monitoring
"monitoring": "Мониторинг",
"check_interval": "Интервал проверки",
"skip_check": "Не проверять доступность",
"skip_check_desc": "Исключить сервер из автопроверки",
"interval_30s": "30с",
"interval_60s": "60с",
"interval_120s": "2мин",
"interval_300s": "5мин",
"status_disabled": "отключено",
# Network interface
"network_interface": "Сетевой интерфейс",
"auto_default": "Авто (по умолчанию)",
}
_ZH = {
@@ -479,6 +521,12 @@ _ZH = {
"clear": "清除",
"no_server_selected": "[!] 未选择服务器",
"server_not_found": "[!] 未找到服务器 '{alias}'",
"term_connecting": "正在连接 {alias}...",
"term_connected": "已连接到 {alias}",
"term_disconnected": "已断开",
"term_reconnecting": "重新连接中 ({n}/{max})...",
"term_connect_failed": "连接失败:{error}",
"term_reconnect_fail": "已断开(重连失败)",
# Files
"upload": "上传",
@@ -581,6 +629,21 @@ _ZH = {
"totp_secret_dialog": "TOTP密钥",
"placeholder_totp_secret": "Base32密钥可选",
"port_out_of_range": "端口必须在1-65535之间",
# Monitoring
"monitoring": "监控",
"check_interval": "检查间隔",
"skip_check": "跳过状态检查",
"skip_check_desc": "不检查此服务器的可用性",
"interval_30s": "30秒",
"interval_60s": "60秒",
"interval_120s": "2分钟",
"interval_300s": "5分钟",
"status_disabled": "已禁用",
# Network interface
"network_interface": "网络接口",
"auto_default": "自动(默认)",
}
_TRANSLATIONS = {

View File

@@ -44,6 +44,7 @@ class ServerStore:
def __init__(self):
self._data: dict = {"servers": [], "ssh_key": {"type": "ed25519", "path": "~/.ssh/id_ed25519"}}
self._observers: list[Callable] = []
self._check_interval: int = 60
self._statuses: dict[str, str] = {} # alias -> "online" | "offline" | "unknown"
self._statuses_lock = threading.Lock()
self._file_lock = threading.Lock()
@@ -66,6 +67,7 @@ class ServerStore:
from core import i18n
lang = settings.get("language", "en")
i18n.set_language(lang)
self._check_interval = settings.get("check_interval", 60)
except json.JSONDecodeError:
log.warning("Corrupted settings.json, using defaults")
except Exception as e:
@@ -77,6 +79,7 @@ class ServerStore:
settings = {
"servers_path": self._servers_file,
"language": i18n.get_language(),
"check_interval": self._check_interval,
}
try:
tmp = SETTINGS_FILE + ".tmp"
@@ -284,6 +287,13 @@ class ServerStore:
# ── Status management (thread-safe) ───────────────
def get_check_interval(self) -> int:
return self._check_interval
def set_check_interval(self, seconds: int):
self._check_interval = max(10, min(600, seconds))
self._save_settings()
def set_status(self, alias: str, status: str):
with self._statuses_lock:
self._statuses[alias] = status

View File

@@ -4,10 +4,170 @@ SSH client wrapper — connect, exec, sftp, key management via paramiko.
import os
import platform
import socket
import threading
import time
import paramiko
from core.logger import log
def _create_bound_socket(bind_ip: str, hostname: str, port: int, timeout: int) -> socket.socket:
"""Create a TCP socket bound to a specific local IP address."""
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(timeout)
sock.bind((bind_ip, 0))
sock.connect((hostname, port))
return sock
def _connect_client(server: dict, key_path: str, timeout: int = 15) -> paramiko.SSHClient:
"""Create and authenticate a paramiko SSHClient. Shared by SSHClientWrapper and ShellSession."""
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
hostname = server["ip"]
port = server.get("port", 22)
bind_ip = server.get("bind_interface")
kwargs = {
"hostname": hostname,
"port": port,
"username": server.get("user", "root"),
"timeout": timeout,
"banner_timeout": timeout,
}
if bind_ip:
kwargs["sock"] = _create_bound_socket(bind_ip, hostname, port, timeout)
# Try key first
if key_path and os.path.exists(key_path):
try:
kwargs["key_filename"] = key_path
client.connect(**kwargs)
return client
except paramiko.AuthenticationException:
log.debug(f"Key auth failed for {server.get('alias', '?')}, trying password")
del kwargs["key_filename"]
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
if bind_ip:
kwargs["sock"] = _create_bound_socket(bind_ip, hostname, port, timeout)
except Exception as e:
log.debug(f"Key connect failed: {e}")
del kwargs["key_filename"]
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
if bind_ip:
kwargs["sock"] = _create_bound_socket(bind_ip, hostname, port, timeout)
# Fallback to password
password = server.get("password", "")
if password:
kwargs["password"] = password
kwargs["look_for_keys"] = False
kwargs["allow_agent"] = False
client.connect(**kwargs)
return client
raise Exception(f"No auth method for {server.get('alias', 'unknown')}")
class ShellSession:
"""Persistent interactive shell session over SSH."""
def __init__(self, server: dict, key_path: str, cols: int = 80, rows: int = 24):
self.server = server
self.key_path = key_path
self.cols = cols
self.rows = rows
self._client: paramiko.SSHClient | None = None
self._channel: paramiko.Channel | None = None
self._running = False
self._read_thread: threading.Thread | None = None
# Callbacks — set by the owner
self.on_data = None # on_data(data: bytes)
self.on_disconnect = None # on_disconnect()
@property
def connected(self) -> bool:
return (
self._channel is not None
and self._channel.get_transport() is not None
and self._channel.get_transport().is_active()
)
def connect(self):
self._client = _connect_client(self.server, self.key_path)
self._channel = self._client.invoke_shell(
term="xterm-256color",
width=self.cols,
height=self.rows,
)
self._channel.settimeout(0.1)
self._running = True
self._read_thread = threading.Thread(target=self._read_loop, daemon=True)
self._read_thread.start()
def _read_loop(self):
try:
while self._running:
try:
data = self._channel.recv(4096)
if not data:
break
if self.on_data:
self.on_data(data)
except TimeoutError:
continue
except OSError:
break
except Exception as e:
log.debug(f"ShellSession read loop error: {e}")
finally:
if self._running:
self._running = False
if self.on_disconnect:
self.on_disconnect()
def send(self, data: bytes):
if self._channel and self._running:
try:
self._channel.sendall(data)
except OSError:
pass
def resize(self, cols: int, rows: int):
self.cols = cols
self.rows = rows
if self._channel and self._running:
try:
self._channel.resize_pty(width=cols, height=rows)
except OSError:
pass
def disconnect(self):
self._running = False
if self._channel:
try:
self._channel.close()
except Exception:
pass
self._channel = None
if self._client:
try:
self._client.close()
except Exception:
pass
self._client = None
def reconnect(self):
self.disconnect()
time.sleep(0.2)
self.connect()
class SSHClientWrapper:
def __init__(self, server: dict, key_path: str = ""):
self.server = server
@@ -15,46 +175,9 @@ class SSHClientWrapper:
self._client: paramiko.SSHClient | None = None
def connect(self) -> paramiko.SSHClient:
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
kwargs = {
"hostname": self.server["ip"],
"port": self.server.get("port", 22),
"username": self.server.get("user", "root"),
"timeout": 15,
"banner_timeout": 15,
}
# Try key first
if os.path.exists(self.key_path):
try:
kwargs["key_filename"] = self.key_path
client.connect(**kwargs)
self._client = client
return client
except paramiko.AuthenticationException:
log.debug(f"Key auth failed for {self.server.get('alias', '?')}, trying password")
del kwargs["key_filename"]
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
except Exception as e:
log.debug(f"Key connect failed: {e}")
del kwargs["key_filename"]
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
# Fallback to password
password = self.server.get("password", "")
if password:
kwargs["password"] = password
kwargs["look_for_keys"] = False
kwargs["allow_agent"] = False
client.connect(**kwargs)
self._client = client
return client
raise Exception(f"No auth method for {self.server.get('alias', 'unknown')}")
client = _connect_client(self.server, self.key_path)
self._client = client
return client
def disconnect(self):
if self._client:
@@ -73,11 +196,11 @@ class SSHClientWrapper:
need_sudo = use_sudo and user != "root"
if need_sudo:
full_cmd = f"sudo -S -p '' bash -c {_shell_quote(command)}"
full_cmd = f"export TERM=xterm; sudo -S -p '' bash -c {_shell_quote(command)}"
else:
full_cmd = command
full_cmd = f"export TERM=xterm; {command}"
stdin, stdout, stderr = client.exec_command(full_cmd, timeout=120)
stdin, stdout, stderr = client.exec_command(full_cmd, timeout=120, get_pty=True)
if need_sudo:
password = self.server.get("password", "")
@@ -131,38 +254,9 @@ class SSHClientWrapper:
def check_connection(self) -> bool:
try:
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
kwargs = {
"hostname": self.server["ip"],
"port": self.server.get("port", 22),
"username": self.server.get("user", "root"),
"timeout": 5,
"banner_timeout": 5,
}
if os.path.exists(self.key_path):
try:
kwargs["key_filename"] = self.key_path
client.connect(**kwargs)
client.close()
return True
except Exception:
del kwargs["key_filename"]
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
password = self.server.get("password", "")
if password:
kwargs["password"] = password
kwargs["look_for_keys"] = False
kwargs["allow_agent"] = False
client.connect(**kwargs)
client.close()
return True
return False
client = _connect_client(self.server, self.key_path, timeout=5)
client.close()
return True
except Exception:
return False

View File

@@ -15,9 +15,8 @@ from core.logger import log
class StatusChecker:
def __init__(self, store: "ServerStore", interval: int = 60):
def __init__(self, store: "ServerStore"):
self.store = store
self.interval = interval
self._running = False
self._thread: threading.Thread | None = None
self._gui_callback = None
@@ -48,18 +47,25 @@ class StatusChecker:
def _loop(self):
while self._running:
self._check_cycle()
for _ in range(self.interval * 10):
interval = self.store.get_check_interval()
for _ in range(interval * 10):
if not self._running:
return
time.sleep(0.1)
def _check_cycle(self):
servers = self.store.get_all()
ssh_servers = [s for s in servers if s.get("type", "ssh") == "ssh"]
# Mark non-SSH as unknown
# Mark skipped servers as disabled
for s in servers:
if s.get("type", "ssh") != "ssh":
if s.get("skip_check", False):
self.store.set_status(s["alias"], "disabled")
ssh_servers = [s for s in servers if s.get("type", "ssh") == "ssh" and not s.get("skip_check", False)]
# Mark non-SSH (non-skipped) as unknown
for s in servers:
if s.get("type", "ssh") != "ssh" and not s.get("skip_check", False):
self.store.set_status(s["alias"], "unknown")
if not ssh_servers:

View File

@@ -34,7 +34,7 @@ class App(ctk.CTk):
# Core
self.store = ServerStore()
self.checker = StatusChecker(self.store, interval=60)
self.checker = StatusChecker(self.store)
# Layout
self._build_layout()
@@ -171,6 +171,9 @@ class App(ctk.CTk):
# Use provided key or default to first tab
current_key = restore_tab_key or self._tab_keys[0]
# Disconnect terminal before destroying tabs
self.terminal_tab._disconnect()
# Detach tab contents
self.terminal_tab.pack_forget()
self.files_tab.pack_forget()
@@ -223,5 +226,6 @@ class App(ctk.CTk):
self.sidebar.update_language()
def _on_close(self):
self.terminal_tab._disconnect()
self.checker.stop()
self.destroy()

View File

@@ -7,6 +7,20 @@ from core.server_store import SERVER_TYPES, DEFAULT_PORTS
from core.i18n import t
def _get_network_interfaces() -> list[tuple[str, str]]:
"""Return list of (name, ipv4_address) for available network interfaces."""
try:
import psutil
result = []
for name, addrs in psutil.net_if_addrs().items():
for addr in addrs:
if addr.family.name == "AF_INET" and addr.address != "127.0.0.1":
result.append((name, addr.address))
return result
except Exception:
return []
class ServerDialog(ctk.CTkToplevel):
def __init__(self, master, store, server: dict | None = None):
super().__init__(master)
@@ -15,7 +29,7 @@ class ServerDialog(ctk.CTkToplevel):
self.result = None
self.title(t("edit_server") if server else t("add_server"))
self.geometry("450x580")
self.geometry("450x680")
self.resizable(False, False)
self.grab_set()
@@ -58,6 +72,20 @@ class ServerDialog(ctk.CTkToplevel):
self.port_entry = ctk.CTkEntry(port_frame, placeholder_text=t("placeholder_port"))
self.port_entry.pack(fill="x")
# Network interface
ctk.CTkLabel(self, text=t("network_interface"), anchor="w").pack(fill="x", **pad)
self._iface_map: dict[str, str] = {} # display_name -> ip
ifaces = _get_network_interfaces()
auto_label = t("auto_default")
iface_values = [auto_label]
for name, ip in ifaces:
label = f"{name} ({ip})"
iface_values.append(label)
self._iface_map[label] = ip
self._iface_var = ctk.StringVar(value=auto_label)
self._iface_menu = ctk.CTkOptionMenu(self, values=iface_values, variable=self._iface_var)
self._iface_menu.pack(fill="x", **entry_pad)
# User
ctk.CTkLabel(self, text=t("username"), anchor="w").pack(fill="x", **pad)
self.user_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_user"))
@@ -79,6 +107,13 @@ class ServerDialog(ctk.CTkToplevel):
font=ctk.CTkFont(family="Consolas", size=12))
self.totp_entry.pack(fill="x", **entry_pad)
# Skip status checks
self.skip_check_var = ctk.BooleanVar(value=False)
self.skip_check_cb = ctk.CTkCheckBox(
self, text=t("skip_check"), variable=self.skip_check_var
)
self.skip_check_cb.pack(fill="x", padx=20, pady=(8, 2))
# Notes
ctk.CTkLabel(self, text=t("notes"), anchor="w").pack(fill="x", **pad)
self.notes_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_notes"))
@@ -100,8 +135,26 @@ class ServerDialog(ctk.CTkToplevel):
self.user_entry.insert(0, server.get("user", ""))
self.password_entry.insert(0, server.get("password", ""))
self.totp_entry.insert(0, server.get("totp_secret", ""))
self.skip_check_var.set(server.get("skip_check", False))
self.notes_entry.insert(0, server.get("notes", ""))
# Restore network interface selection
saved_ip = server.get("bind_interface")
if saved_ip:
found = False
for label, ip in self._iface_map.items():
if ip == saved_ip:
self._iface_var.set(label)
found = True
break
if not found:
unavail_label = f"? ({saved_ip})"
self._iface_map[unavail_label] = saved_ip
current_values = self._iface_menu.cget("values")
current_values.append(unavail_label)
self._iface_menu.configure(values=current_values)
self._iface_var.set(unavail_label)
def _on_type_change(self, value):
default_port = DEFAULT_PORTS.get(value, 22)
self.port_entry.delete(0, "end")
@@ -149,6 +202,14 @@ class ServerDialog(ctk.CTkToplevel):
}
if totp_secret:
server_data["totp_secret"] = totp_secret
if self.skip_check_var.get():
server_data["skip_check"] = True
# Network interface binding
iface_selection = self._iface_var.get()
bind_ip = self._iface_map.get(iface_selection)
if bind_ip:
server_data["bind_interface"] = bind_ip
try:
if self.editing:

View File

@@ -9,6 +9,7 @@ from tkinter import filedialog, messagebox
import customtkinter as ctk
from core.claude_setup import check_status, install_all, install_ssh_script, install_skill, generate_ssh_key
from core.i18n import t
from core.logger import log
class SetupTab(ctk.CTkFrame):
@@ -88,6 +89,35 @@ class SetupTab(ctk.CTkFrame):
command=self._refresh_status)
self.refresh_btn.pack(side="right")
# ── Monitoring section ─────────────────────────
monitor_frame = ctk.CTkFrame(self)
monitor_frame.pack(fill="x", padx=20, pady=(5, 5))
self.monitor_title = ctk.CTkLabel(
monitor_frame, text=t("monitoring"),
font=ctk.CTkFont(size=14, weight="bold"), anchor="w"
)
self.monitor_title.pack(fill="x", padx=15, pady=(10, 5))
interval_row = ctk.CTkFrame(monitor_frame, fg_color="transparent")
interval_row.pack(fill="x", padx=15, pady=(0, 10))
self.interval_label = ctk.CTkLabel(interval_row, text=t("check_interval"), anchor="w")
self.interval_label.pack(side="left", padx=(0, 10))
self._interval_buttons: dict[int, ctk.CTkButton] = {}
current_interval = store.get_check_interval()
for seconds, key in [(30, "interval_30s"), (60, "interval_60s"), (120, "interval_120s"), (300, "interval_300s")]:
is_active = (seconds == current_interval)
btn = ctk.CTkButton(
interval_row, text=t(key), width=60, height=28,
fg_color="#3b82f6" if is_active else "#6b7280",
hover_color="#2563eb" if is_active else "#4b5563",
command=lambda s=seconds: self._set_interval(s)
)
btn.pack(side="left", padx=2)
self._interval_buttons[seconds] = btn
# ── Configuration section ─────────────────────
config_frame = ctk.CTkFrame(self)
config_frame.pack(fill="x", padx=20, pady=(5, 5))
@@ -148,6 +178,14 @@ class SetupTab(ctk.CTkFrame):
# Initial status check
self._refresh_status()
def _set_interval(self, seconds: int):
self.store.set_check_interval(seconds)
for s, btn in self._interval_buttons.items():
if s == seconds:
btn.configure(fg_color="#3b82f6", hover_color="#2563eb")
else:
btn.configure(fg_color="#6b7280", hover_color="#4b5563")
def _log(self, text: str):
self.log.configure(state="normal")
self.log.insert("end", text + "\n")
@@ -166,12 +204,17 @@ class SetupTab(ctk.CTkFrame):
self.install_all_btn.configure(state="disabled", text=t("installing_all"))
def _do():
results = install_all()
for msg in results:
self.after(0, lambda m=msg: self._log(m))
self.after(0, self._refresh_status)
self.after(0, lambda: self._log("\n" + t("install_done")))
self.after(0, lambda: self.install_all_btn.configure(state="normal", text=t("install_everything")))
try:
results = install_all()
for msg in results:
self.after(0, lambda m=msg: self._log(m))
self.after(0, self._refresh_status)
self.after(0, lambda: self._log("\n" + t("install_done")))
except Exception as e:
log.error(f"install_all failed: {e}")
self.after(0, lambda: self._log(f"ERROR: {e}"))
finally:
self.after(0, lambda: self.install_all_btn.configure(state="normal", text=t("install_everything")))
threading.Thread(target=_do, daemon=True).start()
@@ -186,8 +229,12 @@ class SetupTab(ctk.CTkFrame):
self._refresh_status()
def _gen_key(self):
msg = generate_ssh_key()
self._log(msg)
try:
msg = generate_ssh_key()
self._log(msg)
except Exception as e:
log.error(f"generate_ssh_key failed: {e}")
self._log(f"ERROR: {e}")
self._refresh_status()
# ── Configuration methods ─────────────────────────

View File

@@ -1,10 +1,11 @@
"""
Terminal tab — command input + output display.
Terminal tab — persistent interactive SSH shell via ShellSession + TerminalWidget.
"""
import threading
import time
import customtkinter as ctk
from core.ssh_client import SSHClientWrapper
from core.ssh_client import ShellSession
from core.i18n import t
@@ -13,93 +14,110 @@ class TerminalTab(ctk.CTkFrame):
super().__init__(master, fg_color="transparent")
self.store = store
self._current_alias: str | None = None
self._session: ShellSession | None = None
self._reconnect_count = 0
self._max_reconnect = 3
self._intentional_disconnect = False
# Output
self.output = ctk.CTkTextbox(self, font=ctk.CTkFont(family="Consolas", size=12), state="disabled")
self.output.pack(fill="both", expand=True, padx=10, pady=(10, 5))
# Import here to avoid circular issues
from gui.widgets.terminal_widget import TerminalWidget
# Input row
input_frame = ctk.CTkFrame(self, fg_color="transparent")
input_frame.pack(fill="x", padx=10, pady=(0, 10))
self.sudo_var = ctk.BooleanVar(value=True)
self.sudo_check = ctk.CTkCheckBox(input_frame, text=t("sudo"), variable=self.sudo_var, width=60)
self.sudo_check.pack(side="left", padx=(0, 5))
self.cmd_entry = ctk.CTkEntry(input_frame, placeholder_text=t("enter_command"))
self.cmd_entry.pack(side="left", fill="x", expand=True, padx=(0, 5))
self.cmd_entry.bind("<Return>", lambda e: self._run_command())
self.run_btn = ctk.CTkButton(input_frame, text=t("run"), width=70, command=self._run_command)
self.run_btn.pack(side="left", padx=(0, 5))
self.clear_btn = ctk.CTkButton(input_frame, text=t("clear"), width=60, fg_color="#6b7280", command=self._clear)
self.clear_btn.pack(side="right")
self._terminal = TerminalWidget(
self,
send_callback=self._send_to_shell,
resize_callback=self._on_resize,
)
self._terminal.pack(fill="both", expand=True, padx=5, pady=5)
self._terminal.set_status(t("term_disconnected"), "#888888")
def set_server(self, alias: str | None):
if alias == self._current_alias:
return
self._disconnect()
self._current_alias = alias
if alias:
server = self.store.get_server(alias)
user = server.get("user", "root") if server else "root"
self.sudo_var.set(user != "root")
self._connect()
else:
self._terminal.reset()
self._terminal.set_status(t("term_disconnected"), "#888888")
def _append_output(self, text: str, color: str = "white"):
self.output.configure(state="normal")
self.output.insert("end", text)
self.output.configure(state="disabled")
self.output.see("end")
def _run_command(self):
def _connect(self):
if not self._current_alias:
self._append_output(t("no_server_selected") + "\n")
return
command = self.cmd_entry.get().strip()
if not command:
return
server = self.store.get_server(self._current_alias)
if not server:
self._append_output(t("server_not_found").format(alias=self._current_alias) + "\n")
self._terminal.set_status(
t("server_not_found").format(alias=self._current_alias), "#ff4444"
)
return
self.cmd_entry.delete(0, "end")
use_sudo = self.sudo_var.get()
prefix = f"[{self._current_alias}]$ "
if use_sudo and server.get("user", "root") != "root":
prefix = f"[{self._current_alias}]# "
self._append_output(f"{prefix}{command}\n")
alias = self._current_alias
self._terminal.set_status(t("term_connecting").format(alias=alias), "#ccaa00")
self._terminal.reset()
self._intentional_disconnect = False
self.run_btn.configure(state="disabled", text="...")
def _exec():
def _do_connect():
try:
key_path = self.store.get_ssh_key_path()
wrapper = SSHClientWrapper(server, key_path)
out, err, code = wrapper.exec_command(command, use_sudo=use_sudo)
def _show():
if out:
self._append_output(out)
if not out.endswith("\n"):
self._append_output("\n")
if err:
self._append_output(f"STDERR: {err}\n")
if code != 0:
self._append_output(f"[exit code: {code}]\n")
self._append_output("\n")
self.run_btn.configure(state="normal", text=t("run"))
self.after(0, _show)
cols, rows = self._terminal.get_size()
session = ShellSession(server, key_path, cols=cols, rows=rows)
session.on_data = self._on_data_received
session.on_disconnect = self._on_disconnected
session.connect()
self._session = session
self._reconnect_count = 0
self.after(0, lambda: self._terminal.set_status(
t("term_connected").format(alias=alias), "#44cc44"
))
self.after(0, self._terminal.focus_terminal)
except Exception as e:
def _err():
self._append_output(f"[ERROR] {e}\n\n")
self.run_btn.configure(state="normal", text=t("run"))
self.after(0, _err)
self.after(0, lambda: self._terminal.set_status(
t("term_connect_failed").format(error=str(e)), "#ff4444"
))
threading.Thread(target=_exec, daemon=True).start()
threading.Thread(target=_do_connect, daemon=True).start()
def _clear(self):
self.output.configure(state="normal")
self.output.delete("1.0", "end")
self.output.configure(state="disabled")
def _disconnect(self):
self._intentional_disconnect = True
if self._session:
self._session.disconnect()
self._session = None
def _on_data_received(self, data: bytes):
self.after(0, lambda d=data: self._terminal.feed(d))
def _on_disconnected(self):
if self._intentional_disconnect:
self.after(0, lambda: self._terminal.set_status(
t("term_disconnected"), "#888888"
))
return
self._session = None
if self._reconnect_count < self._max_reconnect:
self._reconnect_count += 1
n = self._reconnect_count
mx = self._max_reconnect
self.after(0, lambda: self._terminal.set_status(
t("term_reconnecting").format(n=n, max=mx), "#ccaa00"
))
def _retry():
time.sleep(1)
if not self._intentional_disconnect and self._current_alias:
self.after(0, self._connect)
threading.Thread(target=_retry, daemon=True).start()
else:
self.after(0, lambda: self._terminal.set_status(
t("term_reconnect_fail"), "#ff4444"
))
def _send_to_shell(self, data: bytes):
if self._session and self._session.connected:
self._session.send(data)
def _on_resize(self, cols: int, rows: int):
if self._session and self._session.connected:
self._session.resize(cols, rows)

View File

@@ -8,6 +8,7 @@ COLORS = {
"online": "#22c55e", # green
"offline": "#ef4444", # red
"unknown": "#6b7280", # gray
"disabled": "#9ca3af", # light gray
}
@@ -23,4 +24,5 @@ class StatusBadge(ctk.CTkLabel):
def _update_color(self):
color = COLORS.get(self._status, COLORS["unknown"])
self.configure(text="\u25cf", text_color=color, font=("", 14))
symbol = "\u2014" if self._status == "disabled" else "\u25cf"
self.configure(text=symbol, text_color=color, font=("", 14))

View File

@@ -0,0 +1,400 @@
"""
Terminal widget — pyte VT100 emulator + tkinter.Text with ANSI colors,
full keyboard mapping, diff-based rendering, and cursor display.
"""
import tkinter as tk
import tkinter.font as tkfont
import pyte
# 16 standard ANSI colors
_ANSI_COLORS = {
"black": "#000000",
"red": "#cc0000",
"green": "#4e9a06",
"brown": "#c4a000",
"blue": "#3465a4",
"magenta": "#75507b",
"cyan": "#06989a",
"white": "#d3d7cf",
"brightblack": "#555753",
"brightred": "#ef2929",
"brightgreen": "#8ae234",
"brightbrown": "#fce94f",
"brightblue": "#729fcf",
"brightmagenta": "#ad7fa8",
"brightcyan": "#34e2e2",
"brightwhite": "#eeeeec",
}
_DEFAULT_FG = "#d3d7cf"
_DEFAULT_BG = "#1a1a2e"
_CURSOR_FG = "#1a1a2e"
_CURSOR_BG = "#d3d7cf"
# Key → VT100 escape sequence
_KEY_MAP = {
"Up": "\x1b[A",
"Down": "\x1b[B",
"Right": "\x1b[C",
"Left": "\x1b[D",
"Home": "\x1b[H",
"End": "\x1b[F",
"Insert": "\x1b[2~",
"Delete": "\x1b[3~",
"Prior": "\x1b[5~", # PageUp
"Next": "\x1b[6~", # PageDown
"F1": "\x1bOP",
"F2": "\x1bOQ",
"F3": "\x1bOR",
"F4": "\x1bOS",
"F5": "\x1b[15~",
"F6": "\x1b[17~",
"F7": "\x1b[18~",
"F8": "\x1b[19~",
"F9": "\x1b[20~",
"F10": "\x1b[21~",
"F11": "\x1b[23~",
"F12": "\x1b[24~",
}
class TerminalWidget(tk.Frame):
"""VT100 terminal emulator widget using pyte + tkinter.Text."""
def __init__(self, master, cols=80, rows=24, send_callback=None,
resize_callback=None, **kwargs):
super().__init__(master, bg=_DEFAULT_BG, **kwargs)
self.send_callback = send_callback
self.resize_callback = resize_callback
self._cols = cols
self._rows = rows
# pyte screen + stream
self._screen = pyte.Screen(cols, rows)
self._screen.set_mode(pyte.modes.LNM)
self._stream = pyte.Stream(self._screen)
# Previous buffer for diff rendering
self._prev_buffer: dict[int, list] = {}
# Font
self._font = tkfont.Font(family="Consolas", size=11)
self._bold_font = tkfont.Font(family="Consolas", size=11, weight="bold")
# Text widget
self._text = tk.Text(
self,
bg=_DEFAULT_BG,
fg=_DEFAULT_FG,
font=self._font,
insertwidth=0,
highlightthickness=0,
borderwidth=0,
padx=4,
pady=4,
wrap="none",
cursor="xterm",
)
self._text.pack(fill="both", expand=True)
# Create color tags
self._setup_tags()
# Keyboard bindings
self._text.bind("<Key>", self._on_key)
self._text.bind("<Control-c>", self._on_ctrl_c)
self._text.bind("<Control-v>", self._on_ctrl_v)
self._text.bind("<Control-a>", lambda e: "break")
self._text.bind("<Control-d>", self._on_ctrl_d)
self._text.bind("<Control-l>", self._on_ctrl_l)
self._text.bind("<Control-z>", self._on_ctrl_z)
self._text.bind("<MouseWheel>", self._on_mousewheel)
# Resize handling
self._resize_after_id = None
self._text.bind("<Configure>", self._on_configure)
# Focus on click
self._text.bind("<Button-1>", lambda e: self._text.focus_set())
# Make text read-only (input goes through _on_key)
self._text.bind("<<Paste>>", lambda e: "break")
# Status bar
self._status_frame = tk.Frame(self, bg="#2d2d44", height=22)
self._status_frame.pack(fill="x", side="bottom")
self._status_frame.pack_propagate(False)
self._status_label = tk.Label(
self._status_frame, text="", fg="#888888", bg="#2d2d44",
font=("Consolas", 9), anchor="w", padx=6,
)
self._status_label.pack(fill="x")
def _setup_tags(self):
"""Pre-create tags for all ANSI color combinations."""
for name, color in _ANSI_COLORS.items():
self._text.tag_configure(f"fg_{name}", foreground=color)
self._text.tag_configure(f"bg_{name}", background=color)
self._text.tag_configure("bold", font=self._bold_font)
self._text.tag_configure("italic", font=tkfont.Font(family="Consolas", size=11, slant="italic"))
self._text.tag_configure("underline", underline=True)
self._text.tag_configure("cursor", foreground=_CURSOR_FG, background=_CURSOR_BG)
self._text.tag_configure("default", foreground=_DEFAULT_FG)
def set_status(self, text: str, color: str = "#888888"):
self._status_label.configure(text=text, fg=color)
def feed(self, data: bytes):
"""Feed raw bytes from SSH into pyte and re-render."""
try:
text = data.decode("utf-8", errors="replace")
except Exception:
text = data.decode("latin-1", errors="replace")
self._stream.feed(text)
self._render()
def reset(self):
"""Reset the terminal screen."""
self._screen.reset()
self._prev_buffer.clear()
self._render()
def focus_terminal(self):
self._text.focus_set()
def get_size(self) -> tuple[int, int]:
"""Return (cols, rows) based on current widget size."""
return self._cols, self._rows
def _render(self):
"""Diff-based rendering: only update changed lines."""
self._text.configure(state="normal")
screen = self._screen
cursor = screen.cursor
for row in range(screen.lines):
line = screen.buffer[row]
# Build list of (char, attrs) for the row
current = []
for col in range(screen.columns):
char_data = line[col]
current.append((char_data.data, char_data.fg, char_data.bg,
char_data.bold, char_data.italics, char_data.underscore))
prev = self._prev_buffer.get(row)
is_cursor_row = (row == cursor.y)
prev_was_cursor_row = hasattr(self, "_prev_cursor_y") and self._prev_cursor_y == row
if current == prev and not is_cursor_row and not prev_was_cursor_row:
continue
self._prev_buffer[row] = current
# Delete the line
line_start = f"{row + 1}.0"
line_end = f"{row + 1}.end"
self._text.delete(line_start, line_end)
# Ensure enough lines exist
while int(self._text.index("end-1c").split(".")[0]) <= row:
self._text.insert("end", "\n")
# Build line content with batched tags
col = 0
while col < len(current):
ch, fg, bg, bold, italic, underline = current[col]
# Batch consecutive chars with same attributes
batch = [ch]
while col + len(batch) < len(current):
nc = current[col + len(batch)]
if nc[1:] == (fg, bg, bold, italic, underline):
batch.append(nc[0])
else:
break
text = "".join(batch)
tags = self._make_tags(fg, bg, bold, italic, underline)
# Apply cursor tag
if is_cursor_row:
if col <= cursor.x < col + len(batch):
# Split at cursor position
pre_len = cursor.x - col
if pre_len > 0:
self._text.insert(f"{row + 1}.{col}", text[:pre_len], tags)
cursor_tags = ("cursor",) + tags
self._text.insert(f"{row + 1}.{cursor.x}", text[pre_len:pre_len + 1], cursor_tags)
post = text[pre_len + 1:]
if post:
self._text.insert(f"{row + 1}.{cursor.x + 1}", post, tags)
col += len(batch)
continue
self._text.insert(f"{row + 1}.{col}", text, tags)
col += len(batch)
# Trim excess lines
total_lines = int(self._text.index("end-1c").split(".")[0])
if total_lines > screen.lines:
self._text.delete(f"{screen.lines + 1}.0", "end")
self._prev_cursor_y = cursor.y
self._text.configure(state="disabled")
def _make_tags(self, fg, bg, bold, italic, underline) -> tuple:
tags = []
fg_color = self._resolve_color(fg, is_fg=True)
if fg_color:
tag = f"fg_{fg_color}" if fg_color in _ANSI_COLORS else None
if tag and tag.replace("fg_", "") in _ANSI_COLORS:
tags.append(tag)
bg_color = self._resolve_color(bg, is_fg=False)
if bg_color:
tag = f"bg_{bg_color}" if bg_color in _ANSI_COLORS else None
if tag and tag.replace("bg_", "") in _ANSI_COLORS:
tags.append(tag)
if bold:
tags.append("bold")
if italic:
tags.append("italic")
if underline:
tags.append("underline")
return tuple(tags)
def _resolve_color(self, color, is_fg=True):
if not color or color == "default":
return None
if color in _ANSI_COLORS:
return color
# pyte may return color names like "red", "green" etc directly
return None
def _on_key(self, event):
"""Handle keyboard input and send to SSH."""
# Ignore modifier-only keys
if event.keysym in ("Shift_L", "Shift_R", "Control_L", "Control_R",
"Alt_L", "Alt_R", "Meta_L", "Meta_R",
"Caps_Lock", "Num_Lock"):
return "break"
# Ctrl+key combinations
if event.state & 0x4: # Control
if event.keysym.lower() in ("c", "v", "d", "l", "z"):
return # handled by specific bindings
ch = event.keysym.lower()
if len(ch) == 1 and "a" <= ch <= "z":
self._send(bytes([ord(ch) - ord("a") + 1]))
return "break"
# Special keys
if event.keysym in _KEY_MAP:
self._send(_KEY_MAP[event.keysym].encode())
return "break"
# Tab
if event.keysym == "Tab":
self._send(b"\t")
return "break"
# Return / Enter
if event.keysym in ("Return", "KP_Enter"):
self._send(b"\r")
return "break"
# Backspace
if event.keysym == "BackSpace":
self._send(b"\x7f")
return "break"
# Escape
if event.keysym == "Escape":
self._send(b"\x1b")
return "break"
# Regular character
if event.char and ord(event.char) >= 32:
self._send(event.char.encode("utf-8"))
return "break"
return "break"
def _on_ctrl_c(self, event):
# Check if text is selected — if so, copy
try:
sel = self._text.get("sel.first", "sel.last")
if sel:
self.clipboard_clear()
self.clipboard_append(sel)
return "break"
except tk.TclError:
pass
# No selection — send SIGINT
self._send(b"\x03")
return "break"
def _on_ctrl_v(self, event):
try:
text = self.clipboard_get()
if text:
self._send(text.encode("utf-8"))
except tk.TclError:
pass
return "break"
def _on_ctrl_d(self, event):
self._send(b"\x04")
return "break"
def _on_ctrl_l(self, event):
self._send(b"\x0c")
return "break"
def _on_ctrl_z(self, event):
self._send(b"\x1a")
return "break"
def _on_mousewheel(self, event):
# Send scroll sequences for programs like less/man
if event.delta > 0:
self._send(b"\x1b[5~") # PageUp
else:
self._send(b"\x1b[6~") # PageDown
return "break"
def _send(self, data: bytes):
if self.send_callback:
self.send_callback(data)
def _on_configure(self, event):
"""Debounced resize handler."""
if self._resize_after_id:
self.after_cancel(self._resize_after_id)
self._resize_after_id = self.after(200, self._do_resize)
def _do_resize(self):
self._resize_after_id = None
w = self._text.winfo_width()
h = self._text.winfo_height()
if w < 20 or h < 20:
return
char_w = self._font.measure("M")
char_h = self._font.metrics("linespace")
if char_w <= 0 or char_h <= 0:
return
new_cols = max(20, (w - 8) // char_w)
new_rows = max(4, (h - 8) // char_h)
if new_cols != self._cols or new_rows != self._rows:
self._cols = new_cols
self._rows = new_rows
self._screen.resize(new_rows, new_cols)
self._prev_buffer.clear()
self._render()
if self.resize_callback:
self.resize_callback(new_cols, new_rows)

260
release.py Normal file
View File

@@ -0,0 +1,260 @@
#!/usr/bin/env python3
"""
Release script — auto-versioning and auto-changelog.
Usage:
python release.py minor # 1.5.0 → 1.6.0
python release.py patch # 1.5.0 → 1.5.1
python release.py major # 1.5.0 → 2.0.0
python release.py 2.0.0 # explicit version
python release.py minor --desc "Release desc" # custom description
"""
import argparse
import os
import re
import subprocess
import sys
from datetime import date
PROJECT_DIR = os.path.dirname(os.path.abspath(__file__))
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def read_file(path: str) -> str:
with open(path, "r", encoding="utf-8") as f:
return f.read()
def write_file(path: str, content: str) -> None:
with open(path, "w", encoding="utf-8") as f:
f.write(content)
def get_current_version() -> str:
"""Read __version__ from version.py."""
text = read_file(os.path.join(PROJECT_DIR, "version.py"))
m = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', text)
if not m:
print("ERROR: cannot parse __version__ from version.py")
sys.exit(1)
return m.group(1)
def parse_semver(ver: str) -> tuple[int, int, int]:
parts = ver.split(".")
if len(parts) != 3 or not all(p.isdigit() for p in parts):
print(f"ERROR: invalid semver '{ver}'")
sys.exit(1)
return int(parts[0]), int(parts[1]), int(parts[2])
def bump_version(current: str, bump_type: str) -> str:
major, minor, patch = parse_semver(current)
if bump_type == "major":
return f"{major + 1}.0.0"
elif bump_type == "minor":
return f"{major}.{minor + 1}.0"
elif bump_type == "patch":
return f"{major}.{minor}.{patch + 1}"
else:
# explicit version
parse_semver(bump_type) # validate
return bump_type
# ---------------------------------------------------------------------------
# Git log → changelog
# ---------------------------------------------------------------------------
def get_last_tag() -> str | None:
"""Return the most recent tag, or None."""
try:
result = subprocess.run(
["git", "describe", "--tags", "--abbrev=0"],
capture_output=True, text=True, cwd=PROJECT_DIR,
)
if result.returncode == 0:
return result.stdout.strip()
except FileNotFoundError:
pass
return None
def get_commits_since(tag: str | None) -> list[str]:
"""Return list of commit messages since *tag* (or all if tag is None)."""
if tag:
cmd = ["git", "log", f"{tag}..HEAD", "--pretty=format:%s"]
else:
cmd = ["git", "log", "--pretty=format:%s"]
try:
result = subprocess.run(cmd, capture_output=True, text=True, cwd=PROJECT_DIR)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip().splitlines()
except FileNotFoundError:
pass
return []
def categorize_commits(commits: list[str]) -> dict[str, list[str]]:
"""Group commits into Added / Fixed / Changed."""
cats: dict[str, list[str]] = {"Added": [], "Fixed": [], "Changed": []}
add_re = re.compile(r"^(add|feat|new)\b", re.IGNORECASE)
fix_re = re.compile(r"^fix\b", re.IGNORECASE)
for msg in commits:
msg = msg.strip()
if not msg:
continue
if add_re.search(msg):
cats["Added"].append(msg)
elif fix_re.search(msg):
cats["Fixed"].append(msg)
else:
cats["Changed"].append(msg)
return cats
def build_changelog_section(
new_version: str,
categories: dict[str, list[str]],
description: str | None,
) -> str:
"""Build markdown section for CHANGELOG.md."""
today = date.today().isoformat()
lines = [f"## [{new_version}] - {today}", ""]
if description:
lines.append(description)
lines.append("")
for cat in ("Added", "Fixed", "Changed"):
items = categories.get(cat, [])
if items:
lines.append(f"### {cat}")
for item in items:
lines.append(f"- {item}")
lines.append("")
# if nothing was categorized, add a placeholder
if not any(categories.values()):
lines.append("### Changed")
lines.append(f"- Bump version to {new_version}")
lines.append("")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# File updaters
# ---------------------------------------------------------------------------
def update_version_py(new_version: str) -> None:
path = os.path.join(PROJECT_DIR, "version.py")
text = read_file(path)
text = re.sub(
r'(__version__\s*=\s*["\'])[^"\']+(["\'])',
rf"\g<1>{new_version}\2",
text,
)
write_file(path, text)
print(f" version.py → {new_version}")
def update_claude_md(new_version: str) -> None:
path = os.path.join(PROJECT_DIR, "CLAUDE.md")
if not os.path.exists(path):
print(" CLAUDE.md — not found, skipping")
return
text = read_file(path)
text = re.sub(
r"(##\s*Текущая версия:\s*)\S+",
rf"\g<1>{new_version}",
text,
)
write_file(path, text)
print(f" CLAUDE.md → {new_version}")
def update_readme(old_version: str, new_version: str) -> None:
path = os.path.join(PROJECT_DIR, "README.md")
if not os.path.exists(path):
print(" README.md — not found, skipping")
return
text = read_file(path)
old_tag = f"ServerManager-v{old_version}"
new_tag = f"ServerManager-v{new_version}"
if old_tag in text:
text = text.replace(old_tag, new_tag)
write_file(path, text)
print(f" README.md → {old_tag}{new_tag}")
else:
print(f" README.md — '{old_tag}' not found, skipping")
def update_changelog(section: str) -> None:
path = os.path.join(PROJECT_DIR, "CHANGELOG.md")
if os.path.exists(path):
old = read_file(path)
# Insert after the first line ("# Changelog\n")
header_re = re.compile(r"^(#\s*Changelog\s*\n)", re.MULTILINE)
m = header_re.search(old)
if m:
insert_pos = m.end()
new_text = old[:insert_pos] + "\n" + section + "\n" + old[insert_pos:].lstrip("\n")
else:
new_text = section + "\n" + old
else:
new_text = "# Changelog\n\n" + section
write_file(path, new_text)
print(f" CHANGELOG.md → new section added")
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> None:
parser = argparse.ArgumentParser(
description="Auto-versioning and auto-changelog for ServerManager.",
)
parser.add_argument(
"version",
help="Bump type (major/minor/patch) or explicit version (e.g. 2.0.0)",
)
parser.add_argument(
"--desc",
default=None,
help="Custom description for the changelog section",
)
args = parser.parse_args()
old_version = get_current_version()
bump_type = args.version.lower()
if bump_type in ("major", "minor", "patch"):
new_version = bump_version(old_version, bump_type)
else:
new_version = bump_version(old_version, args.version)
print(f"\nRelease: {old_version}{new_version}\n")
# Collect git log
last_tag = get_last_tag()
commits = get_commits_since(last_tag)
categories = categorize_commits(commits)
section = build_changelog_section(new_version, categories, args.desc)
# Update files
print("Updating files:")
update_version_py(new_version)
update_claude_md(new_version)
update_readme(old_version, new_version)
update_changelog(section)
# Summary
print(f"\nDone! Version is now {new_version}.")
print("Next steps:")
print(" 1. Review changes: git diff")
print(" 2. Build: python build.py --clean")
print(" 3. Commit & push")
if __name__ == "__main__":
main()

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -3,3 +3,5 @@ paramiko>=3.4.0
pillow>=10.0.0
cryptography>=41.0.0
pyotp>=2.9.0
pyte>=0.8.1
psutil>=5.9.0

BIN
screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

BIN
screenshot2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

BIN
screenshot3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

BIN
screenshot4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

BIN
screenshot5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

BIN
screenshot6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

BIN
screenshot7.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

BIN
screenshot_2fa.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

BIN
ss_2fa_new.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

BIN
ss_final.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

BIN
ss_newui.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

View File

@@ -1,6 +1,6 @@
"""Version info for ServerManager."""
__version__ = "1.3.0"
__version__ = "1.5.0"
__app_name__ = "ServerManager"
__author__ = "aibot777"
__description__ = "Desktop GUI for managing remote servers"