feat(pluginmgr): 插件更新接口(upgrade/downgrade 保留配置)+ skill_install overwrite

内核 Registry 拆出 StopAndUnload:
- 停止并从注册表移除插件但保留 config_<name> 表
- 不触发 onRemove 回调(那是删除专用语义)
- RemovePlugin 改为追加清理配置表示清除,更新场景调 StopAndUnload

pluginmgr:
- installFromData/installFromURL/installFromPath 加 overwrite 参数
- 已存在+overwrite=true:StopAndUnload→备份旧目录→解压新包→失败回滚→
  返回 action=upgraded/downgraded/reinstalled+previous_version+config_kept
- 已存在+overwrite=false:返回 error+hint(指向 overwrite 用法)
- cmpVersion 点分版本号数字比较(非字典序)
- 测试覆盖:首次安装→重装拒绝→升级保留配置→降级→失败回滚

skill_install 加 overwrite 参数:
- 同名技能存在时先卸载旧实例+删除目录再安装新包

SDK PluginMgr 接口同步加 StopAndUnload(name string) error

工具链 plugindev 已重建到 /usr/local/bin(7/29→8/25 版本)
QQ 插件诊断日志版(webhook recv 到达+isAtBot 失败日志)已打包并
通过 upgrade 接口热更新部署,配置保留验证通过。
This commit is contained in:
JianFeeeee
2026-08-25 22:02:17 +08:00
parent 5f126d4d10
commit ae42e486de
17 changed files with 3752 additions and 28 deletions

View File

@ -14,6 +14,7 @@ import (
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"time"
@ -137,7 +138,7 @@ func (p *Plugin) Stop() error {
func (p *Plugin) registerTools(s *sdk.PluginSDK) {
s.RegisterTool("plugin_install", sdk.ToolDef{
Name: "plugin_install",
Description: "从 URL 安装 HomeAgent 插件包(.hmap 文件)。安装后需调用 plgreload 或重启生效。",
Description: "从 URL 安装 HomeAgent 插件包(.hmap 文件)。插件已存在时传 overwrite=true 原地更新(升级/降级/重装,保留配置表,无需卸载重装)。更新后需调用 plgreload 或重启生效。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
@ -145,6 +146,10 @@ func (p *Plugin) registerTools(s *sdk.PluginSDK) {
"type": "string",
"description": "插件包的下载 URL",
},
"overwrite": map[string]interface{}{
"type": "boolean",
"description": "已存在时原地更新(保留配置)。默认 false",
},
},
"required": []string{"url"},
},
@ -153,7 +158,8 @@ func (p *Plugin) registerTools(s *sdk.PluginSDK) {
if url == "" {
return map[string]interface{}{"error": "url is required"}, nil
}
return p.installFromURL(url)
overwrite, _ := args["overwrite"].(bool)
return p.installFromURL(url, overwrite)
})
s.RegisterTool("plugin_list", sdk.ToolDef{
@ -247,8 +253,9 @@ func (p *Plugin) handlePlugins(w http.ResponseWriter, r *http.Request) {
ct := r.Header.Get("Content-Type")
if strings.HasPrefix(ct, "application/json") {
var body struct {
URL string `json:"url"`
Path string `json:"path"`
URL string `json:"url"`
Path string `json:"path"`
Overwrite bool `json:"overwrite"` // 已存在时原地更新(保留配置)
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
@ -256,14 +263,14 @@ func (p *Plugin) handlePlugins(w http.ResponseWriter, r *http.Request) {
}
switch {
case body.URL != "":
result, err := p.installFromURL(body.URL)
result, err := p.installFromURL(body.URL, body.Overwrite)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, result)
case body.Path != "":
result, err := p.installFromPath(body.Path)
result, err := p.installFromPath(body.Path, body.Overwrite)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{"error": err.Error()})
return
@ -279,7 +286,7 @@ func (p *Plugin) handlePlugins(w http.ResponseWriter, r *http.Request) {
http.Error(w, "read body: "+err.Error(), http.StatusBadRequest)
return
}
result, err := p.installFromData(data)
result, err := p.installFromData(data, false)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{"error": err.Error()})
return
@ -333,15 +340,15 @@ func (p *Plugin) handlePluginByID(w http.ResponseWriter, r *http.Request) {
// ======== Core Logic ========
func (p *Plugin) installFromPath(path string) (interface{}, error) {
func (p *Plugin) installFromPath(path string, overwrite bool) (interface{}, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read file: %w", err)
}
return p.installFromData(data)
return p.installFromData(data, overwrite)
}
func (p *Plugin) installFromURL(rawURL string) (interface{}, error) {
func (p *Plugin) installFromURL(rawURL string, overwrite bool) (interface{}, error) {
log.Printf("[pluginmgr] downloading: %s", rawURL)
parsed, err := url.Parse(rawURL)
@ -367,7 +374,7 @@ func (p *Plugin) installFromURL(rawURL string) (interface{}, error) {
return nil, fmt.Errorf("read response: %w", err)
}
result, err := p.installFromData(data)
result, err := p.installFromData(data, overwrite)
if err != nil {
return nil, err
}
@ -379,7 +386,10 @@ func (p *Plugin) installFromURL(rawURL string) (interface{}, error) {
return result, nil
}
func (p *Plugin) installFromData(data []byte) (interface{}, error) {
// installFromData 安装(或 overwrite=true 时原地更新)插件包。
// 更新语义StopAndUnload 停止旧实例但保留配置表,备份旧目录→解压新包→失败回滚;
// 更新后配置原样生效,无需用户手动卸载重装。
func (p *Plugin) installFromData(data []byte, overwrite bool) (interface{}, error) {
pkg, err := validatePackage(data)
if err != nil {
return map[string]interface{}{
@ -394,12 +404,78 @@ func (p *Plugin) installFromData(data []byte) (interface{}, error) {
}
target := filepath.Join(dir, pkg.Name)
if _, err := os.Stat(target); err == nil {
var oldVersion string
existing := false
if m, err := plugin.ReadManifest(target); err == nil && m != nil {
existing = true
oldVersion = m.Version
} else if _, statErr := os.Stat(target); statErr == nil {
existing = true // 目录存在但 manifest 不可读:视为已安装、版本未知
}
if existing && !overwrite {
return map[string]interface{}{
"error": "plugin already exists",
"name": pkg.Name,
"version": pkg.Version,
"action": "remove_first",
"error": "plugin already exists",
"name": pkg.Name,
"version": pkg.Version,
"current": oldVersion,
"action": "remove_first",
"hint": `传 "overwrite": true 可原地更新(保留配置)`,
}, nil
}
if existing && overwrite {
// 原地更新:停旧实例(保留配置表),备份旧目录,解压新包,失败回滚。
if p.sdk != nil && p.sdk.PluginMgr() != nil {
if err := p.sdk.PluginMgr().StopAndUnload(pkg.Name); err != nil {
log.Printf("[pluginmgr] StopAndUnload %s: %v", pkg.Name, err)
}
}
backup := target + ".bak"
os.RemoveAll(backup)
if err := os.Rename(target, backup); err != nil {
return map[string]interface{}{
"error": "backup old plugin dir failed",
"details": err.Error(),
}, nil
}
if err := extractPackage(data, dir); err != nil {
// 回滚:恢复旧目录并重新加载旧版
os.RemoveAll(target)
if rbErr := os.Rename(backup, target); rbErr != nil {
return map[string]interface{}{
"error": "extract failed AND rollback failed",
"details": err.Error(),
"rollback": rbErr.Error(),
}, nil
}
if p.sdk != nil && p.sdk.PluginMgr() != nil {
_ = p.sdk.PluginMgr().ReloadOne(pkg.Name)
}
return map[string]interface{}{
"error": "extract failed (rolled back to " + oldVersion + ")",
"details": err.Error(),
}, nil
}
os.RemoveAll(backup)
checksum := fmt.Sprintf("%x", sha256.Sum256(data))
action := "upgraded"
if cmpVersion(pkg.Version, oldVersion) < 0 {
action = "downgraded"
} else if cmpVersion(pkg.Version, oldVersion) == 0 {
action = "reinstalled"
}
return map[string]interface{}{
"status": "installed",
"name": pkg.Name,
"version": pkg.Version,
"previous_version": oldVersion,
"entry": pkg.Entry,
"checksum": checksum,
"action": action,
"reload_required": true,
"config_kept": true,
}, nil
}
@ -422,6 +498,36 @@ func (p *Plugin) installFromData(data []byte) (interface{}, error) {
}, nil
}
// cmpVersion 比较点分版本号a<b 返回 -1a>b 返回 1相等返回 0。
// 非数字段按字符串比较;长度不齐缺段视作 0。
func cmpVersion(a, b string) int {
parse := func(s string) []int {
parts := strings.SplitN(strings.TrimPrefix(strings.TrimSpace(s), "v"), ".", 4)
out := make([]int, 0, len(parts))
for _, p := range parts {
n, err := strconv.Atoi(strings.TrimSpace(p))
if err != nil {
n = 0
}
out = append(out, n)
}
for len(out) < 3 {
out = append(out, 0)
}
return out
}
a1, b1 := parse(a), parse(b)
for i := range a1 {
if a1[i] < b1[i] {
return -1
}
if a1[i] > b1[i] {
return 1
}
}
return 0
}
func (p *Plugin) listPlugins() (interface{}, error) {
dir := p.pluginDir
if dir == "" {