Add automatic update checks

This commit is contained in:
sunguosheng
2026-06-17 21:07:04 +08:00
parent 26265cbb10
commit 692a31303e
7 changed files with 193 additions and 2 deletions
+24
View File
@@ -93,6 +93,30 @@ python -m nlprog models show deepseek
python -m nlprog models remove 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
先确认 opencode 已经登录: 先确认 opencode 已经登录:
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "nlprog" name = "nlprog"
version = "0.1.0" version = "0.1.1"
description = "A terminal-first natural language programming assistant with pluggable LLM providers." description = "A terminal-first natural language programming assistant with pluggable LLM providers."
readme = "README.md" readme = "README.md"
requires-python = ">=3.8" requires-python = ">=3.8"
+1 -1
View File
@@ -1,3 +1,3 @@
__all__ = ["__version__"] __all__ = ["__version__"]
__version__ = "0.1.0" __version__ = "0.1.1"
+76
View File
@@ -24,6 +24,7 @@ from .executor import run_command
from .llm import LLMError, create_client from .llm import LLMError, create_client
from .project_profile import init_project, build_project_context from .project_profile import init_project, build_project_context
from .run_log import list_run_logs, load_run_log from .run_log import list_run_logs, load_run_log
from .updater import check_for_update, run_self_update
from . import __version__ 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("init", help="Create a default config file.")
subparsers.add_parser("version", help="Show NLProg version.") 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_parser = subparsers.add_parser("config", help="Show or update user configuration.")
config_subparsers = config_parser.add_subparsers(dest="config_command") 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) args = parser.parse_args(argv)
if args.command not in {None, "update"}:
_maybe_auto_update()
if args.command == "init": if args.command == "init":
path = write_default_config() path = write_default_config()
print(f"Config ready: {path}") print(f"Config ready: {path}")
@@ -131,6 +141,10 @@ def main(argv: list[str] | None = None) -> None:
_handle_config(args) _handle_config(args)
return return
if args.command == "update":
_handle_update(args)
return
if args.command == "models": if args.command == "models":
_handle_models(args) _handle_models(args)
return return
@@ -387,6 +401,68 @@ def _handle_config(args: argparse.Namespace) -> None:
raise SystemExit(2) 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: def _handle_models(args: argparse.Namespace) -> None:
if args.models_command == "list": if args.models_command == "list":
active, models = list_models() active, models = list_models()
+21
View File
@@ -26,6 +26,9 @@ DEFAULT_CONFIG: dict[str, Any] = {
"require_confirmation": True, "require_confirmation": True,
"opencode_command": "opencode", "opencode_command": "opencode",
"json_repair_retries": 2, "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] = { CONFIG_TYPES: dict[str, type] = {
@@ -40,6 +43,9 @@ CONFIG_TYPES: dict[str, type] = {
"timeout_seconds": int, "timeout_seconds": int,
"opencode_command": str, "opencode_command": str,
"json_repair_retries": int, "json_repair_retries": int,
"auto_update_check": bool,
"update_source": str,
"update_version_url": str,
} }
MODEL_CONFIG_KEYS = { MODEL_CONFIG_KEYS = {
@@ -68,6 +74,9 @@ class Config:
timeout_seconds: int = 60 timeout_seconds: int = 60
opencode_command: str = "opencode" opencode_command: str = "opencode"
json_repair_retries: int = 2 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: def load_config() -> Config:
@@ -85,6 +94,15 @@ def load_config() -> Config:
) )
opencode_command = os.getenv("NLPROG_OPENCODE_COMMAND", raw.get("opencode_command", "opencode")) 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))) 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( return Config(
provider=provider, provider=provider,
@@ -98,6 +116,9 @@ def load_config() -> Config:
timeout_seconds=int(raw.get("timeout_seconds", 60)), timeout_seconds=int(raw.get("timeout_seconds", 60)),
opencode_command=opencode_command, opencode_command=opencode_command,
json_repair_retries=json_repair_retries, json_repair_retries=json_repair_retries,
auto_update_check=auto_update_check,
update_source=update_source,
update_version_url=update_version_url,
) )
+52
View File
@@ -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)
+18
View File
@@ -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()