fix(plugin_store): 修复插件商店的安装与卸载逻辑 (#2050)

* fix(plugin_store): 修复插件商店的安装与卸载逻辑

- 优化了插件安装、更新和移除的逻辑
- 调整了插件路径的处理方式,支持更灵活的安装位置
- 重构了 `install_plugin_with_repo` 方法,使用 `StorePluginInfo` 对象作为参数
- 修复了一些潜在的路径问题和模块命名问题

* refactor(zhenxun): 优化插件信息获取逻辑

- 将 PluginInfo.get_or_none 替换为 get_plugin 方法,简化插件信息获取逻辑
- 优化了插件移除操作中的插件信息获取流程

* refactor(zhenxun): 优化 sparse_checkout_clone 函数的实现

- 将 git 操作移至临时目录中执行,避免影响目标目录中的现有内容
- 简化了稀疏检出的配置和执行过程
- 改进了错误处理和回退逻辑
- 优化了文件移动和目录清理的操作

* 🐛 添加移除插件时二次查询

*  plugin_info.get_plugin参数包含plugin_type时无效过滤

---------

Co-authored-by: HibiKier <45528451+HibiKier@users.noreply.github.com>
This commit is contained in:
molanp 2025-09-12 17:38:24 +08:00 committed by GitHub
parent 631e66d54f
commit ee699fb345
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 95 additions and 81 deletions

View File

@ -1,3 +1,4 @@
import os
from pathlib import Path from pathlib import Path
import random import random
import shutil import shutil
@ -10,6 +11,7 @@ from zhenxun.configs.path_config import TEMP_PATH
from zhenxun.models.plugin_info import PluginInfo from zhenxun.models.plugin_info import PluginInfo
from zhenxun.services.log import logger from zhenxun.services.log import logger
from zhenxun.services.plugin_init import PluginInitManager from zhenxun.services.plugin_init import PluginInitManager
from zhenxun.utils.enum import PluginType
from zhenxun.utils.image_utils import BuildImage, ImageTemplate, RowStyle from zhenxun.utils.image_utils import BuildImage, ImageTemplate, RowStyle
from zhenxun.utils.manager.virtual_env_package_manager import VirtualEnvPackageManager from zhenxun.utils.manager.virtual_env_package_manager import VirtualEnvPackageManager
from zhenxun.utils.repo_utils import RepoFileManager from zhenxun.utils.repo_utils import RepoFileManager
@ -183,6 +185,8 @@ class StoreManager:
StorePluginInfo: 插件信息 StorePluginInfo: 插件信息
bool: 是否是外部插件 bool: 是否是外部插件
""" """
plugin_list: list[StorePluginInfo]
extra_plugin_list: list[StorePluginInfo]
plugin_list, extra_plugin_list = await cls.get_data() plugin_list, extra_plugin_list = await cls.get_data()
plugin_info = None plugin_info = None
is_external = False is_external = False
@ -206,6 +210,12 @@ class StoreManager:
if is_remove: if is_remove:
if plugin_info.module not in modules: if plugin_info.module not in modules:
raise PluginStoreException(f"插件 {plugin_info.name} 未安装,无法移除") raise PluginStoreException(f"插件 {plugin_info.name} 未安装,无法移除")
if plugin_obj := await PluginInfo.get_plugin(
module=plugin_info.module, plugin_type=PluginType.PARENT
):
plugin_info.module_path = plugin_obj.module_path
elif plugin_obj := await PluginInfo.get_plugin(module=plugin_info.module):
plugin_info.module_path = plugin_obj.module_path
return plugin_info, is_external return plugin_info, is_external
if is_update: if is_update:
@ -237,9 +247,7 @@ class StoreManager:
plugin_info.github_url = f"{github_url_split[0]}/tree/{version_split[1]}" plugin_info.github_url = f"{github_url_split[0]}/tree/{version_split[1]}"
logger.info(f"正在安装插件 {plugin_info.name}...", LOG_COMMAND) logger.info(f"正在安装插件 {plugin_info.name}...", LOG_COMMAND)
await cls.install_plugin_with_repo( await cls.install_plugin_with_repo(
plugin_info.github_url, plugin_info,
plugin_info.module_path,
plugin_info.is_dir,
is_external, is_external,
source, source,
) )
@ -248,9 +256,7 @@ class StoreManager:
@classmethod @classmethod
async def install_plugin_with_repo( async def install_plugin_with_repo(
cls, cls,
github_url: str, plugin_info: StorePluginInfo,
module_path: str,
is_dir: bool,
is_external: bool = False, is_external: bool = False,
source: str | None = None, source: str | None = None,
): ):
@ -267,18 +273,26 @@ class StoreManager:
repo_type = RepoType.ALIYUN repo_type = RepoType.ALIYUN
elif source == "git": elif source == "git":
repo_type = RepoType.GITHUB repo_type = RepoType.GITHUB
replace_module_path = module_path.replace(".", "/") module_path = plugin_info.module_path
plugin_name = module_path.split(".")[-1] is_dir = plugin_info.is_dir
github_url = plugin_info.github_url
assert github_url
replace_module_path = module_path.replace(".", "/").lstrip("/")
plugin_name = module_path.split(".")[-1] or plugin_info.module
if is_dir: if is_dir:
files = await RepoFileManager.list_directory_files( files = await RepoFileManager.list_directory_files(
github_url, replace_module_path, repo_type=repo_type github_url, replace_module_path, repo_type=repo_type
) )
else: else:
files = [RepoFileInfo(path=f"{replace_module_path}.py", is_dir=False)] files = [RepoFileInfo(path=f"{replace_module_path}.py", is_dir=False)]
local_path = BASE_PATH / "plugins" if is_external else BASE_PATH if not is_external:
target_dir = BASE_PATH / "plugins" / plugin_name target_dir = BASE_PATH
elif is_dir:
target_dir = BASE_PATH / "plugins" / plugin_name
else:
target_dir = BASE_PATH / "plugins"
files = [file for file in files if not file.is_dir] files = [file for file in files if not file.is_dir]
download_files = [(file.path, local_path / file.path) for file in files] download_files = [(file.path, target_dir / file.path) for file in files]
result = await RepoFileManager.download_files( result = await RepoFileManager.download_files(
github_url, github_url,
download_files, download_files,
@ -298,7 +312,7 @@ class StoreManager:
is_install_req = False is_install_req = False
for requirement_path in requirement_paths: for requirement_path in requirement_paths:
requirement_file = local_path / requirement_path.path requirement_file = target_dir / requirement_path.path
if requirement_file.exists(): if requirement_file.exists():
is_install_req = True is_install_req = True
await VirtualEnvPackageManager.install_requirement(requirement_file) await VirtualEnvPackageManager.install_requirement(requirement_file)
@ -341,13 +355,11 @@ class StoreManager:
str: 返回消息 str: 返回消息
""" """
plugin_info, _ = await cls.get_plugin_by_value(index_or_module, is_remove=True) plugin_info, _ = await cls.get_plugin_by_value(index_or_module, is_remove=True)
path = BASE_PATH module_path = plugin_info.module_path
if plugin_info.github_url: module = module_path.split(".")[-1]
path = BASE_PATH / "plugins" path = BASE_PATH.parent / Path(module_path.replace(".", os.sep))
for p in plugin_info.module_path.split("."):
path = path / p
if not plugin_info.is_dir: if not plugin_info.is_dir:
path = Path(f"{path}.py") path = path.parent / f"{module}.py"
if not path.exists(): if not path.exists():
return f"插件 {plugin_info.name} 不存在..." return f"插件 {plugin_info.name} 不存在..."
logger.debug(f"尝试移除插件 {plugin_info.name} 文件: {path}", LOG_COMMAND) logger.debug(f"尝试移除插件 {plugin_info.name} 文件: {path}", LOG_COMMAND)
@ -356,7 +368,7 @@ class StoreManager:
shutil.rmtree(path, onerror=win_on_rm_error) shutil.rmtree(path, onerror=win_on_rm_error)
else: else:
path.unlink() path.unlink()
await PluginInitManager.remove(f"zhenxun.{plugin_info.module_path}") await PluginInitManager.remove(module_path)
return f"插件 {plugin_info.name} 移除成功! 重启后生效" return f"插件 {plugin_info.name} 移除成功! 重启后生效"
@classmethod @classmethod
@ -423,9 +435,7 @@ class StoreManager:
if plugin_info.github_url is None: if plugin_info.github_url is None:
plugin_info.github_url = DEFAULT_GITHUB_URL plugin_info.github_url = DEFAULT_GITHUB_URL
await cls.install_plugin_with_repo( await cls.install_plugin_with_repo(
plugin_info.github_url, plugin_info,
plugin_info.module_path,
plugin_info.is_dir,
is_external, is_external,
) )
return f"插件 {plugin_info.name} 更新成功! 重启后生效" return f"插件 {plugin_info.name} 更新成功! 重启后生效"
@ -473,9 +483,7 @@ class StoreManager:
plugin_info.github_url = DEFAULT_GITHUB_URL plugin_info.github_url = DEFAULT_GITHUB_URL
is_external = False is_external = False
await cls.install_plugin_with_repo( await cls.install_plugin_with_repo(
plugin_info.github_url, plugin_info,
plugin_info.module_path,
plugin_info.is_dir,
is_external, is_external,
) )
update_success_list.append(plugin_info.name) update_success_list.append(plugin_info.name)

View File

@ -77,7 +77,7 @@ class PluginInfo(Model):
返回: 返回:
Self | None: 插件 Self | None: 插件
""" """
if filter_parent: if not kwargs.get("plugin_type") and filter_parent:
return await cls.get_or_none( return await cls.get_or_none(
load_status=load_status, plugin_type__not=PluginType.PARENT, **kwargs load_status=load_status, plugin_type__not=PluginType.PARENT, **kwargs
) )
@ -96,7 +96,7 @@ class PluginInfo(Model):
返回: 返回:
list[Self]: 插件列表 list[Self]: 插件列表
""" """
if filter_parent: if not kwargs.get("plugin_type") and filter_parent:
return await cls.filter( return await cls.filter(
load_status=load_status, plugin_type__not=PluginType.PARENT, **kwargs load_status=load_status, plugin_type__not=PluginType.PARENT, **kwargs
).all() ).all()

View File

@ -87,7 +87,7 @@ class PluginInitManager:
@classmethod @classmethod
async def remove(cls, module_path: str): async def remove(cls, module_path: str):
"""运行指定插件安装方法""" """运行指定插件移除方法"""
if model := cls.plugins.get(module_path): if model := cls.plugins.get(module_path):
if model.remove: if model.remove:
class_ = model.class_() class_ = model.class_()

View File

@ -326,7 +326,7 @@ class RepoFileManager:
# 获取仓库树信息 # 获取仓库树信息
strategy = GitHubStrategy() strategy = GitHubStrategy()
strategy.body = await GitHubStrategy.parse_repo_info(repo_info) strategy.body = await strategy.parse_repo_info(repo_info)
# 处理目录路径,确保格式正确 # 处理目录路径,确保格式正确
if directory_path and not directory_path.endswith("/") and recursive: if directory_path and not directory_path.endswith("/") and recursive:
@ -480,7 +480,7 @@ class RepoFileManager:
target_dir: Path | None = None, target_dir: Path | None = None,
) -> FileDownloadResult: ) -> FileDownloadResult:
""" """
下载个文件 下载个文件
参数: 参数:
repo_url: 仓库URL repo_url: 仓库URL

View File

@ -7,6 +7,7 @@ import base64
from pathlib import Path from pathlib import Path
import re import re
import shutil import shutil
import tempfile
from zhenxun.services.log import logger from zhenxun.services.log import logger
@ -145,80 +146,85 @@ async def sparse_checkout_clone(
target_dir: Path, target_dir: Path,
) -> None: ) -> None:
""" """
使用 git 稀疏检出克隆指定路径到目标目录完全独立于主项目 git 使用 git 稀疏检出克隆指定路径到目标目录在临时目录中操作
关键保障: 关键保障:
- target_dir 下检测/初始化 .git所有 git 操作均以 cwd=target_dir 执行 - 在临时目录中执行所有 git 操作避免影响 target_dir 中的现有内容
- 强制拉取与工作区覆盖: fetch --forcecheckout -Breset --hardclean -xdf - 只操作 target_dir/sparse_path 路径不影响 target_dir 其他内容
- 反复设置 sparse-checkout 路径确保路径更新生效
""" """
target_dir.mkdir(parents=True, exist_ok=True) target_dir.mkdir(parents=True, exist_ok=True)
if not await check_git(): if not await check_git():
raise GitUnavailableError() raise GitUnavailableError()
git_dir = target_dir / ".git" # 在临时目录中进行 git 操作
if not git_dir.exists(): with tempfile.TemporaryDirectory() as temp_dir:
success, out, err = await run_git_command("init", target_dir) temp_path = Path(temp_dir)
# 初始化临时目录为 git 仓库
success, out, err = await run_git_command("init", temp_path)
if not success: if not success:
raise RuntimeError(f"git init 失败: {err or out}") raise RuntimeError(f"git init 失败: {err or out}")
success, out, err = await run_git_command( success, out, err = await run_git_command(
f"remote add origin {repo_url}", target_dir f"remote add origin {repo_url}", temp_path
) )
if not success: if not success:
raise RuntimeError(f"添加远程失败: {err or out}") raise RuntimeError(f"添加远程失败: {err or out}")
else:
# 启用稀疏检出(使用 --no-cone 模式以获得更精确的控制)
await run_git_command("config core.sparseCheckout true", temp_path)
await run_git_command("sparse-checkout init --no-cone", temp_path)
# 设置需要检出的路径(每次都覆盖配置)
if not sparse_path:
raise RuntimeError("sparse-checkout 路径不能为空")
# 使用 --no-cone 模式,直接指定要检出的具体路径
success, out, err = await run_git_command( success, out, err = await run_git_command(
f"remote set-url origin {repo_url}", target_dir f"sparse-checkout set {sparse_path}/", temp_path
) )
if not success: if not success:
# 兜底尝试添加 raise RuntimeError(f"配置稀疏路径失败: {err or out}")
await run_git_command(f"remote add origin {repo_url}", target_dir)
# 启用稀疏检出(使用 --no-cone 模式以获得更精确的控制) # 强制拉取并同步到远端
await run_git_command("config core.sparseCheckout true", target_dir) success, out, err = await run_git_command(
await run_git_command("sparse-checkout init --no-cone", target_dir) f"fetch --force --depth 1 origin {branch}", temp_path
)
if not success:
raise RuntimeError(f"fetch 失败: {err or out}")
# 设置需要检出的路径(每次都覆盖配置) # 使用远端强制更新本地分支并覆盖工作区
if not sparse_path: success, out, err = await run_git_command(
raise RuntimeError("sparse-checkout 路径不能为空") f"checkout -B {branch} origin/{branch}", temp_path
)
if not success:
# 回退方案
success2, out2, err2 = await run_git_command(
f"checkout {branch}", temp_path
)
if not success2:
raise RuntimeError(f"checkout 失败: {(err or out) or (err2 or out2)}")
# 使用 --no-cone 模式,直接指定要检出的具体路径 # 强制对齐工作区
# 例如sparse_path="plugins/mahiro" -> 只检出 plugins/mahiro/ 下的内容 await run_git_command(f"reset --hard origin/{branch}", temp_path)
success, out, err = await run_git_command( await run_git_command("clean -xdf", temp_path)
f"sparse-checkout set {sparse_path}/", target_dir
)
if not success:
raise RuntimeError(f"配置稀疏路径失败: {err or out}")
# 强制拉取并同步到远端 # 将检出的文件移动到目标位置
success, out, err = await run_git_command( source_path = temp_path / sparse_path
f"fetch --force --depth 1 origin {branch}", target_dir if source_path.exists():
) # 确保目标路径存在
if not success: target_path = target_dir / sparse_path
raise RuntimeError(f"fetch 失败: {err or out}") target_path.parent.mkdir(parents=True, exist_ok=True)
# 使用远端强制更新本地分支并覆盖工作区 # 如果目标路径已存在,先清理
success, out, err = await run_git_command( if target_path.exists():
f"checkout -B {branch} origin/{branch}", target_dir if target_path.is_dir():
) shutil.rmtree(target_path)
if not success: else:
# 回退方案 target_path.unlink()
success2, out2, err2 = await run_git_command(f"checkout {branch}", target_dir)
if not success2:
raise RuntimeError(f"checkout 失败: {(err or out) or (err2 or out2)}")
# 强制对齐工作区 # 移动整个目录结构到目标位置
await run_git_command(f"reset --hard origin/{branch}", target_dir) shutil.move(str(source_path), str(target_path))
await run_git_command("clean -xdf", target_dir)
dir_path = target_dir / Path(sparse_path)
for f in dir_path.iterdir():
shutil.move(f, target_dir / f.name)
dir_name = sparse_path.split("/")[0]
rm_path = target_dir / dir_name
if rm_path.exists():
shutil.rmtree(rm_path)
def prepare_aliyun_url(repo_url: str) -> str: def prepare_aliyun_url(repo_url: str) -> str: