diff --git a/README.md b/README.md index 2f04d887..68a083ce 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,11 @@ That drops `cc_peer.py` and its [Claude Code skill](skills/cc-peer/SKILL.md) int `~/.claude/skills/cc-peer/`, and links `~/.local/bin/cc-peer`. Nothing else is touched. Remove it with `./install.sh --uninstall [--host ...]`. +`cc-peer update` refreshes this machine from the latest GitHub release. For another machine, +`./install.sh --host ` pushes this copy over SSH — deliberately, since a target with no +route to GitHub is one of the cases this tool exists for. `cc-peer update --host` reports what +that machine has rather than trying to make it fetch. + **Remote installs push the files over the SSH connection itself**, so the target needs no internet access — which matters, since air-gapped hosts are one of the reasons this exists. It needs `python3` and your SSH access, nothing more. @@ -100,6 +105,12 @@ git log --oneline -5 | cc-peer send --host web-01 --to api-worker - # stdin cc-peer send --host web-01 --to api-worker --dry-run "x" # resolve only cc-peer list --host web-01 --json # machine-readable + +# Versions. list --host reports what that machine has installed and flags a +# mismatch, since a host left behind by a release won't say so on its own. +cc-peer update --check # is there a newer release? +cc-peer update # replace this installation +cc-peer update --host web-01 # report what's over there cc-peer send --host web-01 --ssh-opt=-p --ssh-opt=2222 --to api-worker "..." # note the '=' # Envelope. Sends carry who they're from and how to answer, both resolved from diff --git a/cc_peer.py b/cc_peer.py index 93c17aeb..962325c1 100755 --- a/cc_peer.py +++ b/cc_peer.py @@ -35,9 +35,12 @@ import socket import subprocess import sys +import urllib.error +import urllib.request from pathlib import Path __version__ = "0.3.0" +GITHUB_REPO = "abruption/cc-peer" # Claude Code refuses a same-machine message once its serialized form passes # about a million characters, so fail here rather than at the far end. @@ -481,14 +484,29 @@ def emit(as_json: bool, payload: dict, human: str) -> None: def cmd_list(args: argparse.Namespace) -> int: + remote_version = None if args.host: result = run_remote(args.host, ["list"] + (["--all"] if args.all else []), args.ssh_opt) sessions = result.get("sessions", []) + # Ask the machine what it has installed. result["version"] would only + # echo our own, since run_remote ships and runs this very file. + remote_version = remote_installed_version(args.host, args.ssh_opt) else: sessions = discover(include_unreachable=args.all) where = args.host or "this machine" - emit(args.json, {"sessions": sessions}, render_sessions(sessions, where)) + human = render_sessions(sessions, where) + if remote_version and remote_version != __version__: + human = ( + f"{where} runs cc-peer {remote_version}; this machine has {__version__}." + f"\nUpdate it with: cc-peer update --host {args.host}\n\n{human}" + ) + emit( + args.json, + {"sessions": sessions, "version": __version__, + **({"remoteVersion": remote_version} if remote_version else {})}, + human, + ) return 0 @@ -503,6 +521,116 @@ def read_message(args: argparse.Namespace) -> str: return args.message +def remote_installed_version(host: str, ssh_opts: list[str]) -> str | None: + """Version of the copy *installed* on that machine. + + Not the same thing as asking the remote command to report itself: + run_remote() ships our own source and runs that, so it would always echo + our version back. The installed file is what a session over there will + actually use, and it is what can fall behind. + """ + check_ssh_argument(host, "--host") + for opt in ssh_opts: + check_ssh_argument(opt, "--ssh-opt") + probe = 'python3 "$HOME/.claude/skills/cc-peer/cc_peer.py" --version 2>/dev/null' + try: + done = subprocess.run( + ["ssh", *ssh_opts, host, probe], + capture_output=True, text=True, timeout=30, + ) + except (OSError, subprocess.SubprocessError): + return None + out = done.stdout.strip() + return out.split()[-1] if out.startswith("cc-peer") else None + + +def parse_version(text: str) -> tuple[int, ...]: + """(1, 2, 3) from "v1.2.3". Unparseable parts sort lowest.""" + parts = text.strip().lstrip("vV").split(".") + return tuple(int(p) if p.isdigit() else 0 for p in parts[:3]) + + +def latest_release() -> tuple[str, str]: + """(tag, download URL) of the newest release on GitHub.""" + url = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest" + request = urllib.request.Request(url, headers={"Accept": "application/vnd.github+json"}) + try: + with urllib.request.urlopen(request, timeout=DETECT_TIMEOUT * 4) as response: + tag = json.load(response).get("tag_name", "") + except (urllib.error.URLError, OSError, ValueError) as exc: + raise CcPeerError( + f"could not reach GitHub to check for updates: {exc}. " + f"On a host with no route out, update it from a machine that has one: " + f"cc-peer update --host " + ) from exc + if not tag: + raise CcPeerError("GitHub returned no release tag") + return tag, f"https://raw.githubusercontent.com/{GITHUB_REPO}/{tag}/cc_peer.py" + + +def cmd_update(args: argparse.Namespace) -> int: + # Pushing from here beats asking the far side to fetch: the machines this + # tool exists for are the ones without a route to GitHub. + if args.host: + there = remote_installed_version(args.host, args.ssh_opt) + if there is None: + human = (f"{args.host} has no cc-peer installed (or it is too old to say).\n" + f"Install it with: ./install.sh --host {args.host}") + elif there == __version__: + human = f"{args.host} runs cc-peer {there} — same as this machine." + else: + human = (f"{args.host} runs cc-peer {there}; this machine has {__version__}.\n" + f"Push it with: ./install.sh --host {args.host}") + emit(args.json, + {"host": args.host, "remoteVersion": there, "current": __version__}, + human) + return 0 + + tag, url = latest_release() + latest, current = parse_version(tag), parse_version(__version__) + + if args.check: + state = "up to date" if current >= latest else f"{tag} available" + emit( + args.json, + {"current": __version__, "latest": tag, "outdated": current < latest}, + f"cc-peer {__version__} — {state}", + ) + return 0 + + if current >= latest: + emit(args.json, {"current": __version__, "latest": tag, "updated": False}, + f"cc-peer {__version__} is already current ({tag}).") + return 0 + + target = Path(__file__).resolve() + try: + with urllib.request.urlopen(url, timeout=DETECT_TIMEOUT * 4) as response: + source = response.read() + except (urllib.error.URLError, OSError) as exc: + raise CcPeerError(f"could not download {tag}: {exc}") from exc + if b"__version__" not in source: + raise CcPeerError(f"what came back from {url} does not look like cc_peer.py") + + # We are running from the file being replaced. Write beside it and rename, + # so a failed download can't leave a half-written script behind. + staged = target.with_suffix(".py.new") + try: + staged.write_bytes(source) + staged.chmod(target.stat().st_mode & 0o777) + staged.replace(target) + except OSError as exc: + staged.unlink(missing_ok=True) + raise CcPeerError(f"could not replace {target}: {exc}") from exc + + emit( + args.json, + {"current": __version__, "latest": tag, "updated": True, "path": str(target)}, + f"cc-peer {__version__} → {tag} ({target})", + ) + return 0 + + def cmd_send(args: argparse.Namespace) -> int: text = read_message(args) @@ -609,6 +737,13 @@ def add_common(sub: argparse.ArgumentParser) -> None: sending.add_argument("--dry-run", action="store_true", help="resolve the target, send nothing") sending.set_defaults(func=cmd_send) + updating = subparsers.add_parser("update", help="update this installation") + add_common(updating) + updating.add_argument( + "--check", action="store_true", help="report the available version, change nothing" + ) + updating.set_defaults(func=cmd_update) + return parser diff --git a/test_cc_peer.py b/test_cc_peer.py index 45a22146..facffe02 100644 --- a/test_cc_peer.py +++ b/test_cc_peer.py @@ -219,6 +219,50 @@ def test_body_is_not_duplicated_or_reordered(self): self.assertLess(out.index("line two"), out.index("Reply:")) +class VersionParsing(unittest.TestCase): + def test_orders_releases(self): + self.assertLess(cc_peer.parse_version("0.2.0"), cc_peer.parse_version("0.3.0")) + self.assertLess(cc_peer.parse_version("v0.9.0"), cc_peer.parse_version("v0.10.0")) + self.assertEqual(cc_peer.parse_version("v1.2.3"), cc_peer.parse_version("1.2.3")) + + def test_survives_junk_without_raising(self): + # A malformed tag must not crash an update check; sorting lowest means + # "don't offer this as newer". + for text in ("", "not-a-version", "v..", "1.x.3"): + self.assertIsInstance(cc_peer.parse_version(text), tuple) + self.assertLess(cc_peer.parse_version("junk"), cc_peer.parse_version("0.0.1")) + + def test_ignores_anything_past_patch(self): + self.assertEqual(cc_peer.parse_version("1.2.3.4"), (1, 2, 3)) + + +class RemoteInstalledVersion(unittest.TestCase): + """Why this exists at all: run_remote() ships and runs *our* source, so + asking the remote command to report itself would only echo our version. + The installed copy is the one that can fall behind.""" + + def run_with(self, stdout): + completed = mock.Mock(stdout=stdout, returncode=0) + with mock.patch.object(cc_peer.subprocess, "run", return_value=completed): + return cc_peer.remote_installed_version("web-01", []) + + def test_reads_the_reported_version(self): + self.assertEqual(self.run_with("cc-peer 0.2.0\n"), "0.2.0") + + def test_none_when_not_installed(self): + self.assertIsNone(self.run_with("")) + self.assertIsNone(self.run_with("python3: can't open file\n")) + + def test_none_when_ssh_fails(self): + with mock.patch.object(cc_peer.subprocess, "run", side_effect=OSError("boom")): + self.assertIsNone(cc_peer.remote_installed_version("web-01", [])) + + def test_still_validates_ssh_arguments(self): + # This path builds its own ssh command, so it needs the same guard. + with self.assertRaises(cc_peer.CcPeerError): + cc_peer.remote_installed_version("-oProxyCommand=whoami", []) + + class OwnSession(unittest.TestCase): def test_reads_pid_from_the_exported_socket_path(self): rows = [{"pid": 4011, "name": "worker", "reachable": True}]