Files
HomeAgent/internal/update/plugin.go
JianFeeeee 475aac867b refactor(update): 内核与插件更新合并为 update 子系统(三端共享一个底层)
## 为什么合并(用户口径:更新部分应独立为子系统)

之前 kernelupgrade / pluginupdate 两个包,但它们共享三样东西,
分开写就要各复制一份,然后慢慢不一致:

① **版本比较** —— 合并前共 6 个函数做同一件事:
   kernel 侧 ParseSemver / CmpSemver / versionNewer / trimTagV
   plugin 侧 trimV / isNewer / semverNums(还自带一份正则)
   且**行为不一致**:kernel 侧只挡 len<2,"1.4" 漏过去被填成 1.4.0;
   plugin 侧的正则要求两段所以也漏。两份都把 "1.4" 当合法版本。

② **两阶段交互**(检查 → 确认 → 执行)—— 同一套用户口径。

③ **失败要响亮** —— 都不静默降级。

## 收敛结果

internal/update/
  doc.go     子系统头:职责边界、三端共享契约、为何不分家
  semver.go  ParseSemver / CmpSemver / Newer / TrimTag(**唯一一份**)
  kernel.go  内核升级闭环(解包→校验→备份→替换→重启→复核→回滚)
  release.go GitHub Release 查询
  plugin.go  插件升级(查仓库→拉源码→hmapdev 编译→产物)

三端共享:
  agent 工具(plugin_update / kernel_update)  ┐
  WebAPI(/plugins/update、/kernel/update)    ├─ internal/update
  CLI(homed-upgrade)                         ┘

入口层只做参数解析与结果呈现,不含业务逻辑 —— 否则会出现
"agent 说没有新版、WebUI 却升了级"的分裂状态。

## 合并时抓到的两个真实 bug

① **ParseSemver 只挡 len<2** ⇒ "1.4" 被零值填成 1.4.0,
   「1.4 → 1.4.0」被判完全相同(说"无需升级"),而两者内容未必相同。
   现在要求恰好三段。

② **不认 v 前缀** ⇒ GitHub release tag 全是 v1.4.0 形态,
   CLI 对"当前 v1.4.0 vs 包内 v1.4.0"判不可比、只能退回字符串比较。
   现在 TrimTag 去 v 前缀后再比。

## 新增入口

· plugin_update(agent 工具)+ GET/POST /plugins/update
  —— 两阶段:apply=false 只检查(列出 updatable / no_repo 两类,
     后者是"未声明仓库、无法自动更新"的内置/私有插件);
     apply=true 需显式 plugins=all 或名单(不默认全升)
· kernel_update(agent 工具)+ GET/POST /kernel/update
  —— 两阶段同构;apply 只收本机包路径(URL 下载是 CLI 职责,
     agent 不该在推理中发起长下载)

判据:
· internal/update: Newer / TrimTag / ParseSemver 的边界(含上述两个 bug 的回归)
· pluginmgr 入口层 5 条:检查形状、apply 必须显式给范围、
  kernel apply 必须给 pkg 且指向 homed-upgrade、拒绝目录当包
2026-10-26 01:05:09 +08:00

386 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package pluginupdate 实现**插件**的更新闭环:查仓库最新版 → 拉源码 → 现场编译 → 交给调用方安装。
//
// ## 三个入口共享这一个实现(架构约束)
//
// - agent 工具 plugin_update(pluginmgr 注册)
// - WebAPI(/api/v1/plugins/update)
// - CLI 无(插件更新由 agent/WebUI 驱动,没有独立 CLI 命令)
//
// 三处若各写一份「拉取/比较/编译」逻辑,必然漂移 —— 出现"agent 说没有更新、
// WebUI 却升了级"这类互相矛盾的状态。所以这里只提供**唯一**的两个动作,
// 入口层只做参数解析与结果呈现。
//
// ## 数据流
//
// plugin.json 的 repository 字段(hmapdev 从 plg.json 的 repo 透传)
// → Check:git ls-remote / GitHub API 比较版本
// → Upgrade:git clone → hmapdev build → 产物 .hmap
// → 调用方用 plugin_install(path, overwrite=true) 安装
//
// ## 为什么编译产物不直接装
//
// 安装路径(配置保留、运行态停止、版本比对)是 pluginmgr 已有且经过
// 生产验证的逻辑。这里**只负责到 .hmap 产出为止**,安装由调用方走
// installFromPath —— 否则要在这里重新实现一遍升级语义,两份必然漂移。
package update
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
)
// UpdateInfo 是一次「检查更新」的结果。
type UpdateInfo struct {
// Name 是插件名(来自本地 plugin.json)。
Name string `json:"name"`
// Current 是本地版本(来自本地 plugin.json)。
Current string `json:"current"`
// Latest 是远端最新版本(来自远端 plg.json)。
Latest string `json:"latest"`
// Repo 是插件声明的源码仓库(本地 plugin.json 的 repository)。
Repo string `json:"repo"`
// HasUpdate 表示远端版本是否比本地新。
HasUpdate bool `json:"has_update"`
// Reason 是"为什么没有更新可用"的说明(未声明仓库/仓库不可达等)。
// 有更新时为空。
Reason string `json:"reason,omitempty"`
}
// RepoFromManifest 读本地 plugin.json 的 repository 字段。
//
// 返回空串表示该插件未声明仓库 —— 调用方应把它如实呈现
// ("该插件未声明仓库"),而不是当作"没有更新"混淆。
func RepoFromManifest(pluginDir string) string {
data, err := os.ReadFile(filepath.Join(pluginDir, "plugin.json"))
if err != nil {
return ""
}
var m struct {
Repository string `json:"repository"`
}
if json.Unmarshal(data, &m) != nil {
return ""
}
return strings.TrimSpace(m.Repository)
}
// VersionFromManifest 读本地 plugin.json 的 version 字段。
func VersionFromManifest(pluginDir string) string {
data, err := os.ReadFile(filepath.Join(pluginDir, "plugin.json"))
if err != nil {
return ""
}
var m struct {
Version string `json:"version"`
}
if json.Unmarshal(data, &m) != nil {
return ""
}
return strings.TrimSpace(m.Version)
}
// NameFromManifest 读本地 plugin.json 的 name 字段。
func NameFromManifest(pluginDir string) string {
data, err := os.ReadFile(filepath.Join(pluginDir, "plugin.json"))
if err != nil {
return ""
}
var m struct {
Name string `json:"name"`
}
if json.Unmarshal(data, &m) != nil {
return ""
}
return strings.TrimSpace(m.Name)
}
var tagSemverRe = regexp.MustCompile(`^v?(\d+\.\d+\.\d+)$`)
// Check 从仓库查询最新版本并与本地比较。
//
// 版本来源按可靠性排序:
// 1. GitHub API:仓库最新 release 的 tag(最准确,发版即有)
// 2. git ls-remote 的 tag 列表(任何 git 托管都可用;无 release 时靠它)
// 3. 远端 plg.json 的 version 字段(兜底,覆盖没有打 tag 的仓库)
//
// 三种都失败时返回 Reason 说明(不返回 error —— "查不到"本身是可呈现的结果)。
func Check(pluginDir, repo string) *UpdateInfo {
info := &UpdateInfo{
Name: NameFromManifest(pluginDir),
Current: VersionFromManifest(pluginDir),
Repo: repo,
}
if info.Current == "" {
info.Current = "unknown"
}
if repo == "" {
info.Reason = "未声明仓库(plugin.json 无 repository 字段):无法自动更新"
return info
}
latest, how := fetchLatestVersion(repo)
if latest == "" {
info.Reason = "仓库不可达或没有版本信息:" + how
return info
}
info.Latest = latest
info.HasUpdate = Newer(latest, info.Current)
return info
}
// fetchLatestVersion 返回(最新版本, 来源说明)。
func fetchLatestVersion(repo string) (string, string) {
if owner, name, ok := parseGitHub(repo); ok {
if v := ghLatestRelease(owner, name); v != "" {
return v, "GitHub release"
}
if v := ghLatestTag(owner, name); v != "" {
return v, "GitHub tag"
}
}
// 非 GitHub 或 GitHub 都空:git ls-remote 拿 tag 列表取最大
if v := gitRemoteMaxTag(repo); v != "" {
return v, "git tag"
}
return "", "无可用的版本来源"
}
// parseGitHub 判断是否 GitHub 仓库,返回 (owner, repo)。
func parseGitHub(repo string) (string, string, bool) {
for _, prefix := range []string{
"https://github.com/", "http://github.com/", "git@github.com:", "github.com/",
} {
if strings.HasPrefix(repo, prefix) {
rest := strings.TrimPrefix(repo, prefix)
rest = strings.TrimSuffix(rest, ".git")
rest = strings.TrimSuffix(rest, "/")
parts := strings.Split(rest, "/")
if len(parts) >= 2 && parts[0] != "" && parts[1] != "" {
return parts[0], parts[1], true
}
}
}
return "", "", false
}
var ghHTTPClient = &http.Client{Timeout: 15 * time.Second}
// ghLatestRelease 查 GitHub 最新 release 的 tag。
func ghLatestRelease(owner, name string) string {
url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, name)
var body struct {
TagName string `json:"tag_name"`
}
if getJSON(url, &body) != nil {
return ""
}
return TrimTag(body.TagName)
}
// ghLatestTag 查 GitHub 全部 tag,取最大的 semver。
func ghLatestTag(owner, name string) string {
url := fmt.Sprintf("https://api.github.com/repos/%s/%s/tags?per_page=100", owner, name)
var body []struct {
Name string `json:"name"`
}
if getJSON(url, &body) != nil {
return ""
}
max := ""
for _, t := range body {
if v := TrimTag(t.Name); v != "" {
if Newer(v, max) {
max = v
}
}
}
return max
}
// gitRemoteMaxTag 用 git ls-remote 拿 tag 列表(非 GitHub 也可用)。
func gitRemoteMaxTag(repo string) string {
cmd := exec.Command("git", "ls-remote", "--tags", repo)
out, err := cmd.Output()
if err != nil {
return ""
}
max := ""
for _, line := range strings.Split(string(out), "\n") {
// 形如 "refs/tags/v1.2.3"(跳过 ^{} 解引用行)
i := strings.LastIndex(line, "refs/tags/")
if i < 0 {
continue
}
tag := strings.TrimSuffix(line[i+len("refs/tags/"):], "^{}")
if v := TrimTag(tag); v != "" && Newer(v, max) {
max = v
}
}
return max
}
func getJSON(url string, v interface{}) error {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return err
}
req.Header.Set("Accept", "application/vnd.github+json")
resp, err := ghHTTPClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
io.Copy(io.Discard, resp.Body)
return fmt.Errorf("HTTP %d", resp.StatusCode)
}
return json.NewDecoder(resp.Body).Decode(v)
}
// 版本比较统一走 semver.go 的 Newer / TrimTag。
//
// 合并前这里是另一套私有实现(trimV / isNewer / semverNums + 自己的正则),
// 与 kernel 侧的行为不一致 —— 例如 kernel 侧会把 "1.4" 判不可比,
// 这套却因正则只要求两段而漏过去。三份实现的差异就是 bug 温床。
// UpgradeResult 是一次「升级」的结果。
type UpgradeResult struct {
// HmapPath 是编出来的 .hmap 产物路径(调用方交给 plugin_install)。
HmapPath string `json:"hmap_path"`
// Repo / From / To 记录升级轨迹。
Repo string `json:"repo"`
From string `json:"from"`
To string `json:"to"`
// BuildLog 是 hmapdev build 的输出尾部(失败时诊断用)。
BuildLog string `json:"build_log,omitempty"`
}
// Upgrade 拉最新源码并现场编译,返回 .hmap 产物路径。
//
// 流程:clone(浅)→ 校验远端 plg.json 的版本确实更新 → hmapdev build。
//
// workDir 是工作目录(由调用方给定,插件源码副本落在其下,调试友好且可复用);
// hmapdevBin 是 hmapdev 可执行文件路径。
func Upgrade(repo, workDir, hmapdevBin string) (*UpgradeResult, error) {
if repo == "" {
return nil, fmt.Errorf("未声明仓库,无法升级")
}
if hmapdevBin == "" {
hmapdevBin = "hmapdev"
}
if _, err := exec.LookPath(hmapdevBin); err != nil {
return nil, fmt.Errorf("hmapdev 不可用(%s):现场编译插件需要它", hmapdevBin)
}
name := repoName(repo)
dst := filepath.Join(workDir, name)
from := VersionFromManifest(dst) // 已有副本时读它的版本
// ① 拉取/更新源码(浅克隆足够 —— 我们不要历史)
if err := fetchSource(repo, dst); err != nil {
return nil, fmt.Errorf("拉取源码: %w", err)
}
// ② 远端版本校验:明确告知"已经是最新"而不是白编一场
to := VersionFromManifest(dst)
if to == "" {
return nil, fmt.Errorf("远端仓库的 plg.json 缺 version 字段,无法判定版本")
}
if !Newer(to, from) && from != "" {
return nil, fmt.Errorf("已是最新(本地 %s = 远端 %s)", from, to)
}
// ③ 现场编译
buildOut, err := hmapdevBuild(dst, hmapdevBin)
if err != nil {
return nil, fmt.Errorf("编译失败: %w\n%s", err, tailStr(buildOut, 800))
}
// ④ 找产物(hmapdev 输出到 dist/)
hmap := findHmap(filepath.Join(dst, "dist"))
if hmap == "" {
return nil, fmt.Errorf("编译完成但 dist/ 下没有 .hmap 产物\n%s", tailStr(buildOut, 400))
}
return &UpgradeResult{HmapPath: hmap, Repo: repo, From: from, To: to, BuildLog: tailStr(buildOut, 400)}, nil
}
// fetchSource clone(或更新已存在的副本)到 dst。
func fetchSource(repo, dst string) error {
if _, err := os.Stat(filepath.Join(dst, ".git")); err == nil {
// 已有副本:fetch + reset,比删了重 clone 快且省流量
if out, err := exec.Command("git", "-C", dst, "fetch", "origin").CombinedOutput(); err != nil {
return fmt.Errorf("git fetch: %w\n%s", err, tailStr(string(out), 300))
}
if out, err := exec.Command("git", "-C", dst, "reset", "--hard", "origin/HEAD").CombinedOutput(); err != nil {
return fmt.Errorf("git reset: %w\n%s", err, tailStr(string(out), 300))
}
return nil
}
// 新副本:浅 clone。--depth 1 对几十 MB 的插件仓省 90% 时间。
cmd := exec.Command("git", "clone", "--depth", "1", repo, dst)
if out, err := cmd.CombinedOutput(); err != nil {
// 清掉半成品,避免下次把半成品误当有效副本
os.RemoveAll(dst)
return fmt.Errorf("git clone: %w\n%s", err, tailStr(string(out), 300))
}
return nil
}
// hmapdevBuild 在 srcDir 里跑 hmapdev build,返回完整输出。
func hmapdevBuild(srcDir, hmapdevBin string) (string, error) {
cmd := exec.Command(hmapdevBin, "build")
cmd.Dir = srcDir
out, err := cmd.CombinedOutput()
return string(out), err
}
// findHmap 在 dist 目录里找 .hmap 产物(优先 bundle)。
func findHmap(distDir string) string {
entries, err := os.ReadDir(distDir)
if err != nil {
return ""
}
// bundle 优先:它是全平台合集,plugin_install 直接吃
bundle := ""
for _, e := range entries {
if strings.HasSuffix(e.Name(), "_bundle.hmap") {
bundle = filepath.Join(distDir, e.Name())
break
}
}
if bundle != "" {
return bundle
}
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".hmap") {
return filepath.Join(distDir, e.Name())
}
}
return ""
}
// repoName 从仓库 URL 提取名字(用于本地目录名)。
func repoName(repo string) string {
s := strings.TrimSuffix(repo, "/")
s = strings.TrimSuffix(s, ".git")
if i := strings.LastIndexAny(s, "/:"); i >= 0 {
return s[i+1:]
}
return "plugin"
}
func tailStr(s string, n int) string {
if len(s) <= n {
return s
}
return "…(前略)\n" + s[len(s)-n:]
}