Add automatic update checks
This commit is contained in:
@@ -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 已经登录:
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "0.1.1"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user