git proxy: add start/stop/restart daemon lifecycle for FastGitHub.

Background daemons with port/PID tracking and Parallels socat, keep FastGithub logs off the TTY, and document install in the skill.
This commit is contained in:
Zhengshou Lai
2026-08-12 21:12:14 +08:00
parent f6fa1aa93d
commit f8be17fe4f
7 changed files with 611 additions and 106 deletions
+4
View File
@@ -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 <xiaohe-agent>/packages/mytoolkit && make install` |
## 安装与更新
+46 -8
View File
@@ -4,12 +4,50 @@
```bash
mytoolkit init paper <dir> # 初始化 LaTeX 论文项目
mytoolkit init manual <dir> # 初始化 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 proxyFastGitHub 等)
配置在 `~/.mytoolkit/config.json``connections.proxy.<name>`(例:`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** 场景。
+13
View File
@@ -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
+1 -1
View File
@@ -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 |
+533 -96
View File
@@ -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://<ip>:%d https://api.github.com" % port,
f" tip: curl -I -x http://<ip>:{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 <name>)",
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 <name>)",
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()
+13
View File
@@ -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
Generated
+1 -1
View File
@@ -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'" },