mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +00:00
## 为什么合并(用户口径:更新部分应独立为子系统)
之前 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、拒绝目录当包
386 lines
12 KiB
Go
386 lines
12 KiB
Go
// 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:]
|
||
}
|