diff --git a/README.md b/README.md index b79e79d..e93ba0a 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,30 @@ python -m nlprog models show deepseek python -m nlprog models remove deepseek ``` +## 自动更新 + +NLProg 默认在每次启动时检查更新。如果发现新版本,会询问是否立即更新。 + +手动检查更新: +```powershell +python -m nlprog update check +``` + +手动更新: +```powershell +python -m nlprog update self +``` + +关闭自动检测更新: +```powershell +python -m nlprog config set auto_update_check false +``` + +重新开启自动检测更新: +```powershell +python -m nlprog config set auto_update_check true +``` + ## 使用 opencode 先确认 opencode 已经登录: diff --git a/pyproject.toml b/pyproject.toml index 2ff0d9b..48fbd6c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nlprog" -version = "0.1.0" +version = "0.1.1" description = "A terminal-first natural language programming assistant with pluggable LLM providers." readme = "README.md" requires-python = ">=3.8" diff --git a/src/nlprog/__init__.py b/src/nlprog/__init__.py index a05eb9a..fd9a4ec 100644 --- a/src/nlprog/__init__.py +++ b/src/nlprog/__init__.py @@ -1,3 +1,3 @@ __all__ = ["__version__"] -__version__ = "0.1.0" +__version__ = "0.1.1" diff --git a/src/nlprog/cli.py b/src/nlprog/cli.py index 7b1cafa..474a5ef 100644 --- a/src/nlprog/cli.py +++ b/src/nlprog/cli.py @@ -24,6 +24,7 @@ from .executor import run_command from .llm import LLMError, create_client from .project_profile import init_project, build_project_context from .run_log import list_run_logs, load_run_log +from .updater import check_for_update, run_self_update from . import __version__ @@ -35,6 +36,12 @@ def main(argv: list[str] | None = None) -> None: subparsers.add_parser("init", help="Create a default config file.") subparsers.add_parser("version", help="Show NLProg version.") + update_parser = subparsers.add_parser("update", help="Check for or install NLProg updates.") + update_subparsers = update_parser.add_subparsers(dest="update_command") + update_subparsers.add_parser("check", help="Check whether a newer NLProg version is available.") + update_self_parser = update_subparsers.add_parser("self", help="Update NLProg from the configured source.") + update_self_parser.add_argument("--yes", action="store_true", help="Update without asking.") + config_parser = subparsers.add_parser("config", help="Show or update user configuration.") config_subparsers = config_parser.add_subparsers(dest="config_command") @@ -118,6 +125,9 @@ def main(argv: list[str] | None = None) -> None: args = parser.parse_args(argv) + if args.command not in {None, "update"}: + _maybe_auto_update() + if args.command == "init": path = write_default_config() print(f"Config ready: {path}") @@ -131,6 +141,10 @@ def main(argv: list[str] | None = None) -> None: _handle_config(args) return + if args.command == "update": + _handle_update(args) + return + if args.command == "models": _handle_models(args) return @@ -387,6 +401,68 @@ def _handle_config(args: argparse.Namespace) -> None: raise SystemExit(2) +def _handle_update(args: argparse.Namespace) -> None: + if args.update_command == "check": + config = load_config() + info = check_for_update(config) + if info.error: + print(f"Update check failed: {info.error}", file=sys.stderr) + raise SystemExit(1) + print(f"Current version: {info.current_version}") + print(f"Latest version: {info.latest_version}") + if info.update_available: + print("Update available.") + print("Run: nlprog update self") + else: + print("NLProg is up to date.") + return + + if args.update_command == "self": + config = load_config() + if not args.yes and not _confirm( + f"Update NLProg from {config.update_source}?" + ): + print("Cancelled.") + return + code = run_self_update(config) + if code != 0: + raise SystemExit(code) + print("Update finished. Run: nlprog version") + return + + print("Missing update command. Use: check or self.", file=sys.stderr) + raise SystemExit(2) + + +def _maybe_auto_update() -> None: + try: + config = load_config() + if not config.auto_update_check: + return + info = check_for_update(config) + except Exception: + return + + if info.error or not info.update_available or not info.latest_version: + return + + print( + f"Update available: {info.latest_version} (current: {info.current_version})." + ) + print("Disable automatic update checks with: nlprog config set auto_update_check false") + + if not sys.stdin.isatty(): + print("Run to update: nlprog update self") + return + + if _confirm("Update now?"): + code = run_self_update(config) + if code != 0: + print(f"Update failed with exit code {code}.", file=sys.stderr) + else: + print("Update finished. Restart NLProg to use the new version.") + + def _handle_models(args: argparse.Namespace) -> None: if args.models_command == "list": active, models = list_models() diff --git a/src/nlprog/config.py b/src/nlprog/config.py index 7bb52c5..05e8886 100644 --- a/src/nlprog/config.py +++ b/src/nlprog/config.py @@ -26,6 +26,9 @@ DEFAULT_CONFIG: dict[str, Any] = { "require_confirmation": True, "opencode_command": "opencode", "json_repair_retries": 2, + "auto_update_check": True, + "update_source": "git+https://git.tointe.com/sunguosheng/nlprog.git", + "update_version_url": "https://git.tointe.com/sunguosheng/nlprog/raw/branch/main/pyproject.toml", } CONFIG_TYPES: dict[str, type] = { @@ -40,6 +43,9 @@ CONFIG_TYPES: dict[str, type] = { "timeout_seconds": int, "opencode_command": str, "json_repair_retries": int, + "auto_update_check": bool, + "update_source": str, + "update_version_url": str, } MODEL_CONFIG_KEYS = { @@ -68,6 +74,9 @@ class Config: timeout_seconds: int = 60 opencode_command: str = "opencode" json_repair_retries: int = 2 + auto_update_check: bool = True + update_source: str = "git+https://git.tointe.com/sunguosheng/nlprog.git" + update_version_url: str = "https://git.tointe.com/sunguosheng/nlprog/raw/branch/main/pyproject.toml" def load_config() -> Config: @@ -85,6 +94,15 @@ def load_config() -> Config: ) opencode_command = os.getenv("NLPROG_OPENCODE_COMMAND", raw.get("opencode_command", "opencode")) json_repair_retries = int(os.getenv("NLPROG_JSON_REPAIR_RETRIES", raw.get("json_repair_retries", 2))) + auto_update_check = _bool_env( + os.getenv("NLPROG_AUTO_UPDATE_CHECK"), + bool(raw.get("auto_update_check", True)), + ) + update_source = os.getenv("NLPROG_UPDATE_SOURCE", raw.get("update_source", DEFAULT_CONFIG["update_source"])) + update_version_url = os.getenv( + "NLPROG_UPDATE_VERSION_URL", + raw.get("update_version_url", DEFAULT_CONFIG["update_version_url"]), + ) return Config( provider=provider, @@ -98,6 +116,9 @@ def load_config() -> Config: timeout_seconds=int(raw.get("timeout_seconds", 60)), opencode_command=opencode_command, json_repair_retries=json_repair_retries, + auto_update_check=auto_update_check, + update_source=update_source, + update_version_url=update_version_url, ) diff --git a/src/nlprog/updater.py b/src/nlprog/updater.py new file mode 100644 index 0000000..372f6b4 --- /dev/null +++ b/src/nlprog/updater.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import re +import subprocess +import sys +from dataclasses import dataclass +from urllib.error import URLError +from urllib.request import urlopen + +from .config import Config +from . import __version__ + + +VERSION_RE = re.compile(r'^\s*version\s*=\s*["\']([^"\']+)["\']\s*$', re.MULTILINE) + + +@dataclass(frozen=True) +class UpdateInfo: + current_version: str + latest_version: str | None + update_available: bool + error: str | None = None + + +def check_for_update(config: Config, timeout_seconds: int = 4) -> UpdateInfo: + try: + with urlopen(config.update_version_url, timeout=timeout_seconds) as response: + body = response.read().decode("utf-8", errors="replace") + except (OSError, URLError) as exc: + return UpdateInfo(__version__, None, False, str(exc)) + + match = VERSION_RE.search(body) + if not match: + return UpdateInfo(__version__, None, False, "Could not find project version in update source.") + + latest = match.group(1).strip() + return UpdateInfo(__version__, latest, _version_tuple(latest) > _version_tuple(__version__)) + + +def run_self_update(config: Config) -> int: + command = [sys.executable, "-m", "pip", "install", "--upgrade", config.update_source] + return subprocess.call(command) + + +def _version_tuple(version: str) -> tuple[int, ...]: + parts: list[int] = [] + for part in re.split(r"[.+-]", version): + if part.isdigit(): + parts.append(int(part)) + else: + break + return tuple(parts) diff --git a/tests/test_updater.py b/tests/test_updater.py new file mode 100644 index 0000000..f7127b3 --- /dev/null +++ b/tests/test_updater.py @@ -0,0 +1,18 @@ +import unittest + +from nlprog.updater import VERSION_RE, _version_tuple + + +class UpdaterTests(unittest.TestCase): + def test_version_regex_reads_pyproject_version(self): + match = VERSION_RE.search('[project]\nname = "nlprog"\nversion = "0.2.0"\n') + self.assertIsNotNone(match) + self.assertEqual(match.group(1), "0.2.0") + + def test_version_tuple_compares_numeric_versions(self): + self.assertGreater(_version_tuple("0.10.0"), _version_tuple("0.2.0")) + self.assertEqual(_version_tuple("1.2.3"), (1, 2, 3)) + + +if __name__ == "__main__": + unittest.main()