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

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()