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 - bib转markdown
- avi转mp4 - avi转mp4
- 更新mytoolkit - 更新mytoolkit
- fastgithub
- git proxy
- FastGithub
--- ---
# MyToolkit 个人 CLI 工具集 # MyToolkit 个人 CLI 工具集
@@ -72,6 +75,7 @@ mytoolkit completion zsh # 打印 zsh 补全脚本
| LaTeX 编译 | `mytoolkit latex compile paper.tex` | | LaTeX 编译 | `mytoolkit latex compile paper.tex` |
| BibTeX 转 Markdown | `mytoolkit convert refs.bib -o refs.md` | | BibTeX 转 Markdown | `mytoolkit convert refs.bib -o refs.md` |
| AVI 视频转 MP4 | `mytoolkit convert video.avi -o video.mp4` | | 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` | | 更新工具本身 | `cd <xiaohe-agent>/packages/mytoolkit && make install` |
## 安装与更新 ## 安装与更新
+46 -8
View File
@@ -4,12 +4,50 @@
```bash ```bash
mytoolkit init paper <dir> # 初始化 LaTeX 论文项目 mytoolkit init paper <dir> # 初始化 LaTeX 论文项目
mytoolkit init manual <dir> # 初始化 LaTeX 手册项目(技术与使用文档) mytoolkit env # 环境变量管理
mytoolkit env # 环境变量管理 mytoolkit git # Git 辅助(含 proxy 守护进程)
mytoolkit git # Git 辅助工具 mytoolkit ssh # SSH 快捷连接
mytoolkit ssh # SSH 快捷连接 mytoolkit server # 开发服务器管理(webpage,非 git proxy
mytoolkit server # 开发服务器管理 mytoolkit update # 从本地仓库更新
mytoolkit update # 从本地仓库更新 mytoolkit uninstall # 卸载
mytoolkit uninstall # 卸载 mytoolkit completion # 安装/查看/卸载 shell 补全(install / uninstall / doctor / zsh / bash
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"; \ rm -rf "$(HOME)/.agents/skills/mytoolkit"; \
cp -R "$(ROOT_DIR)/.agents/skills/mytoolkit" "$(HOME)/.agents/skills/mytoolkit"; \ cp -R "$(ROOT_DIR)/.agents/skills/mytoolkit" "$(HOME)/.agents/skills/mytoolkit"; \
fi 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: uninstall:
@mytoolkit uninstall || $(PYTHON) -m pip uninstall mytoolkit -y @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 | | `templates` | Inspect template root; external override still possible |
| `env` | Manage stored vars (api keys, paths, feishu app credentials) | | `env` | Manage stored vars (api keys, paths, feishu app credentials) |
| `ssh` | `list` / `connect` / `tunnel` / `cmd` | | `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 | | `server` | Manage dev/serve servers |
| `update` | Reinstall from local repo | | `update` | Reinstall from local repo |
| `uninstall` | Uninstall | | `uninstall` | Uninstall |
+533 -96
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import json
import os import os
import re import re
import shutil import shutil
@@ -9,11 +10,13 @@ import signal
import subprocess import subprocess
import sys import sys
import time import time
from pathlib import Path
from typing import Any
from urllib.parse import urlparse from urllib.parse import urlparse
import click 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 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") _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: def _proxy_loopback_port(proxy_url: str) -> int | None:
"""Return listen port if proxy_url is a loopback HTTP proxy.""" """Return listen port if proxy_url is a loopback HTTP proxy."""
try: try:
@@ -97,40 +109,72 @@ def _parallels_host_ips() -> list[str]:
def _port_listening_on(host: str, port: int) -> bool: 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: try:
out = subprocess.check_output( 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, text=True,
stderr=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
) )
return bool(out.strip())
except (OSError, subprocess.CalledProcessError): 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: def _report_proxy_listen(port: int, ips: list[str] | None = None) -> None:
"""Print loopback + Parallels listen state and guest proxy URLs.""" """Print loopback + Parallels listen state and guest proxy URLs."""
loop_ok = _port_listening_on("127.0.0.1", port) 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( 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: if ips is None:
ips = _parallels_host_ips() ips = _parallels_host_ips()
if not ips: if not ips:
click.secho( click.secho(
" (no Parallels Shared/Host-Only IPs auto-detected " " (no Parallels Shared/Host-Only IPs — "
"guest VMs cannot use this proxy via host IP; " "set XIAOHE_CI_PARALLELS_HOST_IP if a VM needs the proxy)",
"override with XIAOHE_CI_PARALLELS_HOST_IP)",
fg="yellow", fg="yellow",
) )
return return
click.echo(" Parallels guest → Mac (socat):") click.echo(" Parallels guest → Mac (socat):")
down: list[str] = []
for ip in ips: for ip in ips:
ok = _port_listening_on(ip, port) ok = _port_listening_on(ip, port)
mark = "OK" if ok else "DOWN" if not ok:
click.echo(f" {ip}:{port} {mark} → guest use http://{ip}:{port}") down.append(ip)
down = [ip for ip in ips if not _port_listening_on(ip, port)] click.echo(
_fmt_listen_row(
f"{ip}:{port}",
ok,
f"→ http://{ip}:{port}",
indent=4,
)
)
if down: if down:
click.secho( click.secho(
" WARN: some Parallels forwards are DOWN — Windows/Linux VMs " " 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: else:
click.secho( 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", fg="cyan",
) )
@@ -165,12 +209,18 @@ def _start_parallels_forwards(port: int) -> list[subprocess.Popen]:
err=True, err=True,
) )
return [] return []
click.echo(f"socat: {socat}") click.echo("")
click.echo(f"Parallels host IPs (auto): {', '.join(ips)}") click.secho("Parallels forwards:", fg="cyan")
click.echo(f" socat: {socat}")
click.echo(f" host IPs: {', '.join(ips)}")
procs: list[subprocess.Popen] = [] procs: list[subprocess.Popen] = []
for ip in ips: for ip in ips:
addr = f"{ip}:{port}"
if _port_listening_on(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 continue
proc = subprocess.Popen( proc = subprocess.Popen(
[ [
@@ -180,18 +230,22 @@ def _start_parallels_forwards(port: int) -> list[subprocess.Popen]:
], ],
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
start_new_session=True,
) )
# Brief wait so bind failures surface. # Brief wait so bind failures surface.
time.sleep(0.3) time.sleep(0.3)
if proc.poll() is not None: if proc.poll() is not None:
click.secho( click.secho(
f"WARN: socat failed for {ip}:{port} (exit {proc.returncode})", _fmt_listen_row(addr, False, f"socat exit {proc.returncode}"),
fg="yellow", fg="yellow",
err=True, err=True,
) )
continue continue
procs.append(proc) 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 return procs
@@ -207,6 +261,317 @@ def _stop_procs(procs: list[subprocess.Popen]) -> None:
proc.wait() 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") @click.group(name="git")
def git_cmd() -> None: def git_cmd() -> None:
"""Git utilities.""" """Git utilities."""
@@ -215,7 +580,7 @@ def git_cmd() -> None:
@git_cmd.group(name="proxy") @git_cmd.group(name="proxy")
def proxy_cmd() -> None: def proxy_cmd() -> None:
"""Manage git proxy settings.""" """Manage git proxy settings and local proxy daemons."""
pass pass
@@ -229,71 +594,137 @@ def _complete_proxy_names(_ctx, _param, incomplete):
@proxy_cmd.command("set") @proxy_cmd.command("set")
@click.argument("name", shell_complete=_complete_proxy_names) @click.argument("name", shell_complete=_complete_proxy_names)
def proxy_set(name: str) -> None: def proxy_set(name: str) -> None:
"""Set git proxy by name.""" """Set git http(s).proxy by name (config only; does not start a process)."""
proxies = _get_proxies() _, proxy_url = _require_proxy(name)
if name not in proxies: _set_git_proxies(proxy_url)
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
)
click.secho(f"Git proxy set to {name}: {proxy_url}", fg="green") click.secho(f"Git proxy set to {name}: {proxy_url}", fg="green")
@proxy_cmd.command("unset") @proxy_cmd.command("unset")
def proxy_unset() -> None: def proxy_unset() -> None:
"""Unset git proxy.""" """Unset git http(s).proxy."""
_unset_git_proxy("http.proxy") _unset_git_proxy("http.proxy")
_unset_git_proxy("https.proxy") _unset_git_proxy("https.proxy")
click.secho("Git proxy unset", fg="green") 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") @proxy_cmd.command("status")
def proxy_status() -> None: @click.argument("name", required=False, shell_complete=_complete_proxy_names)
"""Show current git proxy status.""" 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") http_proxy = _get_git_proxy("http.proxy")
https_proxy = _get_git_proxy("https.proxy") https_proxy = _get_git_proxy("https.proxy")
git_name = next(
proxies = _get_proxies() (p for p, url in _get_proxies().items() if url == http_proxy), None
current_name = next(
(p for p, url in proxies.items() if url == http_proxy), None
) )
current = _current_daemon_name()
state = _read_state()
click.secho("Git proxy status:", fg="cyan") click.secho("Git proxy daemons:", fg="cyan")
if current_name: if not proxies:
click.echo(f" Active: {current_name}") 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" http.proxy: {http_proxy}")
click.echo(f" https.proxy: {https_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 = ( # Detailed listen for single-name or current loopback.
_proxy_loopback_port(http_proxy) focus = name or current
if http_proxy != "(not set)" if focus and focus in _get_proxies():
else None port = _proxy_loopback_port(_get_proxies()[focus])
) if port is not None:
if port is not None: _report_proxy_listen(port)
_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}")
@proxy_cmd.command("list") @proxy_cmd.command("list")
def proxy_list_cmd() -> None: def proxy_list_cmd() -> None:
"""List available git proxies.""" """List available git proxies from config."""
proxies = _get_proxies() proxies = _get_proxies()
click.secho("Available proxies:", fg="cyan") click.secho("Available proxies:", fg="cyan")
for name, url in proxies.items(): 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("name", shell_complete=_complete_proxy_names)
@click.argument("command", nargs=-1, required=False) @click.argument("command", nargs=-1, required=False)
def proxy_run(name: str, command: tuple[str, ...]) -> None: 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. Prefer ``start``/``stop`` for a background daemon (e.g. CI host).
If that program is not found, sets the proxy and waits for Ctrl-C. 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.
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.
""" """
proxies = _get_proxies() _, proxy_url = _require_proxy(name)
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)
old_http = _get_git_proxy("http.proxy") old_http = _get_git_proxy("http.proxy")
old_https = _get_git_proxy("https.proxy") old_https = _get_git_proxy("https.proxy")
proxy_url = proxies[name] _set_git_proxies(proxy_url)
subprocess.run(
["git", "config", "--global", "http.proxy", proxy_url], check=True
)
subprocess.run(
["git", "config", "--global", "https.proxy", proxy_url], check=True
)
click.secho(f"Git proxy set to {name}: {proxy_url}", fg="green") click.secho(f"Git proxy set to {name}: {proxy_url}", fg="green")
forward_procs: list[subprocess.Popen] = [] forward_procs: list[subprocess.Popen] = []
@@ -369,28 +787,27 @@ def proxy_run(name: str, command: tuple[str, ...]) -> None:
try: try:
# Loopback proxy (fastgithub): ensure 127.0.0.1:port is up BEFORE socat. # Loopback proxy (fastgithub): ensure 127.0.0.1:port is up BEFORE socat.
if port is not None: if port is not None:
log_file: Path | None = None
if _port_listening_on("127.0.0.1", port): if _port_listening_on("127.0.0.1", port):
click.secho( 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: elif cmd_to_run:
click.secho(f"Running: {' '.join(cmd_to_run)}", fg="cyan") proxy_proc, log_file = _spawn_proxy_process(
proxy_proc = subprocess.Popen(cmd_to_run) name, cmd_to_run, new_session=False
ready = False )
for _ in range(60): if not _wait_for_listen("127.0.0.1", port, proc=proxy_proc):
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:
click.secho( 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", fg="red",
err=True, err=True,
) )
sys.exit(1) sys.exit(1)
click.secho(
f" listening 127.0.0.1:{port} pid={proxy_proc.pid}",
fg="green",
)
else: else:
click.secho( click.secho(
f"ERROR: nothing on 127.0.0.1:{port} and `{name}` not found", 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) sys.exit(1)
forward_procs = _start_parallels_forwards(port) 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) _report_proxy_listen(port)
click.echo("")
if log_file is not None:
click.echo(f" proxy log: {log_file}")
click.secho( 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: try:
if proxy_proc is not None: if proxy_proc is not None:
@@ -416,8 +850,9 @@ def proxy_run(name: str, command: tuple[str, ...]) -> None:
# Non-loopback proxy: original behavior. # Non-loopback proxy: original behavior.
if cmd_to_run: if cmd_to_run:
click.secho(f"Running: {' '.join(cmd_to_run)}", fg="cyan") click.secho(f"Running: {' '.join(cmd_to_run)}", fg="cyan")
click.echo("")
click.secho( 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: try:
proxy_proc = subprocess.Popen(cmd_to_run) proxy_proc = subprocess.Popen(cmd_to_run)
@@ -426,6 +861,7 @@ def proxy_run(name: str, command: tuple[str, ...]) -> None:
pass pass
sys.exit(proxy_proc.returncode if proxy_proc is not None else 0) sys.exit(proxy_proc.returncode if proxy_proc is not None else 0)
click.echo("")
click.secho( click.secho(
"Proxy active. Press Ctrl-C to stop and auto-unset proxy", fg="cyan" "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 pass
finally: finally:
cleanup() 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]] [[package]]
name = "mytoolkit" name = "mytoolkit"
version = "0.1.0" version = "0.2.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },