feat: 审查修复一轮——sync 解引用软链技能/防 CLAUDE.md 崩溃/init 建 agent-tasks;upgrade SHA256 校验+收尾容错;新增 xiaohe switch 回滚;uninstall --all 扩到 ~/.metabot ~/.mytoolkit;下载路径改 xiaohe-agent

This commit is contained in:
Zhengshou Lai
2026-07-16 19:36:18 +08:00
parent f878a49c8a
commit 84ad0fa4cf
9 changed files with 432 additions and 17 deletions
+84
View File
@@ -0,0 +1,84 @@
"""``xiaohe switch`` — move ``current`` to another installed runtime version.
Rollback/roll-forward counterpart to ``xiaohe upgrade``: repoints the
``current`` symlink, reinstalls the CLI tools from that runtime, and re-syncs
the workspace. Run without an argument to list installed versions and pick
one interactively.
"""
import click
from rich.console import Console
from myagents.commands.sync_workspace import _print_report, sync_workspace
from myagents.commands.upgrade import _current_version, _install_tools, _runtime_root
from myagents.project_root import get_workspace_root
stderr_console = Console(stderr=True)
console = Console()
def _installed_versions() -> list[str]:
root = _runtime_root()
if not root.is_dir():
return []
return sorted(
d.name for d in root.iterdir() if d.is_dir() and not d.is_symlink()
)
@click.command("switch")
@click.argument("version", required=False)
@click.option("--yes", is_flag=True, help="Skip the confirmation prompt")
def switch_cmd(version: str | None, yes: bool) -> None:
"""Switch the active runtime to another installed version (rollback)."""
versions = _installed_versions()
current = _current_version()
if not versions:
raise click.ClickException(
"No installed runtime versions found (~/.xiaohe/runtime)."
)
if not version:
console.print("[bold]Installed versions:[/bold]")
for name in versions:
marker = " [green](current)[/green]" if name == current else ""
console.print(f" {name}{marker}")
version = str(click.prompt("Switch to", err=True))
if version not in versions:
raise click.ClickException(
f"Version {version!r} is not installed. Installed: {', '.join(versions)}"
)
if version == current:
console.print(f"Already on {version}.")
return
if not yes and not click.confirm(
f"Switch runtime {current} -> {version} and reinstall tools?",
default=True,
err=True,
):
console.print("Cancelled.")
return
target = _runtime_root() / version
current_link = _runtime_root() / "current"
current_link.unlink(missing_ok=True)
current_link.symlink_to(target)
warnings = _install_tools(target)
workspace = get_workspace_root(create=False)
if workspace.is_dir():
try:
report = sync_workspace(workspace)
except click.ClickException as exc:
stderr_console.print(f"[yellow]workspace sync skipped: {exc.message}[/yellow]")
else:
_print_report(workspace, report)
for warning in warnings:
stderr_console.print(f"[yellow]warning: {warning}[/yellow]")
console.print(
f"\n[bold green]Switched: {current} -> {version}[/bold green] "
"(open a new terminal to pick up the change)"
)
+35 -5
View File
@@ -86,15 +86,35 @@ def _hash_file(path: Path) -> str:
def _enumerate_managed(runtime: Path) -> dict[str, str]: def _enumerate_managed(runtime: Path) -> dict[str, str]:
"""rel-path -> sha256 for every managed file in the runtime tree.""" """rel-path -> sha256 for every managed file in the runtime tree.
Symlinked dirs (e.g. .agents/skills/mytoolkit -> contrib/...) are followed
and their content is hashed through the link, so the workspace receives
real self-contained files. Directory inodes are tracked to break cycles.
"""
files: dict[str, str] = {} files: dict[str, str] = {}
for rel in MANAGED_PATHS: for rel in MANAGED_PATHS:
src = runtime / rel src = runtime / rel
if src.is_file(): if src.is_file():
files[rel] = _hash_file(src) files[rel] = _hash_file(src)
elif src.is_dir(): continue
for path in sorted(src.rglob("*")): if not src.is_dir():
if path.is_file() and not path.is_symlink(): continue
seen: set[tuple[int, int]] = set()
for dirpath, dirnames, filenames in os.walk(src, followlinks=True):
try:
real = Path(dirpath).resolve().stat()
except OSError:
dirnames[:] = []
continue
key = (real.st_dev, real.st_ino)
if key in seen:
dirnames[:] = []
continue
seen.add(key)
for name in sorted(filenames):
path = Path(dirpath) / name
if path.is_file():
files[str(path.relative_to(runtime))] = _hash_file(path) files[str(path.relative_to(runtime))] = _hash_file(path)
return files return files
@@ -175,7 +195,10 @@ def sync_workspace(workspace: Path, force: bool = False) -> dict:
for link, target_rel in MANAGED_SYMLINKS.items(): for link, target_rel in MANAGED_SYMLINKS.items():
link_path = workspace / link link_path = workspace / link
if not link_path.is_symlink() and (workspace / target_rel).exists(): # Never clobber: an existing entry (real file, dir, or link) stays put.
if link_path.is_symlink() or link_path.exists():
continue
if (workspace / target_rel).exists():
link_path.symlink_to(target_rel) link_path.symlink_to(target_rel)
_write_stamp(workspace, _runtime_version(runtime), new_stamp) _write_stamp(workspace, _runtime_version(runtime), new_stamp)
@@ -328,6 +351,13 @@ def init_cmd(path: str | None, force: bool) -> None:
"skills/docs will sync after reinstall.[/yellow]" "skills/docs will sync after reinstall.[/yellow]"
) )
# Worklog task board: instantiate the live file from the synced template.
template = workspace / "assistant" / "agent-tasks.template.md"
live = workspace / "assistant" / "agent-tasks.md"
if template.is_file() and not live.exists():
shutil.copy2(template, live)
console.print(" [green]created:[/green] assistant/agent-tasks.md (from template)")
_create_launcher() _create_launcher()
console.print("\n[bold green]Done.[/bold green] Daily use: [cyan]xiaohe[/cyan]") console.print("\n[bold green]Done.[/bold green] Daily use: [cyan]xiaohe[/cyan]")
+11 -5
View File
@@ -2,8 +2,9 @@
Default: removes the entry points (xiaohe/myclaude/.../mytoolkit), metabot Default: removes the entry points (xiaohe/myclaude/.../mytoolkit), metabot
CLI, shell completions, and the desktop launcher. Keeps ~/.xiaohe (runtime, CLI, shell completions, and the desktop launcher. Keeps ~/.xiaohe (runtime,
settings, keys) and the workspace. ``--all`` also deletes ~/.xiaohe; the settings, keys), ~/.metabot, ~/.mytoolkit and the workspace. ``--all`` also
workspace is never touched. deletes ~/.xiaohe, ~/.metabot and ~/.mytoolkit (every saved credential);
the workspace is never touched.
""" """
import os import os
@@ -89,9 +90,12 @@ def uninstall_cmd(remove_all: bool, yes: bool) -> None:
console.print(" - pip packages: mytoolkit, myagents") console.print(" - pip packages: mytoolkit, myagents")
if remove_all: if remove_all:
console.print(" - [red]~/.xiaohe (runtime, settings.json, config.json keys)[/red]") console.print(" - [red]~/.xiaohe (runtime, settings.json, config.json keys)[/red]")
console.print(" - [red]~/.metabot (Feishu bot config)[/red]")
console.print(" - [red]~/.mytoolkit (mytoolkit config, incl. keys)[/red]")
console.print("[bold]Will keep:[/bold]") console.print("[bold]Will keep:[/bold]")
if not remove_all: if not remove_all:
console.print(" - ~/.xiaohe (runtime, settings, keys)") console.print(" - ~/.xiaohe (runtime, settings, keys)")
console.print(" - ~/.metabot and ~/.mytoolkit (bot/tool configs)")
console.print(f" - workspace: {get_workspace_root(create=False)}") console.print(f" - workspace: {get_workspace_root(create=False)}")
console.print(" - rc-file edits (PATH line, ANTHROPIC_* exports) and the claude CLI") console.print(" - rc-file edits (PATH line, ANTHROPIC_* exports) and the claude CLI")
@@ -99,11 +103,11 @@ def uninstall_cmd(remove_all: bool, yes: bool) -> None:
console.print("Cancelled.") console.print("Cancelled.")
return return
if remove_all: if remove_all:
# Deleting ~/.xiaohe destroys settings and every saved API key # Deleting these dirs destroys settings and every saved credential
# always require the full confirmation chain, even with --yes. # always require the full confirmation chain, even with --yes.
if not click.confirm( if not click.confirm(
"This also deletes ~/.xiaohe — runtime, settings and ALL saved API keys. " "This also deletes ~/.xiaohe, ~/.metabot and ~/.mytoolkit — runtime, "
"Continue?", "bot config and ALL saved API keys. Continue?",
default=False, default=False,
err=True, err=True,
): ):
@@ -138,6 +142,8 @@ def uninstall_cmd(remove_all: bool, yes: bool) -> None:
if remove_all: if remove_all:
_remove_path(home / ".xiaohe", removed) _remove_path(home / ".xiaohe", removed)
_remove_path(home / ".metabot", removed)
_remove_path(home / ".mytoolkit", removed)
for warning in warnings: for warning in warnings:
stderr_console.print(f"[yellow]warning: {warning}[/yellow]") stderr_console.print(f"[yellow]warning: {warning}[/yellow]")
+37 -5
View File
@@ -8,6 +8,7 @@ workspace content. Credentials come from XIAOHE_USER/XIAOHE_PASS, --user/
""" """
import base64 import base64
import hashlib
import re import re
import shutil import shutil
import subprocess import subprocess
@@ -40,7 +41,7 @@ from myagents.settings import get_setting
stderr_console = Console(stderr=True) stderr_console = Console(stderr=True)
console = Console() console = Console()
DEFAULT_BASE_URL = "http://1.14.226.205:8088/xiaohe" DEFAULT_BASE_URL = "http://1.14.226.205:8088/xiaohe-agent"
KEEP_VERSIONS = 2 KEEP_VERSIONS = 2
@@ -113,6 +114,29 @@ def _parse_version(install_sh: str) -> str | None:
return match.group(1) if match else None return match.group(1) if match else None
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as fh:
for chunk in iter(lambda: fh.read(1 << 16), b""):
digest.update(chunk)
return digest.hexdigest()
def _verify_checksum(
tarball: Path, sha256_text: str, url: str
) -> None:
"""Verify tarball against the published "<sha256> <name>" line."""
expected = sha256_text.split()[0].strip().lower() if sha256_text.split() else ""
if not expected or not all(c in "0123456789abcdef" for c in expected):
raise click.ClickException(f"Checksum file looks wrong — {url}")
actual = _sha256(tarball)
if actual != expected:
raise click.ClickException(
"Checksum mismatch — the download is corrupted or tampered with. "
"Aborting; nothing was installed."
)
def _extract(tarball: Path, dest: Path) -> None: def _extract(tarball: Path, dest: Path) -> None:
with tarfile.open(tarball) as tf: with tarfile.open(tarball) as tf:
try: try:
@@ -200,8 +224,11 @@ def upgrade_cmd(user: str | None, password: str | None, force: bool) -> None:
tmpdir = Path(tempfile.mkdtemp(prefix="xiaohe-upgrade-")) tmpdir = Path(tempfile.mkdtemp(prefix="xiaohe-upgrade-"))
try: try:
tarball = tmpdir / "xiaohe-agent.tar.gz" tarball = tmpdir / "xiaohe-agent-latest.tar.gz"
_download(f"{base}/xiaohe-agent-latest.tar.gz", user, password, tarball) _download(f"{base}/xiaohe-agent-latest.tar.gz", user, password, tarball)
with console.status("Verifying checksum ..."):
sha_url = f"{base}/xiaohe-agent-latest.tar.gz.sha256"
_verify_checksum(tarball, _fetch_text(sha_url, user, password), sha_url)
target = _runtime_root() / latest target = _runtime_root() / latest
with console.status(f"Installing to {target} ..."): with console.status(f"Installing to {target} ..."):
@@ -225,8 +252,13 @@ def upgrade_cmd(user: str | None, password: str | None, force: bool) -> None:
workspace = get_workspace_root(create=False) workspace = get_workspace_root(create=False)
if workspace.is_dir(): if workspace.is_dir():
report = sync_workspace(workspace) try:
_print_report(workspace, report) report = sync_workspace(workspace)
except click.ClickException as exc:
# e.g. dev machine: workspace is a git checkout — not a failure.
stderr_console.print(f"[yellow]workspace sync skipped: {exc.message}[/yellow]")
else:
_print_report(workspace, report)
else: else:
console.print(f"[yellow]workspace {workspace} missing — run 'xiaohe init' first.[/yellow]") console.print(f"[yellow]workspace {workspace} missing — run 'xiaohe init' first.[/yellow]")
@@ -234,5 +266,5 @@ def upgrade_cmd(user: str | None, password: str | None, force: bool) -> None:
stderr_console.print(f"[yellow]warning: {warning}[/yellow]") stderr_console.print(f"[yellow]warning: {warning}[/yellow]")
console.print( console.print(
f"\n[bold green]Upgraded: {current} -> {latest}[/bold green] " f"\n[bold green]Upgraded: {current} -> {latest}[/bold green] "
"(open a new terminal; previous runtime kept for rollback)" "(open a new terminal; roll back anytime with 'xiaohe switch')"
) )
+3 -1
View File
@@ -26,7 +26,8 @@ def default_agent() -> str:
def build_xiaohe_cli(): def build_xiaohe_cli():
"""The ``xiaohe`` command group: agent forwarding + init/sync/upgrade/uninstall/version.""" """The ``xiaohe`` command group: agent forwarding + init/sync/upgrade/switch/uninstall/version."""
from myagents.commands.switch import switch_cmd
from myagents.commands.sync_workspace import init_cmd, sync_cmd from myagents.commands.sync_workspace import init_cmd, sync_cmd
from myagents.commands.uninstall import uninstall_cmd from myagents.commands.uninstall import uninstall_cmd
from myagents.commands.upgrade import upgrade_cmd from myagents.commands.upgrade import upgrade_cmd
@@ -36,6 +37,7 @@ def build_xiaohe_cli():
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) xiaohe_cli.add_command(upgrade_cmd)
xiaohe_cli.add_command(switch_cmd)
xiaohe_cli.add_command(uninstall_cmd) xiaohe_cli.add_command(uninstall_cmd)
xiaohe_cli.add_command(version_cmd) xiaohe_cli.add_command(version_cmd)
return xiaohe_cli return xiaohe_cli
+80
View File
@@ -0,0 +1,80 @@
"""Tests for myagents.commands.switch."""
from pathlib import Path
import pytest
from click.testing import CliRunner
from myagents.commands import switch as sw_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))
monkeypatch.setattr(sw_mod, "_install_tools", lambda runtime: [])
workspace = tmp_path / "ws"
workspace.mkdir()
monkeypatch.setattr(sw_mod, "get_workspace_root", lambda create=False: workspace)
monkeypatch.setattr(
sw_mod,
"sync_workspace",
lambda ws: {"added": [], "updated": [], "skipped": [], "removed": []},
)
return home
def _make_versions(home: Path, versions: tuple[str, ...], current: str) -> None:
root = home / ".xiaohe" / "runtime"
for name in versions:
(root / name).mkdir(parents=True)
(root / "current").symlink_to(root / current)
class TestSwitch:
def test_switches_current_and_reinstalls(
self, fake_home: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_make_versions(fake_home, ("v1", "v2"), "v2")
installed: list[str] = []
monkeypatch.setattr(
sw_mod, "_install_tools", lambda rt: installed.append(rt.name) or []
)
result = CliRunner().invoke(sw_mod.switch_cmd, ["v1", "--yes"])
assert result.exit_code == 0, result.output
assert "Switched: v2 -> v1" in result.output
current = fake_home / ".xiaohe" / "runtime" / "current"
assert current.resolve().name == "v1"
assert installed == ["v1"]
def test_unknown_version_errors(self, fake_home: Path) -> None:
_make_versions(fake_home, ("v1", "v2"), "v2")
result = CliRunner().invoke(sw_mod.switch_cmd, ["v9", "--yes"])
assert result.exit_code != 0
assert "not installed" in result.output
def test_already_current_is_noop(self, fake_home: Path) -> None:
_make_versions(fake_home, ("v1", "v2"), "v2")
result = CliRunner().invoke(sw_mod.switch_cmd, ["v2", "--yes"])
assert result.exit_code == 0
assert "Already on v2" in result.output
def test_interactive_list_and_prompt(self, fake_home: Path) -> None:
_make_versions(fake_home, ("v1", "v2"), "v2")
result = CliRunner().invoke(sw_mod.switch_cmd, [], input="v1\ny\n")
assert result.exit_code == 0, result.output
assert "v2" in result.output and "(current)" in result.output
assert (fake_home / ".xiaohe" / "runtime" / "current").resolve().name == "v1"
def test_cancelled_keeps_current(self, fake_home: Path) -> None:
_make_versions(fake_home, ("v1", "v2"), "v2")
result = CliRunner().invoke(sw_mod.switch_cmd, ["v1"], input="n\n")
assert result.exit_code == 0
assert "Cancelled" in result.output
assert (fake_home / ".xiaohe" / "runtime" / "current").resolve().name == "v2"
def test_no_versions_errors(self, fake_home: Path) -> None:
result = CliRunner().invoke(sw_mod.switch_cmd, ["v1", "--yes"])
assert result.exit_code != 0
assert "No installed runtime" in result.output
+75
View File
@@ -86,6 +86,41 @@ class TestSyncWorkspace:
report = sync_mod.sync_workspace(workspace, force=True) report = sync_mod.sync_workspace(workspace, force=True)
assert report["added"] assert report["added"]
def test_symlinked_skill_dir_is_dereferenced(
self, runtime: Path, workspace: Path
) -> None:
# Skills living in contrib/ are linked into .agents/skills (relative
# symlink); the workspace must receive real self-contained files.
real = runtime / "contrib" / "mytoolkit" / ".claude" / "skills" / "mytoolkit"
real.mkdir(parents=True)
(real / "SKILL.md").write_text("toolkit skill v1")
import os
rel_target = os.path.relpath(real, runtime / ".agents" / "skills")
(runtime / ".agents" / "skills" / "mytoolkit").symlink_to(
rel_target, target_is_directory=True
)
report = sync_mod.sync_workspace(workspace)
copied = workspace / ".agents" / "skills" / "mytoolkit" / "SKILL.md"
assert copied.is_file() and not copied.is_symlink()
assert copied.read_text() == "toolkit skill v1"
assert ".agents/skills/mytoolkit/SKILL.md" in report["added"]
# Second run: content identical -> no spurious updates.
assert sync_mod.sync_workspace(workspace) == {
"added": [], "updated": [], "skipped": [], "removed": []
}
def test_existing_real_managed_symlink_target_is_kept(
self, runtime: Path, workspace: Path
) -> None:
# A pre-existing real CLAUDE.md must not be replaced by a symlink.
(workspace / "CLAUDE.md").write_text("my own claude rules")
sync_mod.sync_workspace(workspace)
claude_md = workspace / "CLAUDE.md"
assert not claude_md.is_symlink()
assert claude_md.read_text() == "my own claude rules"
assert (workspace / ".claude").is_symlink() # other links still made
class TestCreateLauncher: class TestCreateLauncher:
@pytest.fixture() @pytest.fixture()
@@ -133,3 +168,43 @@ class TestCreateLauncher:
text = entry.read_text() text = entry.read_text()
assert "Name=Xiaohe Agent" in text assert "Name=Xiaohe Agent" in text
assert "Icon=xiaohe-agent" in text assert "Icon=xiaohe-agent" in text
class TestInitCommand:
@pytest.fixture()
def fake_home(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
home = tmp_path / "home"
home.mkdir()
monkeypatch.setattr(Path, "home", staticmethod(lambda: home))
monkeypatch.setattr(sync_mod, "get_setting", lambda *a, **kw: "")
monkeypatch.setattr(sync_mod, "set_setting", lambda *a, **kw: None)
return home
def test_agent_tasks_created_from_template(
self, runtime: Path, workspace: Path, fake_home: Path
) -> None:
from click.testing import CliRunner
template = runtime / "assistant" / "agent-tasks.template.md"
template.parent.mkdir(parents=True)
template.write_text("# task board template")
result = CliRunner().invoke(sync_mod.init_cmd, [str(workspace)])
assert result.exit_code == 0, result.output
live = workspace / "assistant" / "agent-tasks.md"
assert live.read_text() == "# task board template"
assert (workspace / "tmp").is_dir() # skeleton dirs created
def test_existing_agent_tasks_untouched(
self, runtime: Path, workspace: Path, fake_home: Path
) -> None:
from click.testing import CliRunner
template = runtime / "assistant" / "agent-tasks.template.md"
template.parent.mkdir(parents=True)
template.write_text("# task board template")
live = workspace / "assistant" / "agent-tasks.md"
live.parent.mkdir(parents=True)
live.write_text("# my live board")
result = CliRunner().invoke(sync_mod.init_cmd, [str(workspace)])
assert result.exit_code == 0, result.output
assert live.read_text() == "# my live board"
+9 -1
View File
@@ -30,6 +30,10 @@ def _populate(home: Path) -> None:
(local_bin / "completions" / "_metabot").write_text("comp") (local_bin / "completions" / "_metabot").write_text("comp")
(home / ".xiaohe" / "agent").mkdir(parents=True) (home / ".xiaohe" / "agent").mkdir(parents=True)
(home / ".xiaohe" / "agent" / "config.json").write_text("{}") (home / ".xiaohe" / "agent" / "config.json").write_text("{}")
(home / ".metabot").mkdir()
(home / ".metabot" / "bots.json").write_text("{}")
(home / ".mytoolkit").mkdir()
(home / ".mytoolkit" / "config.json").write_text("{}")
(home / "workspace").mkdir() (home / "workspace").mkdir()
app = home / "Desktop" / "Xiaohe Agent.app" / "Contents" app = home / "Desktop" / "Xiaohe Agent.app" / "Contents"
app.mkdir(parents=True) app.mkdir(parents=True)
@@ -49,15 +53,19 @@ class TestUninstall:
assert not (local_bin / "completions").exists() # empty dir cleaned assert not (local_bin / "completions").exists() # empty dir cleaned
assert not (fake_home / "Desktop" / "Xiaohe Agent.app").exists() assert not (fake_home / "Desktop" / "Xiaohe Agent.app").exists()
assert (fake_home / ".xiaohe" / "agent" / "config.json").is_file() assert (fake_home / ".xiaohe" / "agent" / "config.json").is_file()
assert (fake_home / ".metabot" / "bots.json").is_file()
assert (fake_home / ".mytoolkit" / "config.json").is_file()
assert (fake_home / "workspace").is_dir() assert (fake_home / "workspace").is_dir()
def test_all_removes_xiaohe_dir(self, fake_home: Path) -> None: def test_all_removes_config_dirs(self, fake_home: Path) -> None:
_populate(fake_home) _populate(fake_home)
result = CliRunner().invoke( result = CliRunner().invoke(
un_mod.uninstall_cmd, ["--all", "--yes"], input="y\ndelete-all\n" un_mod.uninstall_cmd, ["--all", "--yes"], input="y\ndelete-all\n"
) )
assert result.exit_code == 0 assert result.exit_code == 0
assert not (fake_home / ".xiaohe").exists() assert not (fake_home / ".xiaohe").exists()
assert not (fake_home / ".metabot").exists()
assert not (fake_home / ".mytoolkit").exists()
assert (fake_home / "workspace").is_dir() # workspace never touched assert (fake_home / "workspace").is_dir() # workspace never touched
def test_all_requires_full_chain_even_with_yes(self, fake_home: Path) -> None: def test_all_requires_full_chain_even_with_yes(self, fake_home: Path) -> None:
+98
View File
@@ -80,6 +80,104 @@ class TestUpgradeCommand:
assert "Cancelled" in result.output assert "Cancelled" in result.output
def _make_tarball_bytes() -> bytes:
import tarfile
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tf:
data = b"#!/bin/sh\n"
info = tarfile.TarInfo("workspace/setup.sh")
info.size = len(data)
tf.addfile(info, io.BytesIO(data))
return buf.getvalue()
def _wire_upgrade(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
payload: bytes,
sha: str,
) -> None:
import hashlib
def fake_fetch(url: str, user: str, password: str) -> str:
if url.endswith("install.sh"):
return 'VERSION="v2.0.0"\n'
if url.endswith(".sha256"):
return f"{sha} xiaohe-agent-latest.tar.gz\n"
raise AssertionError(f"unexpected url {url}")
monkeypatch.setattr(up_mod, "_fetch_text", fake_fetch)
monkeypatch.setattr(
up_mod, "_download", lambda url, u, p, dest: dest.write_bytes(payload)
)
monkeypatch.setattr(up_mod, "_install_tools", lambda runtime: [])
workspace = tmp_path / "ws"
workspace.mkdir()
monkeypatch.setattr(up_mod, "get_workspace_root", lambda create=False: workspace)
monkeypatch.setattr(
up_mod,
"sync_workspace",
lambda ws: {"added": [], "updated": [], "skipped": [], "removed": []},
)
class TestUpgradeFlow:
def test_downloads_verifies_and_switches(
self, fake_home: Path, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
import hashlib
_make_runtime(fake_home, "v1.0.0")
payload = _make_tarball_bytes()
sha = hashlib.sha256(payload).hexdigest()
_wire_upgrade(monkeypatch, tmp_path, payload, sha)
result = CliRunner().invoke(
up_mod.upgrade_cmd, ["--user", "u", "--password", "p"], input="y\n"
)
assert result.exit_code == 0, result.output
assert "Upgraded: v1.0.0 -> v2.0.0" in result.output
current = fake_home / ".xiaohe" / "runtime" / "current"
assert current.resolve().name == "v2.0.0"
assert (current / "setup.sh").is_file()
def test_checksum_mismatch_aborts_before_install(
self, fake_home: Path, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
_make_runtime(fake_home, "v1.0.0")
payload = _make_tarball_bytes()
_wire_upgrade(monkeypatch, tmp_path, payload, "0" * 64)
result = CliRunner().invoke(
up_mod.upgrade_cmd, ["--user", "u", "--password", "p"], input="y\n"
)
assert result.exit_code != 0
assert "Checksum mismatch" in result.output
# Old runtime untouched, no new version dir.
current = fake_home / ".xiaohe" / "runtime" / "current"
assert current.resolve().name == "v1.0.0"
assert not (fake_home / ".xiaohe" / "runtime" / "v2.0.0").exists()
class TestVerifyChecksum:
def test_accepts_matching(self, tmp_path: Path) -> None:
import hashlib
blob = tmp_path / "f.tar.gz"
blob.write_bytes(b"data")
sha = hashlib.sha256(b"data").hexdigest()
up_mod._verify_checksum(blob, f"{sha} f.tar.gz\n", "http://x/")
def test_rejects_mismatch_and_garbage(self, tmp_path: Path) -> None:
import click
blob = tmp_path / "f.tar.gz"
blob.write_bytes(b"data")
with pytest.raises(click.ClickException, match="Checksum mismatch"):
up_mod._verify_checksum(blob, f"{'0' * 64} f.tar.gz\n", "http://x/")
with pytest.raises(click.ClickException, match="looks wrong"):
up_mod._verify_checksum(blob, "not-a-checksum\n", "http://x/")
class _FakeResponse: class _FakeResponse:
def __init__(self, data: bytes) -> None: def __init__(self, data: bytes) -> None:
self.headers = {"Content-Length": str(len(data))} self.headers = {"Content-Length": str(len(data))}