diff --git a/docs/superpowers/plans/2026-05-28-cli-tool-packaging.md b/docs/superpowers/plans/2026-05-28-cli-tool-packaging.md new file mode 100644 index 0000000..d726df8 --- /dev/null +++ b/docs/superpowers/plans/2026-05-28-cli-tool-packaging.md @@ -0,0 +1,1087 @@ +# 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 new file mode 100644 index 0000000..ab39f51 --- /dev/null +++ b/docs/superpowers/specs/2026-05-28-cli-tool-packaging-design.md @@ -0,0 +1,148 @@ +# 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 文件管理、跨平台进程检查、进程终止