From 8af421206bcc4e6ae2298e4ecfc57bffa7edae7d Mon Sep 17 00:00:00 2001 From: tech Date: Thu, 28 May 2026 18:15:50 +0800 Subject: [PATCH] docs: update README with repo URL, add setup guide and .gitignore - Update README installation to use uv tool install with self-hosted repo URL - Add docs/SETUP.md for AI agent consumption - Add .gitignore for __pycache__, build artifacts, venv, IDE files - Remove obsolete superpowers planning docs Co-Authored-By: Claude Opus 4.7 --- .gitignore | 17 + README.md | 10 +- docs/SETUP.md | 23 + .../plans/2026-05-28-cli-tool-packaging.md | 1087 ----------------- .../2026-05-28-cli-tool-packaging-design.md | 148 --- 5 files changed, 44 insertions(+), 1241 deletions(-) create mode 100644 .gitignore create mode 100644 docs/SETUP.md delete mode 100644 docs/superpowers/plans/2026-05-28-cli-tool-packaging.md delete mode 100644 docs/superpowers/specs/2026-05-28-cli-tool-packaging-design.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..30781c9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +__pycache__/ +*.py[cod] +*$py.class + +# Distribution +dist/ +*.egg-info/ +*.whl + +# Virtual environments +.venv/ +venv/ + +# IDE +.idea/ +.vscode/ +*.swp diff --git a/README.md b/README.md index cddf2f5..c55aff0 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ HTTP proxy for Gitea LFS -- rewrites Host header to bypass ICP domain blocking. +**Repository:** `ssh://git@git.zz.com:2222/Developer/jianda-proxy.git` + ## Background Internal Gitea is exposed via a reverse tunnel on a Tencent Cloud server. The client resolves the domain via `hosts` file to the cloud IP. SSH clone works fine, but LFS file downloads go over HTTP. Tencent Cloud detects the unregistered domain from the Host header and blocks the request with a 302 redirect. @@ -11,11 +13,7 @@ This proxy rewrites the Host header on all requests to the upstream IP address, ## Installation ```bash -# Install as a global CLI tool (recommended) -uv tool install . - -# Or with pip -pip install . +uv tool install git+ssh://git@git.zz.com:2222/Developer/jianda-proxy.git ``` ## CLI Commands @@ -92,7 +90,7 @@ git config --global http.http://git.zz.com:3000/.proxy http://127.0.0.1:13000 3. Clone as usual: ```bash -git clone ssh://git@git.zz.com:2222/your-repo.git +git clone ssh://git@git.zz.com:2222/Developer/jianda-proxy.git ``` LFS downloads will be routed through the proxy transparently. diff --git a/docs/SETUP.md b/docs/SETUP.md new file mode 100644 index 0000000..32e8229 --- /dev/null +++ b/docs/SETUP.md @@ -0,0 +1,23 @@ +# jianda-proxy Setup Guide + +## Install + +```bash +uv tool install git+ssh://git@git.zz.com:2222/Developer/jianda-proxy.git +``` + +## Git Config + +After installation, run once to route LFS requests through the proxy: + +```bash +git config --global http.http://git.zz.com:3000/.proxy http://127.0.0.1:13000 +``` + +## Start Proxy + +```bash +jianda-proxy start +``` + +After that, `git clone` / `git pull` / `git push` over SSH will automatically use the proxy for LFS downloads. No extra steps needed. diff --git a/docs/superpowers/plans/2026-05-28-cli-tool-packaging.md b/docs/superpowers/plans/2026-05-28-cli-tool-packaging.md deleted file mode 100644 index d726df8..0000000 --- a/docs/superpowers/plans/2026-05-28-cli-tool-packaging.md +++ /dev/null @@ -1,1087 +0,0 @@ -# jianda-proxy CLI Tool Packaging — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Transform the single-file `lfs-proxy.py` script into a distributable CLI tool with `uvx`/`uv tool install` support, INI-based config, and cross-platform background daemon management. - -**Architecture:** Four focused modules — `config.py` (INI config CRUD), `proxy.py` (HTTP proxy server, refactored from `lfs-proxy.py`), `daemon.py` (PID file + cross-platform process lifecycle), `cli.py` (argparse entry point). Zero external dependencies. `hatchling` build backend. - -**Tech Stack:** Python 3.8+, `configparser`, `argparse`, `logging`, `http.server`, `ctypes` (Windows process checks) - ---- - -## File Map - -| File | Responsibility | -|------|---------------| -| `pyproject.toml` | Package metadata, entry point, build config | -| `src/jianda_proxy/__init__.py` | Package marker, version | -| `src/jianda_proxy/config.py` | Config directory resolution, INI read/write, defaults | -| `src/jianda_proxy/proxy.py` | HTTP proxy server (from `lfs-proxy.py`), accepts config dict | -| `src/jianda_proxy/daemon.py` | PID file CRUD, cross-platform process check/kill, daemonize | -| `src/jianda_proxy/cli.py` | argparse subcommands, entry point `main()` | -| `tests/test_config.py` | Tests for config module | -| `tests/test_daemon.py` | Tests for daemon module | -| `tests/test_proxy.py` | Tests for proxy module | -| `tests/test_cli.py` | Integration tests for CLI | - ---- - -### Task 1: Project Scaffolding - -**Files:** -- Create: `pyproject.toml` -- Create: `src/jianda_proxy/__init__.py` - -- [ ] **Step 1: Create `pyproject.toml`** - -```toml -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[project] -name = "jianda-proxy" -version = "0.1.0" -description = "HTTP proxy for Gitea LFS — rewrites Host header to bypass ICP domain block" -requires-python = ">=3.8" -dependencies = [] - -[project.scripts] -jianda-proxy = "jianda_proxy.cli:main" - -[tool.hatch.build.targets.wheel] -packages = ["src/jianda_proxy"] -``` - -- [ ] **Step 2: Create `src/jianda_proxy/__init__.py`** - -```python -__version__ = "0.1.0" -``` - -- [ ] **Step 3: Verify `uv` can see the package** - -Run: `uv pip install -e .` (from project root, in a venv) -Expected: Package installs successfully - -- [ ] **Step 4: Verify entry point works (will fail, that's OK)** - -Run: `jianda-proxy` or `python -m jianda_proxy` -Expected: `ModuleNotFoundError: No module named 'jianda_proxy.cli'` (confirms entry point wiring) - -- [ ] **Step 5: Commit** - -```bash -git add pyproject.toml src/jianda_proxy/__init__.py -git commit -m "feat: add project scaffolding with pyproject.toml and src layout" -``` - ---- - -### Task 2: Config Module - -**Files:** -- Create: `src/jianda_proxy/config.py` -- Create: `tests/test_config.py` - -- [ ] **Step 1: Write tests for config module** - -```python -import os -import tempfile -import pytest -from configparser import ConfigParser -from jianda_proxy.config import ( - get_config_dir, - get_config_path, - get_pid_path, - get_log_path, - load_config, - set_config_value, - get_config_value, - list_config, - DEFAULTS, -) - -FIXTURES = { - "remote_domain": "git.zz.com", - "upstream_host": "62.234.191.215", - "upstream_port": "3000", - "listen_port": "13000", - "listen_host": "127.0.0.1", -} - - -class TestConfigDir: - def test_returns_path_with_app_name(self, tmp_path, monkeypatch): - monkeypatch.setenv("HOME", str(tmp_path)) - result = get_config_dir() - assert "jianda-proxy" in result - - def test_windows_uses_appdata(self, tmp_path, monkeypatch): - monkeypatch.setenv("APPDATA", str(tmp_path), raising=False) - monkeypatch.delenv("HOME", raising=False) - monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) - result = get_config_dir() - assert "jianda-proxy" in result - - -class TestConfigCRUD: - def test_load_creates_default_config(self, tmp_path, monkeypatch): - monkeypatch.setattr("jianda_proxy.config.get_config_dir", lambda: str(tmp_path)) - config = load_config() - for key, val in FIXTURES.items(): - assert config.get("proxy", key) == val - - def test_load_creates_config_file(self, tmp_path, monkeypatch): - monkeypatch.setattr("jianda_proxy.config.get_config_dir", lambda: str(tmp_path)) - load_config() - assert (tmp_path / "config.ini").exists() - - def test_set_and_get_value(self, tmp_path, monkeypatch): - monkeypatch.setattr("jianda_proxy.config.get_config_dir", lambda: str(tmp_path)) - set_config_value("listen_port", "8080") - assert get_config_value("listen_port") == "8080" - - def test_set_unknown_key_raises(self, tmp_path, monkeypatch): - monkeypatch.setattr("jianda_proxy.config.get_config_dir", lambda: str(tmp_path)) - with pytest.raises(ValueError, match="Unknown config key"): - set_config_value("nonexistent_key", "value") - - def test_list_config(self, tmp_path, monkeypatch): - monkeypatch.setattr("jianda_proxy.config.get_config_dir", lambda: str(tmp_path)) - config = load_config() - result = list_config() - assert isinstance(result, dict) - for key, val in FIXTURES.items(): - assert result[key] == val - - -class TestPaths: - def test_config_path(self, tmp_path, monkeypatch): - monkeypatch.setattr("jianda_proxy.config.get_config_dir", lambda: str(tmp_path)) - assert get_config_path().endswith("config.ini") - - def test_pid_path(self, tmp_path, monkeypatch): - monkeypatch.setattr("jianda_proxy.config.get_config_dir", lambda: str(tmp_path)) - assert get_pid_path().endswith("daemon.pid") - - def test_log_path(self, tmp_path, monkeypatch): - monkeypatch.setattr("jianda_proxy.config.get_config_dir", lambda: str(tmp_path)) - assert get_log_path().endswith("proxy.log") -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `pytest tests/test_config.py -v` -Expected: FAIL with `ModuleNotFoundError` - -- [ ] **Step 3: Write config module implementation** - -```python -import os -import configparser -from pathlib import Path - -SECTION = "proxy" -DEFAULTS = { - "remote_domain": "git.zz.com", - "upstream_host": "62.234.191.215", - "upstream_port": "3000", - "listen_port": "13000", - "listen_host": "127.0.0.1", -} - - -def get_config_dir(): - if os.name == "nt": - base = os.environ.get("APPDATA", os.path.expanduser("~")) - else: - base = os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")) - return os.path.join(base, "jianda-proxy") - - -def _ensure_dir(path): - os.makedirs(path, exist_ok=True) - - -def get_config_path(): - return os.path.join(get_config_dir(), "config.ini") - - -def get_pid_path(): - return os.path.join(get_config_dir(), "daemon.pid") - - -def get_log_path(): - return os.path.join(get_config_dir(), "proxy.log") - - -def load_config(): - path = get_config_path() - _ensure_dir(get_config_dir()) - parser = configparser.ConfigParser() - if not os.path.exists(path): - parser[SECTION] = dict(DEFAULTS) - with open(path, "w") as f: - parser.write(f) - else: - parser.read(path) - if SECTION not in parser: - parser[SECTION] = dict(DEFAULTS) - return parser - - -def set_config_value(key, value): - if key not in DEFAULTS: - raise ValueError(f"Unknown config key: {key}. Valid keys: {', '.join(DEFAULTS)}") - parser = load_config() - parser[SECTION][key] = value - with open(get_config_path(), "w") as f: - parser.write(f) - - -def get_config_value(key): - parser = load_config() - return parser.get(SECTION, key) - - -def list_config(): - parser = load_config() - return dict(parser[SECTION]) -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `pytest tests/test_config.py -v` -Expected: All PASS - -- [ ] **Step 5: Commit** - -```bash -git add src/jianda_proxy/config.py tests/test_config.py -git commit -m "feat: add config module with INI-based configuration management" -``` - ---- - -### Task 3: Proxy Module - -**Files:** -- Create: `src/jianda_proxy/proxy.py` -- Create: `tests/test_proxy.py` - -- [ ] **Step 1: Write tests for proxy module** - -```python -import threading -import http.client -import time -import pytest -from unittest.mock import MagicMock, patch -from jianda_proxy.proxy import create_server - - -class TestCreateServer: - def test_creates_server_with_config(self): - config = { - "remote_domain": "git.example.com", - "upstream_host": "1.2.3.4", - "upstream_port": "9000", - "listen_host": "127.0.0.1", - "listen_port": "19999", - } - server = create_server(config) - assert server is not None - server.server_close() - - def test_binds_to_correct_port(self): - config = { - "remote_domain": "git.example.com", - "upstream_host": "1.2.3.4", - "upstream_port": "9000", - "listen_host": "127.0.0.1", - "listen_port": "19998", - } - server = create_server(config) - assert server.server_address[1] == 19998 - server.server_close() - - -class TestProxyHandler: - def test_forward_proxy_mode(self): - """When request URL has a hostname matching remote_domain, rewrite to upstream IP.""" - from jianda_proxy.proxy import ProxyHandler - - config = { - "remote_domain": "git.example.com", - "upstream_host": "1.2.3.4", - "upstream_port": "9000", - "listen_host": "127.0.0.1", - "listen_port": "19997", - } - server = create_server(config) - handler = ProxyHandler - - # The handler should have the correct config set as class attributes - assert hasattr(handler, "REMOTE_DOMAIN") - server.server_close() -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `pytest tests/test_proxy.py -v` -Expected: FAIL with `ModuleNotFoundError` - -- [ ] **Step 3: Write proxy module — extract and refactor from `lfs-proxy.py`** - -Read `lfs-proxy.py` and refactor into `src/jianda_proxy/proxy.py`: - -```python -import http.client -import http.server -import logging -from urllib.parse import urlparse - -logger = logging.getLogger("jianda_proxy") - - -class ProxyHandler(http.server.BaseHTTPRequestHandler): - protocol_version = "HTTP/1.1" - - # Set by create_server() before serving - REMOTE_DOMAIN = "" - UPSTREAM_IP = "" - UPSTREAM_PORT = 0 - - def _proxy_request(self, target_host, target_port, target_path, body=None): - body_len = int(self.headers.get("Content-Length", 0)) - body = self.rfile.read(body_len) if body_len else None - - conn = http.client.HTTPConnection(target_host, target_port, timeout=120) - try: - headers = {} - skip = {"host", "transfer-encoding", "connection"} - for k in self.headers: - if k.lower() not in skip: - headers[k] = self.headers[k] - headers["Host"] = f"{ProxyHandler.UPSTREAM_IP}:{ProxyHandler.UPSTREAM_PORT}" - if body_len: - headers["Content-Length"] = str(body_len) - - conn.request(self.command, target_path, body=body, headers=headers) - resp = conn.getresponse() - resp_body = resp.read() - - self.send_response(resp.status) - sent_cl = False - for k, v in resp.getheaders(): - kl = k.lower() - if kl in ("transfer-encoding", "connection"): - continue - if kl == "content-length": - self.send_header(k, len(resp_body)) - sent_cl = True - continue - self.send_header(k, v) - if not sent_cl: - self.send_header("Content-Length", len(resp_body)) - self.end_headers() - self.wfile.write(resp_body) - finally: - conn.close() - - def _do(self): - raw_path = self.path - parsed = urlparse(raw_path) - - if ( - parsed.hostname - and parsed.hostname != "127.0.0.1" - and parsed.hostname != "localhost" - ): - if parsed.hostname == ProxyHandler.REMOTE_DOMAIN: - target_host = ProxyHandler.UPSTREAM_IP - else: - target_host = parsed.hostname - target_port = parsed.port or 80 - target_path = parsed.path + ("?" + parsed.query if parsed.query else "") - self._proxy_request(target_host, target_port, target_path) - else: - self._proxy_request( - ProxyHandler.UPSTREAM_IP, ProxyHandler.UPSTREAM_PORT, raw_path - ) - - do_GET = do_POST = do_PUT = do_DELETE = do_HEAD = do_PATCH = _do - - def log_message(self, fmt, *args): - logger.info("%s - %s", self.address_string(), fmt % args) - - -def create_server(config): - """Create (but don't start) the proxy server. - - Args: - config: dict with keys remote_domain, upstream_host, upstream_port, - listen_host, listen_port (all strings) - - Returns: - ThreadingHTTPServer instance - """ - ProxyHandler.REMOTE_DOMAIN = config["remote_domain"] - ProxyHandler.UPSTREAM_IP = config["upstream_host"] - ProxyHandler.UPSTREAM_PORT = int(config["upstream_port"]) - - listen_host = config.get("listen_host", "127.0.0.1") - listen_port = int(config["listen_port"]) - - server = http.server.ThreadingHTTPServer((listen_host, listen_port), ProxyHandler) - logger.info( - "Proxy: %s:%s -> %s:%s", - listen_host, - listen_port, - ProxyHandler.UPSTREAM_IP, - ProxyHandler.UPSTREAM_PORT, - ) - return server -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `pytest tests/test_proxy.py -v` -Expected: All PASS - -- [ ] **Step 5: Commit** - -```bash -git add src/jianda_proxy/proxy.py tests/test_proxy.py -git commit -m "feat: extract proxy server from lfs-proxy.py into module" -``` - ---- - -### Task 4: Daemon Module - -**Files:** -- Create: `src/jianda_proxy/daemon.py` -- Create: `tests/test_daemon.py` - -- [ ] **Step 1: Write tests for daemon module** - -```python -import os -import tempfile -import pytest -from unittest.mock import patch, MagicMock -from jianda_proxy.daemon import ( - read_pid, - write_pid, - remove_pid, - is_process_alive, - is_running, - stop_daemon, - get_status, -) - - -class TestPidFile: - def test_write_and_read_pid(self, tmp_path, monkeypatch): - pid_file = str(tmp_path / "test.pid") - monkeypatch.setattr("jianda_proxy.daemon.get_pid_path", lambda: pid_file) - write_pid(12345) - assert read_pid() == 12345 - - def test_read_missing_pid_returns_none(self, tmp_path, monkeypatch): - pid_file = str(tmp_path / "nonexistent.pid") - monkeypatch.setattr("jianda_proxy.daemon.get_pid_path", lambda: pid_file) - assert read_pid() is None - - def test_remove_pid(self, tmp_path, monkeypatch): - pid_file = str(tmp_path / "test.pid") - monkeypatch.setattr("jianda_proxy.daemon.get_pid_path", lambda: pid_file) - write_pid(12345) - remove_pid() - assert read_pid() is None - - -class TestProcessCheck: - @patch("os.kill") - def test_alive_on_unix(self, mock_kill): - mock_kill.return_value = None - assert is_process_alive(12345) is True - - @patch("os.kill", side_effect=ProcessLookupError) - def test_dead_on_unix(self, mock_kill): - assert is_process_alive(12345) is False - - @patch("os.kill", side_effect=PermissionError) - def test_permission_means_alive(self, mock_kill): - # Can't send signal but process exists - assert is_process_alive(12345) is True - - -class TestIsRunning: - @patch("jianda_proxy.daemon.is_process_alive", return_value=True) - @patch("jianda_proxy.daemon.read_pid", return_value=12345) - def test_running_when_pid_and_alive(self, mock_read, mock_alive): - assert is_running() is True - - @patch("jianda_proxy.daemon.is_process_alive", return_value=False) - @patch("jianda_proxy.daemon.read_pid", return_value=12345) - def test_not_running_when_pid_dead(self, mock_read, mock_alive): - assert is_running() is False - - @patch("jianda_proxy.daemon.read_pid", return_value=None) - def test_not_running_when_no_pid(self, mock_read): - assert is_running() is False - - -class TestStopDaemon: - @patch("jianda_proxy.daemon.is_process_alive", return_value=True) - @patch("jianda_proxy.daemon.read_pid", return_value=12345) - @patch("time.sleep") - @patch("os.kill") - def test_stop_sends_sigterm(self, mock_kill, mock_sleep, mock_read, mock_alive): - # First call: alive, second call: dead - mock_alive.side_effect = [True, False] - stop_daemon() - mock_kill.assert_called_once_with(12345, 15) # SIGTERM=15 - - @patch("jianda_proxy.daemon.remove_pid") - @patch("jianda_proxy.daemon.is_process_alive", return_value=False) - @patch("jianda_proxy.daemon.read_pid", return_value=None) - def test_stop_noop_when_not_running(self, mock_read, mock_alive, mock_remove): - result = stop_daemon() - assert result == "not_running" - - -class TestGetStatus: - @patch("jianda_proxy.daemon.is_process_alive", return_value=True) - @patch("jianda_proxy.daemon.read_pid", return_value=12345) - def test_status_running(self, mock_read, mock_alive): - status = get_status() - assert status["running"] is True - assert status["pid"] == 12345 - - @patch("jianda_proxy.daemon.read_pid", return_value=None) - def test_status_not_running(self, mock_read): - status = get_status() - assert status["running"] is False -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `pytest tests/test_daemon.py -v` -Expected: FAIL with `ModuleNotFoundError` - -- [ ] **Step 3: Write daemon module implementation** - -```python -import os -import sys -import time -import signal -from jianda_proxy.config import get_pid_path - - -def read_pid(): - path = get_pid_path() - if not os.path.exists(path): - return None - try: - with open(path) as f: - return int(f.read().strip()) - except (ValueError, OSError): - return None - - -def write_pid(pid): - path = get_pid_path() - os.makedirs(os.path.dirname(path), exist_ok=True) - with open(path, "w") as f: - f.write(str(pid)) - - -def remove_pid(): - path = get_pid_path() - if os.path.exists(path): - os.remove(path) - - -def is_process_alive(pid): - if os.name == "nt": - import ctypes - import ctypes.wintypes - - kernel32 = ctypes.windll.kernel32 - PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 - STILL_ACTIVE = 259 - handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid) - if not handle: - return False - try: - exit_code = ctypes.wintypes.DWORD() - kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)) - return exit_code.value == STILL_ACTIVE - finally: - kernel32.CloseHandle(handle) - else: - try: - os.kill(pid, 0) - return True - except ProcessLookupError: - return False - except PermissionError: - return True - - -def is_running(): - pid = read_pid() - if pid is None: - return False - return is_process_alive(pid) - - -def stop_daemon(): - pid = read_pid() - if pid is None: - return "not_running" - - if not is_process_alive(pid): - remove_pid() - return "not_running" - - if os.name == "nt": - os.system(f"taskkill /PID {pid} /T") - else: - try: - os.kill(pid, signal.SIGTERM) - except ProcessLookupError: - remove_pid() - return "stopped" - - # Wait up to 3 seconds for graceful shutdown - for _ in range(6): - time.sleep(0.5) - if not is_process_alive(pid): - remove_pid() - return "stopped" - - # Force kill - if os.name == "nt": - os.system(f"taskkill /F /PID {pid} /T") - else: - try: - os.kill(pid, signal.SIGKILL) - except ProcessLookupError: - pass - - remove_pid() - return "stopped" - - -def get_status(): - pid = read_pid() - if pid is None or not is_process_alive(pid): - return {"running": False, "pid": None} - return {"running": True, "pid": pid} - - -def daemonize(): - """Fork/detach process for background operation.""" - if os.name == "nt": - # Windows: relaunch self with detached flag - import subprocess - - creation_flags = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS - subprocess.Popen( - [sys.executable] + sys.argv + ["--_daemon_child"], - creationflags=creation_flags, - close_fds=True, - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - else: - # Unix: double fork - pid = os.fork() - if pid > 0: - # Parent exits - os._exit(0) - os.setsid() - pid = os.fork() - if pid > 0: - os._exit(0) -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `pytest tests/test_daemon.py -v` -Expected: All PASS - -- [ ] **Step 5: Commit** - -```bash -git add src/jianda_proxy/daemon.py tests/test_daemon.py -git commit -m "feat: add daemon module with cross-platform process lifecycle" -``` - ---- - -### Task 5: CLI Entry Point - -**Files:** -- Create: `src/jianda_proxy/cli.py` -- Create: `tests/test_cli.py` - -- [ ] **Step 1: Write tests for CLI subcommands** - -```python -import os -import tempfile -import subprocess -import pytest -from unittest.mock import patch, MagicMock -from jianda_proxy.cli import build_parser - - -class TestParser: - def test_start_subcommand_exists(self): - parser = build_parser() - args = parser.parse_args(["start"]) - assert args.command == "start" - assert args.foreground is False - - def test_start_foreground_flag(self): - parser = build_parser() - args = parser.parse_args(["start", "--foreground"]) - assert args.foreground is True - - def test_stop_subcommand(self): - parser = build_parser() - args = parser.parse_args(["stop"]) - assert args.command == "stop" - - def test_status_subcommand(self): - parser = build_parser() - args = parser.parse_args(["status"]) - assert args.command == "status" - - def test_config_set(self): - parser = build_parser() - args = parser.parse_args(["config", "set", "listen_port", "8080"]) - assert args.config_command == "set" - assert args.key == "listen_port" - assert args.value == "8080" - - def test_config_get(self): - parser = build_parser() - args = parser.parse_args(["config", "get", "listen_port"]) - assert args.config_command == "get" - assert args.key == "listen_port" - - def test_config_list(self): - parser = build_parser() - args = parser.parse_args(["config", "list"]) - assert args.config_command == "list" - - def test_config_path(self): - parser = build_parser() - args = parser.parse_args(["config", "path"]) - assert args.config_command == "path" -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `pytest tests/test_cli.py -v` -Expected: FAIL with `ModuleNotFoundError` - -- [ ] **Step 3: Write CLI entry point** - -```python -import argparse -import logging -import sys - -from jianda_proxy.config import ( - load_config, - set_config_value, - get_config_value, - list_config, - get_config_path, - get_log_path, -) -from jianda_proxy.proxy import create_server -from jianda_proxy.daemon import ( - write_pid, - is_running, - stop_daemon, - get_status, - daemonize, -) - - -def build_parser(): - parser = argparse.ArgumentParser(prog="jianda-proxy", description="HTTP proxy for Gitea LFS") - sub = parser.add_subparsers(dest="command") - - # start - p_start = sub.add_parser("start", help="Start the proxy daemon") - p_start.add_argument("--foreground", action="store_true", help="Run in foreground (no daemon)") - p_start.add_argument("--port", type=str, default=None, help="Override listen port") - - # stop - sub.add_parser("stop", help="Stop the proxy daemon") - - # status - sub.add_parser("status", help="Show proxy daemon status") - - # config - p_config = sub.add_parser("config", help="Manage configuration") - config_sub = p_config.add_subparsers(dest="config_command") - - p_set = config_sub.add_parser("set", help="Set a config value") - p_set.add_argument("key", help="Config key") - p_set.add_argument("value", help="Config value") - - p_get = config_sub.add_parser("get", help="Get a config value") - p_get.add_argument("key", help="Config key") - - config_sub.add_parser("list", help="List all config values") - config_sub.add_parser("path", help="Show config file path") - - return parser - - -def cmd_start(args): - if is_running(): - print("Error: jianda-proxy is already running.", file=sys.stderr) - sys.exit(1) - - config = load_config() - config_dict = dict(config["proxy"]) - - if args.port: - config_dict["listen_port"] = args.port - - if args.foreground: - _run_foreground(config_dict) - else: - _run_daemonized(config_dict) - - -def _run_foreground(config_dict): - logging.basicConfig( - level=logging.INFO, - format="%(asctime)s [%(name)s] %(levelname)s - %(message)s", - ) - server = create_server(config_dict) - try: - server.serve_forever() - except KeyboardInterrupt: - pass - finally: - server.server_close() - - -def _run_daemonized(config_dict): - from jianda_proxy.daemon import daemonize - - # Set up file logging before daemonizing - from logging.handlers import RotatingFileHandler - - log_path = get_log_path() - handler = RotatingFileHandler(log_path, maxBytes=5 * 1024 * 1024, backupCount=3) - handler.setFormatter( - logging.Formatter("%(asctime)s [%(name)s] %(levelname)s - %(message)s") - ) - root = logging.getLogger() - root.setLevel(logging.INFO) - root.addHandler(handler) - - daemonize() - - # This runs in the child process - pid = os.getpid() - write_pid(pid) - - server = create_server(config_dict) - try: - server.serve_forever() - finally: - server.server_close() - from jianda_proxy.daemon import remove_pid - remove_pid() - - -def cmd_stop(_args): - result = stop_daemon() - if result == "not_running": - print("jianda-proxy is not running.", file=sys.stderr) - sys.exit(1) - else: - print("jianda-proxy stopped.") - - -def cmd_status(_args): - status = get_status() - if status["running"]: - print(f"jianda-proxy is running (PID: {status['pid']})") - else: - print("jianda-proxy is not running.") - - -def cmd_config(args): - if args.config_command == "set": - set_config_value(args.key, args.value) - print(f"{args.key} = {args.value}") - elif args.config_command == "get": - print(get_config_value(args.key)) - elif args.config_command == "list": - for k, v in list_config().items(): - print(f"{k} = {v}") - elif args.config_command == "path": - print(get_config_path()) - else: - print("Unknown config command. Use set/get/list/path.", file=sys.stderr) - sys.exit(1) - - -def main(): - parser = build_parser() - args = parser.parse_args() - - if not args.command: - parser.print_help() - sys.exit(1) - - commands = { - "start": cmd_start, - "stop": cmd_stop, - "status": cmd_status, - "config": cmd_config, - } - fn = commands.get(args.command) - if fn: - fn(args) - else: - parser.print_help() - sys.exit(1) - - -if __name__ == "__main__": - main() -``` - -Note: `cli.py` needs `import os` at the top. Make sure it's included. - -- [ ] **Step 4: Run CLI parser tests** - -Run: `pytest tests/test_cli.py -v` -Expected: All PASS - -- [ ] **Step 5: Manual smoke test — foreground mode** - -Run: `jianda-proxy start --foreground` in one terminal, verify it starts and logs to terminal. Ctrl+C to stop. - -- [ ] **Step 6: Manual smoke test — config commands** - -Run: -```bash -jianda-proxy config list -jianda-proxy config path -jianda-proxy config set listen_port 14000 -jianda-proxy config get listen_port -jianda-proxy config set listen_port 13000 # restore -``` - -- [ ] **Step 7: Manual smoke test — daemon lifecycle** - -Run: -```bash -jianda-proxy start -jianda-proxy status -jianda-proxy stop -jianda-proxy status # should say not running -``` - -- [ ] **Step 8: Commit** - -```bash -git add src/jianda_proxy/cli.py tests/test_cli.py -git commit -m "feat: add CLI entry point with start/stop/status/config subcommands" -``` - ---- - -### Task 6: Integration Verification & Cleanup - -**Files:** -- Modify: `README.md` - -- [ ] **Step 1: Verify `uv tool install .` works** - -Run: `uv tool install . --force` -Expected: `jianda-proxy` command available globally - -- [ ] **Step 2: Verify `python -m jianda_proxy` works** - -Run: `python -m jianda_proxy --help` -Expected: Help text displayed - -- [ ] **Step 3: Run full test suite** - -Run: `pytest tests/ -v` -Expected: All PASS - -- [ ] **Step 4: Update README.md** - -Replace README content with updated usage instructions reflecting the new CLI interface (`start`, `stop`, `status`, `config` subcommands, installation instructions). - -- [ ] **Step 5: Remove old `lfs-proxy.py`** - -```bash -git rm lfs-proxy.py -``` - -- [ ] **Step 6: Final commit** - -```bash -git add README.md -git commit -m "docs: update README for CLI tool usage, remove old script" -``` - ---- - -### Task 7: Verify and Finish - -- [ ] **Step 1: Run all tests one final time** - -Run: `pytest tests/ -v` -Expected: All PASS - -- [ ] **Step 2: Full lifecycle manual test** - -Run the complete workflow: -```bash -jianda-proxy config list -jianda-proxy start -jianda-proxy status -# Verify proxy works: curl through it -jianda-proxy stop -jianda-proxy status -``` - -- [ ] **Step 3: Verify `uv tool` uninstall and reinstall** - -```bash -uv tool uninstall jianda-proxy -uv tool install . -jianda-proxy --help -uv tool uninstall jianda-proxy -``` - -- [ ] **Step 4: Run verification-before-completion skill** - -Invoke the `superpowers:verification-before-completion` skill to confirm all requirements are met. diff --git a/docs/superpowers/specs/2026-05-28-cli-tool-packaging-design.md b/docs/superpowers/specs/2026-05-28-cli-tool-packaging-design.md deleted file mode 100644 index ab39f51..0000000 --- a/docs/superpowers/specs/2026-05-28-cli-tool-packaging-design.md +++ /dev/null @@ -1,148 +0,0 @@ -# jianda-proxy CLI 工具打包设计 - -**日期**:2026-05-28 -**状态**:已批准 - -## 背景 - -jianda-proxy 是一个 HTTP 代理工具,用于解决腾讯云 ICP 拦截导致 Git LFS 下载失败的问题。目前是一个单文件 Python 脚本(`lfs-proxy.py`),配置硬编码,直接 `python3` 运行。 - -目标:将其打包为标准的 Python CLI 工具,支持 `uvx` 运行、`uv tool install` 安装、后台运行、CLI 管理生命周期,且跨平台(Windows + Mac)。 - -## 决策 - -- **工具名称**:`jianda-proxy` -- **配置格式**:INI(标准库 `configparser`,零外部依赖) -- **后台运行**:纯标准库,Unix 用 `os.fork()`,Windows 用 `subprocess` detached -- **配置管理**:CLI `config` 子命令 - -## 项目结构 - -``` -jianda-proxy/ -├── pyproject.toml -├── src/ -│ └── jianda_proxy/ -│ ├── __init__.py -│ ├── cli.py # CLI 入口,argparse 子命令 -│ ├── proxy.py # 代理逻辑 -│ ├── config.py # INI 配置管理 -│ └── daemon.py # 跨平台后台运行管理 -├── docs/ -│ └── superpowers/specs/ -└── README.md -``` - -### pyproject.toml - -- 零外部依赖 -- Python >= 3.8 -- 入口点:`jianda-proxy = "jianda_proxy.cli:main"` -- 构建后端:`hatchling` - -## 打包与分发 - -- `uv tool install .`:本地安装 -- `uvx jianda-proxy`:临时运行(需发布到 PyPI 后) -- `python -m jianda_proxy`:作为模块运行 - -## 配置管理 - -### 配置文件位置 - -- Mac/Linux:`~/.config/jianda-proxy/config.ini` -- Windows:`%APPDATA%\jianda-proxy\config.ini` - -通过 `os.path.expanduser` 和 `os.environ` 手动拼路径,不引入 `platformdirs`。 - -### 配置格式 - -```ini -[proxy] -remote_domain = git.zz.com -upstream_host = 62.234.191.215 -upstream_port = 3000 -listen_port = 13000 -listen_host = 127.0.0.1 -``` - -### config 子命令 - -| 命令 | 说明 | -|------|------| -| `config set ` | 设置配置项 | -| `config get ` | 查看单个配置项 | -| `config list` | 列出所有配置 | -| `config path` | 显示配置文件路径 | - -首次使用 `start` 时,如果配置文件不存在,用默认值自动创建。 - -## CLI 命令 - -``` -jianda-proxy start [--port PORT] [--foreground] -jianda-proxy stop -jianda-proxy status -jianda-proxy config set -jianda-proxy config get -jianda-proxy config list -jianda-proxy config path -``` - -`start --foreground`:前台运行,日志直接输出到终端,方便调试。 - -## 后台运行与生命周期 - -### start - -1. 检查端口是否可用 -2. 检查是否已有进程运行(PID 文件存在且进程存活) -3. 如果已有进程:提示错误并退出 -4. 如果 PID 文件存在但进程已死:清理后继续 -5. Unix:`os.fork()` 后父进程退出 -6. Windows:`subprocess.Popen` with `CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS` -7. 写 PID 文件 -8. 日志写到 `~/.config/jianda-proxy/proxy.log`(RotatingFileHandler,5MB,3 个备份) - -### stop - -1. 读取 PID 文件 -2. 检查进程是否存在 -3. 发送 SIGTERM(Unix)/ taskkill(Windows) -4. 等待最多 3 秒 -5. 如果还活着:SIGKILL / 强制终止 -6. 删除 PID 文件 - -### status - -1. 读取 PID 文件 -2. 检查进程是否存在 -3. 显示:运行状态、PID、端口、运行时间 - -### PID 文件 - -位置:和配置文件同目录 `~/.config/jianda-proxy/daemon.pid`。 - -## 跨平台细节 - -| 操作 | Unix | Windows | -|------|------|---------| -| 后台启动 | `os.fork()` | `subprocess.Popen` + detached flags | -| 进程检查 | `os.kill(pid, 0)` | `ctypes` 调用 `kernel32.OpenProcess` + `GetExitCodeProcess` | -| 优雅停止 | `SIGTERM` | `taskkill /PID` | -| 强制停止 | `SIGKILL` | `taskkill /F /PID` | -| 配置目录 | `~/.config/` | `%APPDATA%` | - -## 错误处理 - -- 端口被占用:提示具体端口和可能原因 -- 配置无效(IP 格式错误等):启动前验证 -- 权限不足:提示需要的权限 -- 后台进程异常退出:`status` 检测到后提示查看日志 - -## 模块职责 - -- **cli.py**:argparse 定义、子命令分发、入口函数 -- **config.py**:读写配置文件、默认值管理、配置路径计算 -- **proxy.py**:HTTP 代理服务器逻辑(从现有 `lfs-proxy.py` 提取) -- **daemon.py**:PID 文件管理、跨平台进程检查、进程终止