feat: xiaohe upgrade + 启动器更名 Xiaohe Agent + CLI 输出全英文

- upgrade: 查版本(install.sh)→确认→rich 进度条下载→落 runtime 切 current(留两版)→重装三件套→自动 sync
- launcher: 'Xiaohe Agent.app' / 'Xiaohe Agent.desktop',自动清理旧 XiaoheAgent.{command,app,desktop}
- 修复 _current_version 对缺失 current 软链误判为 'current'
- 全部提示/log 英文化;93 tests passed
This commit is contained in:
Zhengshou Lai
2026-07-16 18:55:43 +08:00
parent a50a22b992
commit 5d34b247ef
5 changed files with 409 additions and 29 deletions
+17 -7
View File
@@ -197,6 +197,9 @@ def _print_report(workspace: Path, report: dict) -> None:
console.print(f" [dim]- {rel}[/dim]") console.print(f" [dim]- {rel}[/dim]")
_LAUNCHER_NAME = "Xiaohe Agent"
_LEGACY_LAUNCHER_NAMES = ("XiaoheAgent.command", "XiaoheAgent.app", "XiaoheAgent.desktop")
_APP_INFO_PLIST = """<?xml version="1.0" encoding="UTF-8"?> _APP_INFO_PLIST = """<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
@@ -237,6 +240,15 @@ def _icon_assets() -> tuple[Path | None, Path | None]:
return (icns if icns.is_file() else None), (png if png.is_file() else None) return (icns if icns.is_file() else None), (png if png.is_file() else None)
def _remove_legacy_launchers(directory: Path) -> None:
for name in _LEGACY_LAUNCHER_NAMES:
legacy = directory / name
if legacy.is_dir():
shutil.rmtree(legacy)
elif legacy.exists():
legacy.unlink()
def _create_launcher() -> None: def _create_launcher() -> None:
"""Double-click launcher that opens a terminal running ``xiaohe``.""" """Double-click launcher that opens a terminal running ``xiaohe``."""
home = Path.home() home = Path.home()
@@ -245,11 +257,8 @@ def _create_launcher() -> None:
desktop = home / "Desktop" desktop = home / "Desktop"
if not desktop.is_dir(): if not desktop.is_dir():
return return
# Legacy .command launcher is superseded by the .app bundle. _remove_legacy_launchers(desktop)
legacy = desktop / "XiaoheAgent.command" app = desktop / f"{_LAUNCHER_NAME}.app"
if legacy.exists():
legacy.unlink()
app = desktop / "XiaoheAgent.app"
macos = app / "Contents" / "MacOS" macos = app / "Contents" / "MacOS"
resources = app / "Contents" / "Resources" resources = app / "Contents" / "Resources"
macos.mkdir(parents=True, exist_ok=True) macos.mkdir(parents=True, exist_ok=True)
@@ -264,13 +273,14 @@ def _create_launcher() -> None:
else: else:
apps = home / ".local" / "share" / "applications" apps = home / ".local" / "share" / "applications"
apps.mkdir(parents=True, exist_ok=True) apps.mkdir(parents=True, exist_ok=True)
_remove_legacy_launchers(apps)
icon_line = "Icon=utilities-terminal\n" icon_line = "Icon=utilities-terminal\n"
if png is not None: if png is not None:
icon_dir = home / ".local" / "share" / "icons" / "hicolor" / "512x512" / "apps" icon_dir = home / ".local" / "share" / "icons" / "hicolor" / "512x512" / "apps"
icon_dir.mkdir(parents=True, exist_ok=True) icon_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(png, icon_dir / "xiaohe-agent.png") shutil.copy2(png, icon_dir / "xiaohe-agent.png")
icon_line = "Icon=xiaohe-agent\n" icon_line = "Icon=xiaohe-agent\n"
(apps / "XiaoheAgent.desktop").write_text( (apps / f"{_LAUNCHER_NAME}.desktop").write_text(
"[Desktop Entry]\n" "[Desktop Entry]\n"
"Type=Application\n" "Type=Application\n"
"Name=Xiaohe Agent\n" "Name=Xiaohe Agent\n"
@@ -281,7 +291,7 @@ def _create_launcher() -> None:
"Categories=Utility;\n", "Categories=Utility;\n",
encoding="utf-8", encoding="utf-8",
) )
console.print(f" [green]launcher:[/green] {apps / 'XiaoheAgent.desktop'}") console.print(f" [green]launcher:[/green] {apps / f'{_LAUNCHER_NAME}.desktop'}")
@click.command("init") @click.command("init")
+238
View File
@@ -0,0 +1,238 @@
"""``xiaohe upgrade`` — download the latest runtime package and switch to it.
Flow: query version (from the published install.sh) -> confirm -> download
tarball with a progress bar -> install to ~/.xiaohe/runtime/<version> and
repoint ``current`` (keep the two newest) -> reinstall CLI tools -> sync
workspace content. Credentials come from XIAOHE_USER/XIAOHE_PASS, --user/
--password, or an interactive prompt (never stored).
"""
import base64
import re
import shutil
import subprocess
import sys
import tarfile
import tempfile
import urllib.error
import urllib.request
from pathlib import Path
import click
from rich.console import Console
from rich.progress import (
BarColumn,
DownloadColumn,
Progress,
SpinnerColumn,
TextColumn,
TimeRemainingColumn,
TransferSpeedColumn,
)
from myagents.commands.sync_workspace import (
_print_report,
sync_workspace,
)
from myagents.project_root import get_workspace_root
from myagents.settings import get_setting
stderr_console = Console(stderr=True)
console = Console()
DEFAULT_BASE_URL = "http://1.14.226.205:8088/xiaohe"
KEEP_VERSIONS = 2
def _base_url() -> str:
return str(get_setting("vps_baseurl", "") or DEFAULT_BASE_URL).rstrip("/")
def _runtime_root() -> Path:
return Path.home() / ".xiaohe" / "runtime"
def _current_version() -> str:
current = _runtime_root() / "current"
if not current.is_symlink():
return ""
try:
return current.resolve(strict=True).name
except OSError:
return ""
def _auth_header(user: str, password: str) -> dict[str, str]:
token = base64.b64encode(f"{user}:{password}".encode()).decode()
return {"Authorization": f"Basic {token}"}
def _map_url_error(exc: Exception, url: str) -> click.ClickException:
if isinstance(exc, urllib.error.HTTPError) and exc.code in (401, 403):
return click.ClickException("Wrong account or password (server returned 401).")
if isinstance(exc, urllib.error.HTTPError):
return click.ClickException(f"Download failed: HTTP {exc.code}{url}")
reason = getattr(exc, "reason", exc)
return click.ClickException(f"Cannot reach download server: {reason}{url}")
def _fetch_text(url: str, user: str, password: str) -> str:
req = urllib.request.Request(url, headers=_auth_header(user, password))
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return resp.read().decode("utf-8", errors="replace")
except (urllib.error.URLError, OSError) as exc:
raise _map_url_error(exc, url) from exc
def _download(url: str, user: str, password: str, dest: Path) -> None:
req = urllib.request.Request(url, headers=_auth_header(user, password))
try:
with urllib.request.urlopen(req, timeout=60) as resp:
total = int(resp.headers.get("Content-Length") or 0) or None
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
DownloadColumn(),
TransferSpeedColumn(),
TimeRemainingColumn(),
console=console,
) as progress:
task = progress.add_task("Downloading runtime", total=total)
with dest.open("wb") as fh:
while chunk := resp.read(1 << 16):
fh.write(chunk)
progress.update(task, advance=len(chunk))
except (urllib.error.URLError, OSError) as exc:
raise _map_url_error(exc, url) from exc
def _parse_version(install_sh: str) -> str | None:
match = re.search(r'^VERSION="([^"]+)"', install_sh, re.MULTILINE)
return match.group(1) if match else None
def _extract(tarball: Path, dest: Path) -> None:
with tarfile.open(tarball) as tf:
try:
tf.extractall(dest, filter="data")
except TypeError: # Python < 3.11.4 lacks the filter argument
tf.extractall(dest)
def _pip_install(pkg_dir: Path) -> subprocess.CompletedProcess:
import os
if os.environ.get("VIRTUAL_ENV") and shutil.which("uv"):
cmd = ["uv", "pip", "install", "-e", str(pkg_dir), "--quiet"]
else:
cmd = [sys.executable, "-m", "pip", "install", "-e", str(pkg_dir), "--quiet"]
return subprocess.run(cmd, capture_output=True, text=True)
def _install_tools(runtime: Path) -> list[str]:
"""Reinstall pip/npm tools from the new runtime. Returns warnings."""
warnings: list[str] = []
for pkg in ("mytoolkit", "myagents"):
with console.status(f"Installing {pkg} ..."):
result = _pip_install(runtime / "contrib" / pkg)
if result.returncode != 0:
tail = (result.stderr or "").strip().splitlines()[-1:]
warnings.append(f"{pkg} install failed: {tail[0] if tail else 'unknown error'}")
metabot = runtime / "contrib" / "metabot"
if shutil.which("npm") and (metabot / "package.json").is_file():
steps = (["npm", "install", "--silent"], ["npm", "run", "build", "--silent"],
["npm", "run", "update-cli", "--silent"])
for cmd in steps:
with console.status(f"metabot: {' '.join(cmd[:2])} ..."):
result = subprocess.run(cmd, cwd=metabot, capture_output=True, text=True)
if result.returncode != 0:
warnings.append(f"metabot `{' '.join(cmd[:2])}` failed — rerun manually later")
break
elif not shutil.which("npm"):
warnings.append("npm not found — skipping metabot (xiaohe itself is unaffected)")
return warnings
def _prune() -> None:
versions = sorted(
(d for d in _runtime_root().iterdir() if d.is_dir() and not d.is_symlink()),
key=lambda d: d.stat().st_mtime,
reverse=True,
)
for old in versions[KEEP_VERSIONS:]:
shutil.rmtree(old)
@click.command("upgrade")
@click.option("--user", envvar="XIAOHE_USER", help="Download-site account (or set XIAOHE_USER)")
@click.option("--password", envvar="XIAOHE_PASS", help="Download-site password (or set XIAOHE_PASS)")
@click.option("--force", is_flag=True, help="Reinstall even if already up to date")
def upgrade_cmd(user: str | None, password: str | None, force: bool) -> None:
"""Upgrade xiaohe: fetch the latest runtime, reinstall tools, sync workspace."""
base = _base_url()
current = _current_version()
if not current:
raise click.ClickException(
"No installed runtime found (~/.xiaohe/runtime) — run install.sh first."
)
user = str(user or click.prompt("Download account", err=True))
password = str(password or click.prompt("Password", hide_input=True, err=True))
console.print(f"[dim]Current version: {current}[/dim]")
with console.status("Checking latest version ..."):
install_sh = _fetch_text(f"{base}/install.sh", user, password)
latest = _parse_version(install_sh)
if not latest:
raise click.ClickException(
"Could not parse a version from install.sh — server content looks wrong."
)
if latest == current and not force:
console.print(f"[green]Already up to date ({latest}).[/green] Use --force to reinstall.")
return
if not force and not click.confirm(
f"New version {latest} (current: {current}). Upgrade now?", default=True, err=True
):
console.print("Cancelled.")
return
tmpdir = Path(tempfile.mkdtemp(prefix="xiaohe-upgrade-"))
try:
tarball = tmpdir / "xiaohe-agent.tar.gz"
_download(f"{base}/xiaohe-agent-latest.tar.gz", user, password, tarball)
target = _runtime_root() / latest
with console.status(f"Installing to {target} ..."):
extract_dir = tmpdir / "extract"
_extract(tarball, extract_dir)
inner = extract_dir / "workspace"
if not (inner / "setup.sh").is_file():
raise click.ClickException("Tarball looks wrong (setup.sh missing).")
if target.exists():
shutil.rmtree(target)
_runtime_root().mkdir(parents=True, exist_ok=True)
shutil.move(str(inner), str(target))
current_link = _runtime_root() / "current"
current_link.unlink(missing_ok=True)
current_link.symlink_to(target)
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
warnings = _install_tools(target)
_prune()
workspace = get_workspace_root(create=False)
if workspace.is_dir():
report = sync_workspace(workspace)
_print_report(workspace, report)
else:
console.print(f"[yellow]workspace {workspace} missing — run 'xiaohe init' first.[/yellow]")
for warning in warnings:
stderr_console.print(f"[yellow]warning: {warning}[/yellow]")
console.print(
f"\n[bold green]Upgraded: {current} -> {latest}[/bold green] "
"(open a new terminal; previous runtime kept for rollback)"
)
+3 -1
View File
@@ -26,12 +26,14 @@ def default_agent() -> str:
def build_xiaohe_cli(): def build_xiaohe_cli():
"""The ``xiaohe`` command group: agent forwarding + init/sync subcommands.""" """The ``xiaohe`` command group: agent forwarding + init/sync/upgrade."""
from myagents.commands.sync_workspace import init_cmd, sync_cmd from myagents.commands.sync_workspace import init_cmd, sync_cmd
from myagents.commands.upgrade import upgrade_cmd
xiaohe_cli = build_cli(default_agent(), prog_name="xiaohe", offer_install=True) xiaohe_cli = build_cli(default_agent(), prog_name="xiaohe", offer_install=True)
xiaohe_cli.add_command(init_cmd) xiaohe_cli.add_command(init_cmd)
xiaohe_cli.add_command(sync_cmd) xiaohe_cli.add_command(sync_cmd)
xiaohe_cli.add_command(upgrade_cmd)
return xiaohe_cli return xiaohe_cli
+11 -15
View File
@@ -100,19 +100,23 @@ class TestCreateLauncher:
) -> None: ) -> None:
(runtime / "assets" / "icon").mkdir(parents=True) (runtime / "assets" / "icon").mkdir(parents=True)
(runtime / "assets" / "icon" / "XiaoheAgent.icns").write_bytes(b"icns") (runtime / "assets" / "icon" / "XiaoheAgent.icns").write_bytes(b"icns")
legacy = fake_home / "Desktop" / "XiaoheAgent.command" legacy_cmd = fake_home / "Desktop" / "XiaoheAgent.command"
legacy.write_text("#!/bin/sh\n") legacy_cmd.write_text("#!/bin/sh\n")
legacy_app = fake_home / "Desktop" / "XiaoheAgent.app"
legacy_app.mkdir()
monkeypatch.setattr( monkeypatch.setattr(
sync_mod.os, "uname", lambda: type("U", (), {"sysname": "Darwin"}) sync_mod.os, "uname", lambda: type("U", (), {"sysname": "Darwin"})
) )
sync_mod._create_launcher() sync_mod._create_launcher()
app = fake_home / "Desktop" / "Xiaohe Agent.app" app = fake_home / "Desktop" / "Xiaohe Agent.app"
executable = app / "Contents" / "MacOS" / "XiaoheAgent" executable = app / "Contents" / "MacOS" / "XiaoheAgent"
assert (app / "Contents" / "Info.plist").is_file() plist = (app / "Contents" / "Info.plist").read_text()
assert "Xiaohe Agent" in plist
assert (app / "Contents" / "Resources" / "XiaoheAgent.icns").is_file() assert (app / "Contents" / "Resources" / "XiaoheAgent.icns").is_file()
assert executable.stat().st_mode & 0o111 assert executable.stat().st_mode & 0o111
assert "xiaohe" in executable.read_text() assert "xiaohe" in executable.read_text()
assert not legacy.exists() # superseded .command removed assert not legacy_cmd.exists() # superseded .command removed
assert not legacy_app.exists() # renamed from XiaoheAgent.app
def test_linux_desktop_entry_with_icon( def test_linux_desktop_entry_with_icon(
self, runtime: Path, fake_home: Path, monkeypatch: pytest.MonkeyPatch self, runtime: Path, fake_home: Path, monkeypatch: pytest.MonkeyPatch
@@ -126,14 +130,6 @@ class TestCreateLauncher:
entry = ( entry = (
fake_home / ".local" / "share" / "applications" / "Xiaohe Agent.desktop" fake_home / ".local" / "share" / "applications" / "Xiaohe Agent.desktop"
) )
assert "Icon=xiaohe-agent" in entry.read_text() text = entry.read_text()
assert ( assert "Name=Xiaohe Agent" in text
fake_home assert "Icon=xiaohe-agent" in text
/ ".local"
/ "share"
/ "icons"
/ "hicolor"
/ "512x512"
/ "apps"
/ "xiaohe-agent.png"
).is_file()
+134
View File
@@ -0,0 +1,134 @@
"""Tests for myagents.commands.upgrade."""
import io
from pathlib import Path
import pytest
from click.testing import CliRunner
from myagents.commands import upgrade as up_mod
@pytest.fixture()
def fake_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
home = tmp_path / "home"
home.mkdir()
monkeypatch.setattr(Path, "home", staticmethod(lambda: home))
return home
def _make_runtime(home: Path, version: str) -> Path:
target = home / ".xiaohe" / "runtime" / version
target.mkdir(parents=True)
current = target.parent / "current"
current.unlink(missing_ok=True)
current.symlink_to(target)
return target
class TestParseVersion:
def test_parses_quoted_version(self) -> None:
assert up_mod._parse_version('BASE_URL="x"\nVERSION="v1.2.3"\n') == "v1.2.3"
def test_returns_none_on_garbage(self) -> None:
assert up_mod._parse_version("no version here") is None
class TestPrune:
def test_keeps_two_newest(self, fake_home: Path) -> None:
root = fake_home / ".xiaohe" / "runtime"
for name in ("v1", "v2", "v3", "v4"):
d = root / name
d.mkdir(parents=True)
up_mod._prune()
remaining = sorted(p.name for p in root.iterdir())
assert remaining == ["v3", "v4"]
class TestUpgradeCommand:
def test_already_latest_shortcircuits(
self, fake_home: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_make_runtime(fake_home, "v1.0.0")
monkeypatch.setattr(
up_mod, "_fetch_text", lambda *a: 'VERSION="v1.0.0"\n'
)
result = CliRunner().invoke(
up_mod.upgrade_cmd, ["--user", "u", "--password", "p"]
)
assert result.exit_code == 0
assert "Already up to date" in result.output
def test_missing_runtime_errors(self, fake_home: Path) -> None:
result = CliRunner().invoke(
up_mod.upgrade_cmd, ["--user", "u", "--password", "p"]
)
assert result.exit_code != 0
assert "install.sh" in result.output
def test_cancelled_by_user(
self, fake_home: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_make_runtime(fake_home, "v1.0.0")
monkeypatch.setattr(
up_mod, "_fetch_text", lambda *a: 'VERSION="v2.0.0"\n'
)
result = CliRunner().invoke(
up_mod.upgrade_cmd, ["--user", "u", "--password", "p"], input="n\n"
)
assert result.exit_code == 0
assert "Cancelled" in result.output
class _FakeResponse:
def __init__(self, data: bytes) -> None:
self.headers = {"Content-Length": str(len(data))}
self._buf = io.BytesIO(data)
def read(self, n: int = -1) -> bytes:
return self._buf.read(n)
def __enter__(self) -> "_FakeResponse":
return self
def __exit__(self, *args: object) -> None:
return None
class TestFetch:
def test_fetch_text_sends_basic_auth(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
seen: dict[str, str] = {}
def fake_urlopen(req, timeout=0):
seen["auth"] = req.headers["Authorization"]
return _FakeResponse(b"hello")
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
assert up_mod._fetch_text("http://x/", "u", "p") == "hello"
assert seen["auth"].startswith("Basic ")
def test_401_maps_to_friendly_error(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
import urllib.error
def fake_urlopen(req, timeout=0):
raise urllib.error.HTTPError("http://x/", 401, "Unauthorized", {}, None)
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
with pytest.raises(Exception, match="401"):
up_mod._fetch_text("http://x/", "u", "p")
def test_download_writes_file(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
payload = b"x" * 100_000
monkeypatch.setattr(
"urllib.request.urlopen",
lambda req, timeout=0: _FakeResponse(payload),
)
dest = tmp_path / "out.tar.gz"
up_mod._download("http://x/f", "u", "p", dest)
assert dest.read_bytes() == payload