wutongyuonce f17ca3777a
feat(extensions): add in-place upgrade that keeps private config (#5347)
* feat(extensions): add in-place upgrade that keeps private config

Replace a managed local snapshot or re-pin an already-installed
requirement without going through remove, which dropped plugins[].config.

* fix(extensions): keep snapshot, enabled, and git re-pin on upgrade

Rollback keys off staging_root so a failed snapshot rename cannot
rmtree the live tree. Upgrade preserves plugins[].enabled. Re-pin
identification uses tool.uv.sources so git upgrades adopt the
existing plugin record instead of failing closed after uv already
switched the revision.

* test(extensions): cover requirement re-pin identification on upgrade

Re-pinning deerflow-extension-demo==2.0.0 to ==3.0.0 leaves added_names
empty, so identification must take the added_specs fallback. Assert
private config/required/enabled survive and the lock records 3.0.0.

* fix(extensions): reject upgrade of an uninstalled git source

Bare git+ URLs are not named Requirements, so the pre-uv-add installed
check was skipped and upgrade acted as install. Resolve them against
[tool.uv.sources] in the extensions group before uv add.
2026-09-12 15:59:41 +08:00

154 lines
6.9 KiB
Python

"""Command-line interface for installing and managing trusted extensions."""
from __future__ import annotations
import argparse
import os
import subprocess
import sys
from collections.abc import Sequence
from pathlib import Path
from deerflow.extensions.manager import ExtensionManager
_NAME_ENV = "DEER_FLOW_EXTENSION_NAME"
_SOURCE_ENV = "DEER_FLOW_EXTENSION_SOURCE"
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="deerflow extensions",
description="Install and manage trusted Python extensions for this DeerFlow checkout.",
)
commands = parser.add_subparsers(dest="command", required=True)
install = commands.add_parser("install", help="install an extension and enable it in config.yaml")
install.add_argument("--source-env", action="store_true", help=argparse.SUPPRESS)
install.add_argument("source", help="local directory, Python package requirement, or Git URL")
install.add_argument(
"--yes",
action="store_true",
help="acknowledge that installing an extension executes trusted third-party code",
)
install.add_argument(
"--required",
action="store_true",
help="abort Gateway startup when this extension fails to load (default: report and skip)",
)
upgrade = commands.add_parser(
"upgrade",
help="replace an installed extension source and keep its private config",
)
upgrade.add_argument("--source-env", action="store_true", help=argparse.SUPPRESS)
upgrade.add_argument("source", help="local directory, Python package requirement, or Git URL")
upgrade.add_argument(
"--yes",
action="store_true",
help="acknowledge that upgrading an extension executes trusted third-party code",
)
disable = commands.add_parser("disable", help="disable an extension without uninstalling it")
disable.add_argument("--name-env", action="store_true", help=argparse.SUPPRESS)
disable.add_argument("name", help="extension name, distribution, or module:install entry point")
enable = commands.add_parser("enable", help="enable an installed extension")
enable.add_argument("--name-env", action="store_true", help=argparse.SUPPRESS)
enable.add_argument("name", help="extension name, distribution, or module:install entry point")
commands.add_parser("list", help="list configured extensions")
remove = commands.add_parser("remove", help="uninstall an extension and remove its config entry")
remove.add_argument("--name-env", action="store_true", help=argparse.SUPPRESS)
remove.add_argument("name", help="extension name, distribution, or module:install entry point")
return parser
def main(argv: Sequence[str] | None = None) -> int:
args = build_parser().parse_args(list(argv) if argv is not None else None)
try:
root = find_project_root()
configured_path = os.environ.get("DEER_FLOW_CONFIG_PATH")
manager = ExtensionManager(root, config_path=configured_path)
if args.command == "install":
source = _source_argument(args)
trusted = args.yes
if not trusted:
print("Warning: a Python extension executes code with Gateway privileges.")
try:
trusted = input("Install this trusted source? [y/N] ").strip().lower() in {"y", "yes"}
except EOFError:
trusted = False
if not trusted:
print("Extension installation cancelled.", file=sys.stderr)
return 2
installed = manager.install(source, yes=trusted, required=args.required)
print(f"Installed and enabled {installed.name} ({installed.distribution}). Restart DeerFlow to load it.")
return 0
if args.command == "upgrade":
source = _source_argument(args)
trusted = args.yes
if not trusted:
print("Warning: a Python extension executes code with Gateway privileges.")
try:
trusted = input("Upgrade this trusted source? [y/N] ").strip().lower() in {"y", "yes"}
except EOFError:
trusted = False
if not trusted:
print("Extension upgrade cancelled.", file=sys.stderr)
return 2
installed = manager.upgrade(source, yes=trusted)
print(f"Upgraded {installed.name} ({installed.distribution}). Restart DeerFlow to load it.")
return 0
if args.command == "disable":
name = manager.set_enabled(_name_argument(args), enabled=False)
print(f"Disabled {name}. Restart DeerFlow to apply the change.")
return 0
if args.command == "enable":
name = manager.set_enabled(_name_argument(args), enabled=True)
print(f"Enabled {name}. Restart DeerFlow to apply the change.")
return 0
if args.command == "list":
configured = manager.list_configured()
print("NAME\tSTATE\tPACKAGE\tENTRY POINT")
for extension in configured:
state = "enabled" if extension.enabled else "disabled"
print(f"{extension.name}\t{state}\t{extension.distribution}\t{extension.use}")
return 0
if args.command == "remove":
name = manager.remove(_name_argument(args))
print(f"Removed {name}. Restart DeerFlow to apply the change.")
return 0
except (OSError, RuntimeError, ValueError, subprocess.CalledProcessError) as exc:
print(f"extension command failed: {exc}", file=sys.stderr)
return 1
raise AssertionError(f"unhandled extension command: {args.command}")
def _name_argument(args: argparse.Namespace) -> str:
if not args.name_env:
return args.name
name = os.environ.get(_NAME_ENV)
if name is None or not name.strip():
raise ValueError(f"{_NAME_ENV} must contain an extension name")
return name
def _source_argument(args: argparse.Namespace) -> str:
if not args.source_env:
return args.source
source = os.environ.get(_SOURCE_ENV)
if source is None or not source.strip():
raise ValueError(f"{_SOURCE_ENV} must contain an extension source")
return source
def find_project_root() -> Path:
configured = os.environ.get("DEER_FLOW_PROJECT_ROOT")
if configured:
candidate = Path(configured).expanduser().resolve()
if (candidate / "backend" / "pyproject.toml").is_file():
return candidate
raise FileNotFoundError(f"DEER_FLOW_PROJECT_ROOT is not a DeerFlow checkout: {candidate}")
candidates = (Path.cwd(), *Path.cwd().parents)
for candidate in candidates:
candidate = candidate.resolve()
if (candidate / "backend" / "pyproject.toml").is_file():
return candidate
raise FileNotFoundError("could not find a DeerFlow checkout; set DEER_FLOW_PROJECT_ROOT")