diff --git a/.agents/skills/mytoolkit/SKILL.md b/.agents/skills/mytoolkit/SKILL.md index 7267432..83f372c 100644 --- a/.agents/skills/mytoolkit/SKILL.md +++ b/.agents/skills/mytoolkit/SKILL.md @@ -21,6 +21,9 @@ triggers: - bib转markdown - avi转mp4 - 更新mytoolkit + - fastgithub + - git proxy + - FastGithub --- # MyToolkit 个人 CLI 工具集 @@ -72,6 +75,7 @@ mytoolkit completion zsh # 打印 zsh 补全脚本 | LaTeX 编译 | `mytoolkit latex compile paper.tex` | | BibTeX 转 Markdown | `mytoolkit convert refs.bib -o refs.md` | | AVI 视频转 MP4 | `mytoolkit convert video.avi -o video.mp4` | +| FastGitHub / git 代理守护 | `mytoolkit git proxy start fastgithub`(安装见 `references/others.md`) | | 更新工具本身 | `cd /packages/mytoolkit && make install` | ## 安装与更新 diff --git a/.agents/skills/mytoolkit/references/others.md b/.agents/skills/mytoolkit/references/others.md index 94de5d2..bfc5ab4 100644 --- a/.agents/skills/mytoolkit/references/others.md +++ b/.agents/skills/mytoolkit/references/others.md @@ -4,12 +4,50 @@ ```bash mytoolkit init paper # 初始化 LaTeX 论文项目 -mytoolkit init manual # 初始化 LaTeX 手册项目(技术与使用文档) -mytoolkit env # 环境变量管理 -mytoolkit git # Git 辅助工具 -mytoolkit ssh # SSH 快捷连接 -mytoolkit server # 开发服务器管理 -mytoolkit update # 从本地仓库更新 -mytoolkit uninstall # 卸载 -mytoolkit completion # 安装/查看/卸载 shell 补全(install / uninstall / doctor / zsh / bash) +mytoolkit env # 环境变量管理 +mytoolkit git # Git 辅助(含 proxy 守护进程) +mytoolkit ssh # SSH 快捷连接 +mytoolkit server # 开发服务器管理(webpage,非 git proxy) +mytoolkit update # 从本地仓库更新 +mytoolkit uninstall # 卸载 +mytoolkit completion # 安装/查看/卸载 shell 补全(install / uninstall / doctor / zsh / bash) ``` + +## git proxy(FastGitHub 等) + +配置在 `~/.mytoolkit/config.json` → `connections.proxy.`(例:`fastgithub` → `http://127.0.0.1:38457`)。 + +```bash +mytoolkit git proxy list +mytoolkit git proxy status # 全部守护进程 + git config +mytoolkit git proxy start fastgithub # 后台起进程 + Parallels socat(默认不改 git config) +mytoolkit git proxy stop # 停「当前」 +mytoolkit git proxy stop fastgithub +mytoolkit git proxy restart # 重启「当前」 +mytoolkit git proxy set fastgithub # 仅写 git http(s).proxy +mytoolkit git proxy unset +mytoolkit git proxy run fastgithub # 前台(设 git + Ctrl-C 清理);CI 主机请用 start +``` + +- `start` / `stop` / `restart`:端口 + PID(`lsof`),状态文件 `~/.mytoolkit/run/proxy-current.json` +- CI:主机 `start fastgithub`;job 用 `HEYUE_CI_GIT_PROXY=1` / `XIAOHE_CI_GIT_PROXY=1`(勿依赖全局 git proxy) +- Parallels guest 需 `brew install socat`;可选 `XIAOHE_CI_PARALLELS_HOST_IP` + +### 新机器安装 FastGitHub + +上游(自用 fork):https://github.com/creazyboyone/FastGithub +(原 `dotnetcore/fastgithub` 已不可用。) + +1. 从 [Releases](https://github.com/creazyboyone/FastGithub/releases) 下载 macOS 包,解压到固定目录(例 `~/Documents/myBin/fastgithub-osx/`)。 +2. 把二进制放到 PATH,且命令名与 config 里的 proxy 名一致: + ```bash + ln -sf ~/Documents/myBin/fastgithub-osx/fastgithub /opt/homebrew/bin/fastgithub + ``` +3. 按官方说明信任 `cacert/fastgithub.cer`(macOS 钥匙串);git 走代理时通常还需 `sslVerify=false`(CI 已在 `GIT_CONFIG_PARAMETERS` 里处理)。 +4. 写入 config(若尚无): + ```bash + mytoolkit env set connections.proxy.fastgithub http://127.0.0.1:38457 + ``` +5. 启动:`mytoolkit git proxy start fastgithub` → `mytoolkit git proxy status` + +Windows/Linux 也可用官方 `fastgithub start`(系统服务);本 CLI 的 `start` 面向 **Mac 宿主 + Parallels socat** 场景。 diff --git a/Makefile b/Makefile index 6b7fa7c..af23ea8 100644 --- a/Makefile +++ b/Makefile @@ -44,6 +44,19 @@ sync-skill: rm -rf "$(HOME)/.agents/skills/mytoolkit"; \ cp -R "$(ROOT_DIR)/.agents/skills/mytoolkit" "$(HOME)/.agents/skills/mytoolkit"; \ fi + @# Cursor / Claude Code often resolve skills under ~/.claude/skills + @if [[ -L "$(HOME)/.claude/skills/mytoolkit" ]]; then \ + echo " ~/.claude/skills/mytoolkit is symlink — skip copy"; \ + elif [[ -d "$(HOME)/.claude/skills" ]] || [[ -d "$(HOME)/.claude" ]]; then \ + echo "Mirroring skill to ~/.claude/skills/mytoolkit/ ..."; \ + mkdir -p "$(HOME)/.claude/skills"; \ + if command -v rsync >/dev/null 2>&1; then \ + rsync -av --delete "$(ROOT_DIR)/.agents/skills/mytoolkit/" "$(HOME)/.claude/skills/mytoolkit/"; \ + else \ + rm -rf "$(HOME)/.claude/skills/mytoolkit"; \ + cp -R "$(ROOT_DIR)/.agents/skills/mytoolkit" "$(HOME)/.claude/skills/mytoolkit"; \ + fi; \ + fi uninstall: @mytoolkit uninstall || $(PYTHON) -m pip uninstall mytoolkit -y diff --git a/README.md b/README.md index db8da98..ef0b630 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ mytoolkit --help | `templates` | Inspect template root; external override still possible | | `env` | Manage stored vars (api keys, paths, feishu app credentials) | | `ssh` | `list` / `connect` / `tunnel` / `cmd` | -| `git` | `proxy` (set/unset/list/status) | +| `git` | `proxy` (start/stop/restart/status/set/unset/list/run) | | `server` | Manage dev/serve servers | | `update` | Reinstall from local repo | | `uninstall` | Uninstall | diff --git a/mytoolkit/commands/git.py b/mytoolkit/commands/git.py index 63ba7af..026580b 100644 --- a/mytoolkit/commands/git.py +++ b/mytoolkit/commands/git.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import os import re import shutil @@ -9,11 +10,13 @@ import signal import subprocess import sys import time +from pathlib import Path +from typing import Any from urllib.parse import urlparse import click -from mytoolkit.config import config +from mytoolkit.config import CONFIG_PATH, config # Parallels Desktop Shared / Host-Only nets on macOS: host is typically *.*.*.2 _PARALLELS_NET_RE = re.compile(r"inet (10\.(?:211\.55|37\.129)\.\d+)\b") @@ -59,6 +62,15 @@ def _unset_git_proxy(key: str) -> None: ) +def _set_git_proxies(proxy_url: str) -> None: + subprocess.run( + ["git", "config", "--global", "http.proxy", proxy_url], check=True + ) + subprocess.run( + ["git", "config", "--global", "https.proxy", proxy_url], check=True + ) + + def _proxy_loopback_port(proxy_url: str) -> int | None: """Return listen port if proxy_url is a loopback HTTP proxy.""" try: @@ -97,40 +109,72 @@ def _parallels_host_ips() -> list[str]: def _port_listening_on(host: str, port: int) -> bool: + return bool(_pids_listening_on(host, port)) + + +def _pids_listening_on(host: str, port: int) -> list[int]: + """PIDs with a TCP LISTEN on host:port (via lsof).""" try: out = subprocess.check_output( - ["lsof", f"-iTCP@{host}:{port}", "-sTCP:LISTEN", "-n", "-P"], + ["lsof", f"-iTCP@{host}:{port}", "-sTCP:LISTEN", "-n", "-P", "-t"], text=True, stderr=subprocess.DEVNULL, ) - return bool(out.strip()) except (OSError, subprocess.CalledProcessError): - return False + return [] + pids: list[int] = [] + for line in out.splitlines(): + line = line.strip() + if not line: + continue + try: + pid = int(line) + except ValueError: + continue + if pid not in pids: + pids.append(pid) + return pids + + +def _fmt_listen_row( + addr: str, ok: bool, note: str = "", *, indent: int = 2 +) -> str: + mark = "OK" if ok else "DOWN" + note_s = f" {note}" if note else "" + return f"{' ' * indent}{addr:<22} {mark:<4}{note_s}" def _report_proxy_listen(port: int, ips: list[str] | None = None) -> None: """Print loopback + Parallels listen state and guest proxy URLs.""" loop_ok = _port_listening_on("127.0.0.1", port) - click.secho("\nProxy listen check:", fg="cyan") + click.echo("") + click.secho("Proxy listen:", fg="cyan") click.echo( - f" 127.0.0.1:{port} {'OK' if loop_ok else 'DOWN'} (Mac / git http.proxy)" + _fmt_listen_row(f"127.0.0.1:{port}", loop_ok, "(Mac / git http.proxy)") ) if ips is None: ips = _parallels_host_ips() if not ips: click.secho( - " (no Parallels Shared/Host-Only IPs auto-detected — " - "guest VMs cannot use this proxy via host IP; " - "override with XIAOHE_CI_PARALLELS_HOST_IP)", + " (no Parallels Shared/Host-Only IPs — " + "set XIAOHE_CI_PARALLELS_HOST_IP if a VM needs the proxy)", fg="yellow", ) return click.echo(" Parallels guest → Mac (socat):") + down: list[str] = [] for ip in ips: ok = _port_listening_on(ip, port) - mark = "OK" if ok else "DOWN" - click.echo(f" {ip}:{port} {mark} → guest use http://{ip}:{port}") - down = [ip for ip in ips if not _port_listening_on(ip, port)] + if not ok: + down.append(ip) + click.echo( + _fmt_listen_row( + f"{ip}:{port}", + ok, + f"→ http://{ip}:{port}", + indent=4, + ) + ) if down: click.secho( " WARN: some Parallels forwards are DOWN — Windows/Linux VMs " @@ -140,7 +184,7 @@ def _report_proxy_listen(port: int, ips: list[str] | None = None) -> None: ) else: click.secho( - " Guest tip: curl -I -x http://:%d https://api.github.com" % port, + f" tip: curl -I -x http://:{port} https://api.github.com", fg="cyan", ) @@ -165,12 +209,18 @@ def _start_parallels_forwards(port: int) -> list[subprocess.Popen]: err=True, ) return [] - click.echo(f"socat: {socat}") - click.echo(f"Parallels host IPs (auto): {', '.join(ips)}") + click.echo("") + click.secho("Parallels forwards:", fg="cyan") + click.echo(f" socat: {socat}") + click.echo(f" host IPs: {', '.join(ips)}") procs: list[subprocess.Popen] = [] for ip in ips: + addr = f"{ip}:{port}" if _port_listening_on(ip, port): - click.secho(f"Parallels forward already up: {ip}:{port}", fg="green") + click.secho( + _fmt_listen_row(addr, True, "→ 127.0.0.1:%d (already up)" % port), + fg="green", + ) continue proc = subprocess.Popen( [ @@ -180,18 +230,22 @@ def _start_parallels_forwards(port: int) -> list[subprocess.Popen]: ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + start_new_session=True, ) # Brief wait so bind failures surface. time.sleep(0.3) if proc.poll() is not None: click.secho( - f"WARN: socat failed for {ip}:{port} (exit {proc.returncode})", + _fmt_listen_row(addr, False, f"socat exit {proc.returncode}"), fg="yellow", err=True, ) continue procs.append(proc) - click.secho(f"Parallels forward {ip}:{port} → 127.0.0.1:{port}", fg="green") + click.secho( + _fmt_listen_row(addr, True, f"→ 127.0.0.1:{port}"), + fg="green", + ) return procs @@ -207,6 +261,317 @@ def _stop_procs(procs: list[subprocess.Popen]) -> None: proc.wait() +def _kill_pids(pids: list[int], label: str) -> None: + """SIGTERM then SIGKILL listed PIDs.""" + for pid in pids: + try: + os.kill(pid, signal.SIGTERM) + click.secho(f"Sent SIGTERM to {label} (PID {pid})", fg="green") + except ProcessLookupError: + click.secho(f"Process {pid} not found", fg="yellow") + continue + for _ in range(10): + time.sleep(0.3) + try: + os.kill(pid, 0) + except ProcessLookupError: + break + else: + try: + os.kill(pid, signal.SIGKILL) + click.secho(f"Sent SIGKILL to {label} (PID {pid})", fg="red") + except ProcessLookupError: + pass + + +def _run_dir() -> Path: + path = CONFIG_PATH.parent / "run" + path.mkdir(parents=True, exist_ok=True) + return path + + +def _state_path() -> Path: + return _run_dir() / "proxy-current.json" + + +def _read_state() -> dict[str, Any] | None: + path = _state_path() + if not path.exists(): + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + return data if isinstance(data, dict) else None + + +def _write_state(data: dict[str, Any]) -> None: + path = _state_path() + path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + + +def _clear_state() -> None: + path = _state_path() + try: + path.unlink() + except FileNotFoundError: + pass + + +def _require_proxy(name: str) -> tuple[str, str]: + """Return (name, url) or exit.""" + proxies = _get_proxies() + if name not in proxies: + click.echo(f"Unknown proxy: {name}", err=True) + click.echo(f"Available: {', '.join(proxies.keys()) or '(none)'}", err=True) + sys.exit(1) + return name, proxies[name] + + +def _current_daemon_name() -> str | None: + """Name of the managed daemon, if any. + + Prefer state file when its loopback port is still listening; else infer + from config URLs whose loopback port is up. + """ + state = _read_state() + if state and isinstance(state.get("name"), str): + name = state["name"] + proxies = _get_proxies() + url = proxies.get(name) + if url: + port = _proxy_loopback_port(url) + if port is not None and _port_listening_on("127.0.0.1", port): + return name + if port is None: + return name + # Stale state — fall through + for name, url in _get_proxies().items(): + port = _proxy_loopback_port(url) + if port is not None and _port_listening_on("127.0.0.1", port): + return name + return None + + +def _wait_for_listen( + host: str, + port: int, + proc: subprocess.Popen | None = None, + timeout_s: float = 15.0, +) -> bool: + deadline = time.time() + timeout_s + while time.time() < deadline: + if _port_listening_on(host, port): + return True + if proc is not None and proc.poll() is not None: + return False + time.sleep(0.25) + return _port_listening_on(host, port) + + +def _proxy_log_path(name: str) -> Path: + return _run_dir() / f"proxy-{name}.log" + + +def _spawn_proxy_process( + name: str, + cmd: list[str], + *, + new_session: bool, +) -> tuple[subprocess.Popen, Path]: + """Start proxy binary with stdout/stderr redirected to a log file. + + Keeps FastGithub (etc.) logs off the TTY so status lines stay readable. + """ + log_file = _proxy_log_path(name) + click.secho(f"Starting: {' '.join(cmd)}", fg="cyan") + click.echo(f" log: {log_file}") + # Keep the handle open for the child lifetime (Popen does not close it). + log = open(log_file, "a", encoding="utf-8") # noqa: SIM115 + log.write(f"\n--- start {time.strftime('%Y-%m-%d %H:%M:%S')} ---\n") + log.flush() + proc = subprocess.Popen( + cmd, + stdout=log, + stderr=subprocess.STDOUT, + start_new_session=new_session, + ) + # Parent no longer writes; child owns the fd. Closing our handle is fine. + log.close() + return proc, log_file + + +def _collect_forward_pids(port: int) -> list[int]: + pids: list[int] = [] + for ip in _parallels_host_ips(): + for pid in _pids_listening_on(ip, port): + if pid not in pids: + pids.append(pid) + return pids + + +def _start_daemon(name: str, *, set_git: bool) -> None: + _, proxy_url = _require_proxy(name) + port = _proxy_loopback_port(proxy_url) + + old_http = _get_git_proxy("http.proxy") + old_https = _get_git_proxy("https.proxy") + proxy_pid: int | None = None + forward_pids: list[int] = [] + + if port is None: + click.secho( + f"{name} is not a loopback proxy — use `git proxy set {name}` " + "for git config only (no local daemon).", + fg="yellow", + ) + if set_git: + _set_git_proxies(proxy_url) + _write_state( + { + "name": name, + "url": proxy_url, + "port": None, + "proxy_pid": None, + "forward_pids": [], + "set_git": True, + "old_http": old_http, + "old_https": old_https, + } + ) + click.secho(f"Git proxy set to {name}: {proxy_url}", fg="green") + return + + if _port_listening_on("127.0.0.1", port): + click.secho( + f"{name} already listening on 127.0.0.1:{port}", fg="cyan" + ) + pids = _pids_listening_on("127.0.0.1", port) + proxy_pid = pids[0] if pids else None + else: + binary = shutil.which(name) + if not binary: + click.secho( + f"ERROR: nothing on 127.0.0.1:{port} and `{name}` not on PATH", + fg="red", + err=True, + ) + click.echo( + "Install FastGithub and ensure the binary is named/on PATH " + "as the proxy name (see skill references/others.md).", + err=True, + ) + sys.exit(1) + proc, log_file = _spawn_proxy_process( + name, [binary], new_session=True + ) + if not _wait_for_listen("127.0.0.1", port, proc=proc): + click.secho( + f"ERROR: {name} failed to listen on 127.0.0.1:{port} " + f"(see {log_file})", + fg="red", + err=True, + ) + sys.exit(1) + proxy_pid = proc.pid + click.secho( + f" listening 127.0.0.1:{port} pid={proxy_pid}", + fg="green", + ) + + forward_procs = _start_parallels_forwards(port) + forward_pids = [p.pid for p in forward_procs if p.pid] + # Include already-up forwards so stop can clean them. + for pid in _collect_forward_pids(port): + if pid not in forward_pids and pid != proxy_pid: + forward_pids.append(pid) + + if set_git: + _set_git_proxies(proxy_url) + click.secho(f"Git proxy set to {name}: {proxy_url}", fg="green") + + _write_state( + { + "name": name, + "url": proxy_url, + "port": port, + "proxy_pid": proxy_pid, + "forward_pids": forward_pids, + "set_git": set_git, + "old_http": old_http if set_git else None, + "old_https": old_https if set_git else None, + } + ) + _report_proxy_listen(port) + + +def _stop_daemon(name: str | None) -> None: + if name is None: + name = _current_daemon_name() + if name is None: + click.secho( + "No current proxy daemon (pass a name: " + "mytoolkit git proxy stop )", + fg="yellow", + err=True, + ) + sys.exit(1) + click.echo(f"Stopping current: {name}") + + _, proxy_url = _require_proxy(name) + port = _proxy_loopback_port(proxy_url) + state = _read_state() + state_matches = bool(state and state.get("name") == name) + + if port is not None: + forward_pids = _collect_forward_pids(port) + if state_matches: + for pid in state.get("forward_pids") or []: + if isinstance(pid, int) and pid not in forward_pids: + forward_pids.append(pid) + if forward_pids: + _kill_pids(forward_pids, f"{name} socat") + else: + click.secho(f"{name} socat: nothing listening on guest IPs", fg="yellow") + + proxy_pids = _pids_listening_on("127.0.0.1", port) + if proxy_pids: + _kill_pids(proxy_pids, name) + else: + click.secho( + f"{name} is not listening on 127.0.0.1:{port}", fg="yellow" + ) + else: + click.secho(f"{name}: no local loopback daemon to stop", fg="yellow") + + if state_matches and state.get("set_git"): + old_http = state.get("old_http") + old_https = state.get("old_https") + if old_http in (None, "(not set)"): + _unset_git_proxy("http.proxy") + else: + subprocess.run( + ["git", "config", "--global", "http.proxy", str(old_http)], + capture_output=True, + check=False, + ) + if old_https in (None, "(not set)"): + _unset_git_proxy("https.proxy") + else: + subprocess.run( + ["git", "config", "--global", "https.proxy", str(old_https)], + capture_output=True, + check=False, + ) + click.secho("Git proxy restored", fg="green") + + if state_matches or ( + state and state.get("name") == name + ): + _clear_state() + click.secho(f"Stopped {name}", fg="green") + + @click.group(name="git") def git_cmd() -> None: """Git utilities.""" @@ -215,7 +580,7 @@ def git_cmd() -> None: @git_cmd.group(name="proxy") def proxy_cmd() -> None: - """Manage git proxy settings.""" + """Manage git proxy settings and local proxy daemons.""" pass @@ -229,71 +594,137 @@ def _complete_proxy_names(_ctx, _param, incomplete): @proxy_cmd.command("set") @click.argument("name", shell_complete=_complete_proxy_names) def proxy_set(name: str) -> None: - """Set git proxy by name.""" - proxies = _get_proxies() - if name not in proxies: - click.echo(f"Unknown proxy: {name}", err=True) - click.echo(f"Available: {', '.join(proxies.keys())}", err=True) - sys.exit(1) - - proxy_url = proxies[name] - subprocess.run( - ["git", "config", "--global", "http.proxy", proxy_url], check=True - ) - subprocess.run( - ["git", "config", "--global", "https.proxy", proxy_url], check=True - ) + """Set git http(s).proxy by name (config only; does not start a process).""" + _, proxy_url = _require_proxy(name) + _set_git_proxies(proxy_url) click.secho(f"Git proxy set to {name}: {proxy_url}", fg="green") @proxy_cmd.command("unset") def proxy_unset() -> None: - """Unset git proxy.""" + """Unset git http(s).proxy.""" _unset_git_proxy("http.proxy") _unset_git_proxy("https.proxy") click.secho("Git proxy unset", fg="green") +@proxy_cmd.command("start") +@click.argument("name", shell_complete=_complete_proxy_names) +@click.option( + "--set-git", + is_flag=True, + help="Also set global git http(s).proxy (default: process only)", +) +def proxy_start(name: str, set_git: bool) -> None: + """Start a proxy daemon in the background (loopback + Parallels socat). + + Does not change global git config unless --set-git is passed. + CI should keep using per-job GIT_CONFIG_PARAMETERS instead. + """ + _start_daemon(name, set_git=set_git) + + +@proxy_cmd.command("stop") +@click.argument("name", required=False, shell_complete=_complete_proxy_names) +def proxy_stop(name: str | None) -> None: + """Stop a proxy daemon. Omit NAME to stop the current one.""" + _stop_daemon(name) + + +@proxy_cmd.command("restart") +@click.argument("name", required=False, shell_complete=_complete_proxy_names) +@click.option( + "--set-git", + is_flag=True, + help="Also set global git http(s).proxy after restart", +) +def proxy_restart(name: str | None, set_git: bool) -> None: + """Restart a proxy daemon. Omit NAME to restart the current one.""" + if name is None: + name = _current_daemon_name() + if name is None: + click.secho( + "No current proxy daemon (pass a name: " + "mytoolkit git proxy restart )", + fg="yellow", + err=True, + ) + sys.exit(1) + click.echo(f"Restarting current: {name}") + _stop_daemon(name) + _start_daemon(name, set_git=set_git) + + @proxy_cmd.command("status") -def proxy_status() -> None: - """Show current git proxy status.""" +@click.argument("name", required=False, shell_complete=_complete_proxy_names) +def proxy_status(name: str | None) -> None: + """Show proxy daemon + git config status. Omit NAME to list all.""" + proxies = _get_proxies() + if name is not None: + _require_proxy(name) + proxies = {name: proxies[name]} + http_proxy = _get_git_proxy("http.proxy") https_proxy = _get_git_proxy("https.proxy") - - proxies = _get_proxies() - current_name = next( - (p for p, url in proxies.items() if url == http_proxy), None + git_name = next( + (p for p, url in _get_proxies().items() if url == http_proxy), None ) + current = _current_daemon_name() + state = _read_state() - click.secho("Git proxy status:", fg="cyan") - if current_name: - click.echo(f" Active: {current_name}") + click.secho("Git proxy daemons:", fg="cyan") + if not proxies: + click.echo(" (none configured in ~/.mytoolkit/config.json)") + for proxy_name, url in proxies.items(): + port = _proxy_loopback_port(url) + markers = [] + if proxy_name == current: + markers.append("current") + if proxy_name == git_name: + markers.append("git") + mark = f" [{', '.join(markers)}]" if markers else "" + if port is None: + click.echo(f" {proxy_name:12} {url} (non-loopback){mark}") + continue + loop_ok = _port_listening_on("127.0.0.1", port) + pids = _pids_listening_on("127.0.0.1", port) + pid_s = ",".join(str(p) for p in pids) if pids else "-" + loop_s = "UP" if loop_ok else "DOWN" + guest_ips = _parallels_host_ips() + if guest_ips: + guest_ok = all(_port_listening_on(ip, port) for ip in guest_ips) + guest_s = "socat UP" if guest_ok else "socat DOWN" + else: + guest_s = "socat n/a" + color = "green" if loop_ok else "black" + click.secho( + f" {proxy_name:12} {url} loopback={loop_s} pid={pid_s} " + f"{guest_s}{mark}", + fg=color, + ) + + click.secho("\nGit config:", fg="cyan") + if git_name: + click.echo(f" Active name: {git_name}") click.echo(f" http.proxy: {http_proxy}") click.echo(f" https.proxy: {https_proxy}") + if state: + click.echo( + f" State file: {state.get('name')} " + f"(set_git={state.get('set_git')}) → {_state_path()}" + ) - port = ( - _proxy_loopback_port(http_proxy) - if http_proxy != "(not set)" - else None - ) - if port is not None: - _report_proxy_listen(port) - else: - ips = _parallels_host_ips() - if ips: - click.secho("\nParallels host IPs (auto):", fg="cyan") - for ip in ips: - click.echo(f" {ip}") - - click.secho("\nAvailable proxies:", fg="cyan") - for proxy_name, url in proxies.items(): - marker = "*" if proxy_name == current_name else " " - click.echo(f" [{marker}] {proxy_name:12} {url}") + # Detailed listen for single-name or current loopback. + focus = name or current + if focus and focus in _get_proxies(): + port = _proxy_loopback_port(_get_proxies()[focus]) + if port is not None: + _report_proxy_listen(port) @proxy_cmd.command("list") def proxy_list_cmd() -> None: - """List available git proxies.""" + """List available git proxies from config.""" proxies = _get_proxies() click.secho("Available proxies:", fg="cyan") for name, url in proxies.items(): @@ -304,31 +735,18 @@ def proxy_list_cmd() -> None: @click.argument("name", shell_complete=_complete_proxy_names) @click.argument("command", nargs=-1, required=False) def proxy_run(name: str, command: tuple[str, ...]) -> None: - """Set proxy, run a command, auto-unset on exit. + """Foreground helper: set git proxy, run process, restore on exit. - If no COMMAND is given, tries to run a program with the same name as the proxy. - If that program is not found, sets the proxy and waits for Ctrl-C. - - For loopback proxies (e.g. fastgithub on 127.0.0.1), start the proxy - process first, then socat-forward auto-detected Parallels host IPs so - Windows/Linux VMs can connect. Socat must not bind before the proxy. + Prefer ``start``/``stop`` for a background daemon (e.g. CI host). + If no COMMAND is given, tries to run a program with the same name as + the proxy. For loopback proxies, starts socat forwards for Parallels. """ - proxies = _get_proxies() - if name not in proxies: - click.echo(f"Unknown proxy: {name}", err=True) - click.echo(f"Available: {', '.join(proxies.keys())}", err=True) - sys.exit(1) + _, proxy_url = _require_proxy(name) old_http = _get_git_proxy("http.proxy") old_https = _get_git_proxy("https.proxy") - proxy_url = proxies[name] - subprocess.run( - ["git", "config", "--global", "http.proxy", proxy_url], check=True - ) - subprocess.run( - ["git", "config", "--global", "https.proxy", proxy_url], check=True - ) + _set_git_proxies(proxy_url) click.secho(f"Git proxy set to {name}: {proxy_url}", fg="green") forward_procs: list[subprocess.Popen] = [] @@ -369,28 +787,27 @@ def proxy_run(name: str, command: tuple[str, ...]) -> None: try: # Loopback proxy (fastgithub): ensure 127.0.0.1:port is up BEFORE socat. if port is not None: + log_file: Path | None = None if _port_listening_on("127.0.0.1", port): click.secho( - f"{name} already listening on 127.0.0.1:{port}", fg="cyan" + f" already listening 127.0.0.1:{port}", fg="cyan" ) elif cmd_to_run: - click.secho(f"Running: {' '.join(cmd_to_run)}", fg="cyan") - proxy_proc = subprocess.Popen(cmd_to_run) - ready = False - for _ in range(60): - if _port_listening_on("127.0.0.1", port): - ready = True - break - if proxy_proc.poll() is not None: - break - time.sleep(0.25) - if not ready: + proxy_proc, log_file = _spawn_proxy_process( + name, cmd_to_run, new_session=False + ) + if not _wait_for_listen("127.0.0.1", port, proc=proxy_proc): click.secho( - f"ERROR: {name} failed to listen on 127.0.0.1:{port}", + f"ERROR: {name} failed to listen on 127.0.0.1:{port}" + + (f" (see {log_file})" if log_file else ""), fg="red", err=True, ) sys.exit(1) + click.secho( + f" listening 127.0.0.1:{port} pid={proxy_proc.pid}", + fg="green", + ) else: click.secho( f"ERROR: nothing on 127.0.0.1:{port} and `{name}` not found", @@ -400,9 +817,26 @@ def proxy_run(name: str, command: tuple[str, ...]) -> None: sys.exit(1) forward_procs = _start_parallels_forwards(port) + _write_state( + { + "name": name, + "url": proxy_url, + "port": port, + "proxy_pid": proxy_proc.pid if proxy_proc else ( + (_pids_listening_on("127.0.0.1", port) or [None])[0] + ), + "forward_pids": [p.pid for p in forward_procs if p.pid], + "set_git": True, + "old_http": old_http, + "old_https": old_https, + } + ) _report_proxy_listen(port) + click.echo("") + if log_file is not None: + click.echo(f" proxy log: {log_file}") click.secho( - "Press Ctrl-C to stop and auto-unset proxy\n", fg="cyan" + "Press Ctrl-C to stop and auto-unset proxy", fg="cyan" ) try: if proxy_proc is not None: @@ -416,8 +850,9 @@ def proxy_run(name: str, command: tuple[str, ...]) -> None: # Non-loopback proxy: original behavior. if cmd_to_run: click.secho(f"Running: {' '.join(cmd_to_run)}", fg="cyan") + click.echo("") click.secho( - "Press Ctrl-C to stop and auto-unset proxy\n", fg="cyan" + "Press Ctrl-C to stop and auto-unset proxy", fg="cyan" ) try: proxy_proc = subprocess.Popen(cmd_to_run) @@ -426,6 +861,7 @@ def proxy_run(name: str, command: tuple[str, ...]) -> None: pass sys.exit(proxy_proc.returncode if proxy_proc is not None else 0) + click.echo("") click.secho( "Proxy active. Press Ctrl-C to stop and auto-unset proxy", fg="cyan" ) @@ -435,3 +871,4 @@ def proxy_run(name: str, command: tuple[str, ...]) -> None: pass finally: cleanup() + _clear_state() diff --git a/tests/test_git_proxy.py b/tests/test_git_proxy.py new file mode 100644 index 0000000..071908e --- /dev/null +++ b/tests/test_git_proxy.py @@ -0,0 +1,13 @@ +"""Unit tests for git proxy helpers.""" + +from mytoolkit.commands.git import _proxy_loopback_port + + +def test_proxy_loopback_port_http(): + assert _proxy_loopback_port("http://127.0.0.1:38457") == 38457 + assert _proxy_loopback_port("http://localhost:10080") == 10080 + + +def test_proxy_loopback_port_rejects_remote(): + assert _proxy_loopback_port("http://10.211.55.2:38457") is None + assert _proxy_loopback_port("socks5://127.0.0.1:1080") is None diff --git a/uv.lock b/uv.lock index 8fec139..df3c76f 100644 --- a/uv.lock +++ b/uv.lock @@ -541,7 +541,7 @@ wheels = [ [[package]] name = "mytoolkit" -version = "0.1.0" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },