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>
36
CHANGELOG.md
@@ -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
@@ -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.
|
||||
@@ -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`
|
||||
|
||||
### 使用方法
|
||||
|
||||
|
||||
2
build.py
@@ -79,6 +79,8 @@ def build():
|
||||
"--hidden-import", "customtkinter",
|
||||
"--hidden-import", "PIL",
|
||||
"--hidden-import", "pyotp",
|
||||
"--hidden-import", "pyte",
|
||||
"--hidden-import", "psutil",
|
||||
"--collect-all", "customtkinter",
|
||||
])
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
||||
63
core/i18n.py
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
client = _connect_client(self.server, self.key_path)
|
||||
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')}")
|
||||
|
||||
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 = _connect_client(self.server, self.key_path, timeout=5)
|
||||
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
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,11 +204,16 @@ class SetupTab(ctk.CTkFrame):
|
||||
self.install_all_btn.configure(state="disabled", text=t("installing_all"))
|
||||
|
||||
def _do():
|
||||
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):
|
||||
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 ─────────────────────────
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
400
gui/widgets/terminal_widget.py
Normal 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
@@ -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()
|
||||
BIN
releases/ServerManager-v1.1.0-win-x64.exe
Normal file
BIN
releases/ServerManager-v1.2.0-win-x64.exe
Normal file
BIN
releases/ServerManager-v1.4.0-win-x64.exe
Normal 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
|
After Width: | Height: | Size: 248 KiB |
BIN
screenshot2.png
Normal file
|
After Width: | Height: | Size: 257 KiB |
BIN
screenshot3.png
Normal file
|
After Width: | Height: | Size: 263 KiB |
BIN
screenshot4.png
Normal file
|
After Width: | Height: | Size: 257 KiB |
BIN
screenshot5.png
Normal file
|
After Width: | Height: | Size: 254 KiB |
BIN
screenshot6.png
Normal file
|
After Width: | Height: | Size: 257 KiB |
BIN
screenshot7.png
Normal file
|
After Width: | Height: | Size: 258 KiB |
BIN
screenshot_2fa.png
Normal file
|
After Width: | Height: | Size: 212 KiB |
BIN
ss_2fa_new.png
Normal file
|
After Width: | Height: | Size: 194 KiB |
BIN
ss_final.png
Normal file
|
After Width: | Height: | Size: 231 KiB |
BIN
ss_newui.png
Normal file
|
After Width: | Height: | Size: 219 KiB |
@@ -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"
|
||||
|
||||