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:
@@ -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` |
|
||||
|
||||
## 安装与更新
|
||||
|
||||
@@ -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 proxy(FastGitHub 等)
|
||||
|
||||
配置在 `~/.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** 场景。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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'" },
|
||||
|
||||
Reference in New Issue
Block a user