feat(ota): kernelupgrade 抽成独立包 + 插件仓库迁移脚本

## 1. internal/kernelupgrade:抽包(避免两份实现漂移)

原来 641 行全在 cmd/homed-upgrade 的 main 包里。内核侧的 kernel_update
工具要复用同一套逻辑,而 main 包不可被 import —— 照抄一份的后果是
"CLI 能升级、agent 调用却把机器搞坏"。

导出:ReadBinVersion / CompareVersions / ParseSemver / CmpSemver /
ProbePluginCompat / ExtractPkg / FindFile / DoUpgrade / WaitHealthy 等。
CLI 变薄壳(只留 flag 解析与 fail 输出)。

★ 顺带修一个真实 bug(判据抓到):ParseSemver 原来只挡 len<2,
于是 "1.4" 漏过去、缺失的 patch 被零值填成 1.4.0 —— 会让
「1.4 → 1.4.0」被判成完全相同(进而说"无需升级"),而两者内容未必相同。
现在要求恰好三段。

## 2. scripts/migrate-plugins-to-org.sh:插件迁成独立仓库

为什么迁:插件散在 SDK 仓 example/ 下有三个问题 ——
  ① 插件版本与 SDK 版本绑死(一次 SDK 发版把所有插件都带上)
  ② 插件作者不能单独为自己的插件发版
  ③ plugin_update 需要"仓库地址 → 拉源码 → 编译",而 example/ 是
     别人的仓库,不是插件自己的仓库

脚本纪律:
· 默认 --dry-run,不碰 GitHub
· 产物不入库(dist/build/rawfile/node_modules/*.hmap 全排除)——
  实测源码 3MB、产物 1.9G,后者进了仓库就再也删不干净
· 幂等:仓库已存在(422)则跳过创建,重跑安全
· 自动给 plg.json 补 repo 字段,使 plugin_update 能定位仓库
· 用 tar 管道而非 rsync:后者在最小化系统里常常没有(本机实测就没有)

执行结果:26 个插件全部迁移(25 Go + 1 Lua),仓库名与插件名同名。
This commit is contained in:
JianFeeeee
2026-10-26 00:06:59 +08:00
parent 17f36ac430
commit 02022bc13d
4 changed files with 777 additions and 572 deletions

View File

@ -1,4 +1,7 @@
// Command homed-upgrade 是**内核**的本地升级闭环(默认只探测,不写盘)。
// Command homed-upgrade 是**内核**升级闭环的 CLI 入口(默认只探测,不写盘)。
//
// 实现全部在 internal/kernelupgrade —— 内核侧的 kernel_update 工具走同一套
// 代码,避免"CLI 能升级、agent 调用却把机器搞坏"这类两份实现的漂移。
//
// ## 范围:只面向内核
//
@ -27,21 +30,19 @@
package main
import (
"archive/tar"
"compress/gzip"
"encoding/json"
"flag"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/JianFeeeee/HologramAgent/internal/kernelupgrade"
)
// fail 打印错误并以非零退出(CLI 专属;库内部一律返回 error)。
func fail(format string, a ...interface{}) {
fmt.Fprintf(os.Stderr, format+"\n", a...)
os.Exit(1)
}
func main() {
pkgPath := flag.String("pkg", "", "新安装包(.tar.gz);给出即做探测")
binPath := flag.String("bin", "/usr/local/bin/homed", "当前运行的内核二进制路径")
@ -50,7 +51,8 @@ func main() {
flag.Parse()
if *pkgPath == "" {
fail("必须提供 -pkg <新安装包.tar.gz>")
fmt.Fprintln(os.Stderr, "必须提供 -pkg <新安装包.tar.gz>")
os.Exit(2)
}
tmp, err := os.MkdirTemp("", "homed-upgrade-*")
@ -60,45 +62,45 @@ func main() {
defer os.RemoveAll(tmp)
fmt.Printf("== 解包 %s ==\n", *pkgPath)
root, err := extractPkg(*pkgPath, tmp)
root, err := kernelupgrade.ExtractPkg(*pkgPath, tmp)
if err != nil {
fail("解包: %v", err)
}
// ── 1. 当前版本 ──
cur, curErr := readBinVersion(*binPath)
cur, curErr := kernelupgrade.ReadBinVersion(*binPath)
if curErr != nil {
fmt.Printf("⚠️ 读当前内核版本失败: %v\n", curErr)
cur = nil
} else {
fmt.Printf("当前内核: %s (commit %s)\n", cur.Version, shortCommit(cur.Commit))
fmt.Printf("当前内核: %s (commit %s)\n", cur.Version, kernelupgrade.ShortCommit(cur.Commit))
}
// ── 2. 新版本 ──
newBin := findFile(root, "homed")
newBin := kernelupgrade.FindFile(root, "homed")
if newBin == "" {
fail("包内找不到 homed")
}
nv, err := readBinVersion(newBin)
nv, err := kernelupgrade.ReadBinVersion(newBin)
if err != nil {
fail("新内核无法自报版本(可能不是本仓构建的二进制): %v", err)
}
fmt.Printf("包内内核: %s (commit %s)\n", nv.Version, shortCommit(nv.Commit))
fmt.Printf("包内内核: %s (commit %s)\n", nv.Version, kernelupgrade.ShortCommit(nv.Commit))
// ── 3. 版本比较 ──
verdict := compareVersions(cur, nv)
verdict := kernelupgrade.CompareVersions(cur, nv)
fmt.Printf("\n== 结论 ==\n%s\n", verdict)
// ── 4. depgraph 必须与新二进制同批 ──
newDG := findFile(root, "depgraph.bin")
newDG := kernelupgrade.FindFile(root, "depgraph.bin")
if newDG == "" {
fmt.Println("⚠️ 包内缺 depgraph.bin —— 内核启动会因依赖图不匹配拒绝启动")
} else {
fmt.Printf("depgraph.bin: 包内已带(%d 字节),将与 homed 同批替换\n", fileSize(newDG))
fmt.Printf("depgraph.bin: 包内已带(%d 字节),将与 homed 同批替换\n", kernelupgrade.FileSize(newDG))
}
// ── 5. 插件兼容性预判(OTA 最容易出事的点)──
incompat := probePluginCompat(*pluginsDir, nv.SDKVersion)
incompat := kernelupgrade.ProbePluginCompat(*pluginsDir, nv.SDKVersion)
if len(incompat) > 0 {
fmt.Printf("\n== 插件兼容性警告(%d 个)==\n", len(incompat))
fmt.Println("换内核后这些插件会握手失败并被**自动禁用**(写入禁用配置 + 原因):")
@ -116,526 +118,8 @@ func main() {
}
// ── 6. 真正升级(备份 → 替换 → 重启 → 后置验证 → 失败回滚)──
if err := doUpgrade(root, *binPath, newDG, *pluginsDir); err != nil {
if err := kernelupgrade.DoUpgrade(root, *binPath, newDG, *pluginsDir); err != nil {
fail("升级失败: %v", err)
}
}
// binVersion 是 homed --version 的输出。
type binVersion struct {
Version string
Commit string
BuildTime string
SDKVersion string
}
// readBinVersion 跑 `<bin> --version` 并解析。
//
// ★ 为什么用"跑它自己"而不是读文件里的字符串:
//
// 版本是 ldflags 注入的,从 ELF 里 grep 只能撞运气;而 --version 是内核
// 自己算出来的,还能一并拿到 commit("同版本号不同 commit"很难察觉)。
func readBinVersion(bin string) (*binVersion, error) {
if _, err := os.Stat(bin); err != nil {
return nil, err
}
out, err := exec.Command(bin, "--version").CombinedOutput()
if err != nil {
// ★ 回退:老版本内核没有 --version(它是 2026-10-25 才加的)。
//
// 这正是 OTA 的**第一次**升级必然撞到的情况:新版工具要读线上版本,
// 而线上那个版本还不支持被读。此时不能失败退出——否则所有人第一次
// 升级都要人工介入。改为从运行中进程的环境/文件里找线索,找不到就
// 诚实地说"未知",让版本比较降级(其余预检照常)。
if v := inferVersionFromRunning(); v != nil {
return v, nil
}
return nil, fmt.Errorf("执行 --version: %v(老版本内核不支持 --version,且无法从运行进程推断)", err)
}
v := &binVersion{}
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
k, val, ok := strings.Cut(line, "=")
if !ok {
continue
}
switch k {
case "version":
v.Version = val
case "commit":
v.Commit = val
case "build_time":
v.BuildTime = val
case "sdk":
v.SDKVersion = val
}
}
if v.Version == "" {
return nil, fmt.Errorf("--version 输出里没有 version= 行: %q", strings.TrimSpace(string(out)))
}
return v, nil
}
// compareVersions 给出可读结论。返回文案而不是 bool:
// "能升但不能降"这类判断需要说明理由,用户才敢按 -apply。
func compareVersions(cur, nv *binVersion) string {
if cur == nil {
return "无法读取当前版本,跳过比较(其余预检照常)"
}
c, n := parseSemver(cur.Version), parseSemver(nv.Version)
switch {
case n == nil || c == nil:
// 版本号形态不标准(如 1.4.0dev):退化成字符串比较并说明
if cur.Version == nv.Version && cur.Commit == nv.Commit {
return "⚠️ 版本与 commit 都相同 —— 升级后内容不会变化,请确认包是否拿错"
}
return "版本号非标准形态,按字符串比较;实测内容有差异(commit 不同)"
case cmpSemver(n, c) > 0:
return "✅ 可升级:包内版本高于当前"
case cmpSemver(n, c) == 0:
if cur.Commit != nv.Commit {
return fmt.Sprintf("⚠️ 同为 %s 但 commit 不同(%s → %s)—— 这是一次**同版本重发**,请确认是否有意为之",
nv.Version, shortCommit(cur.Commit), shortCommit(nv.Commit))
}
return "⚠️ 与当前完全相同 —— 无需升级"
default:
return "❌ 这是**降级**(包内版本低于当前)。默认不建议;确认要回退请加 -allow-downgrade"
}
}
type semver struct{ major, minor, patch int }
func parseSemver(s string) *semver {
// 只认纯 X.Y.Z(1.4.0dev 这类走字符串回退,避免把 dev 当成正式版)
if strings.ContainsAny(s, "dev-+") {
return nil
}
parts := strings.Split(s, ".")
if len(parts) < 2 {
return nil
}
var nums [3]int
for i := 0; i < 3 && i < len(parts); i++ {
n, err := strconv.Atoi(parts[i])
if err != nil {
return nil
}
nums[i] = n
}
return &semver{nums[0], nums[1], nums[2]}
}
func cmpSemver(a, b *semver) int {
switch {
case a.major != b.major:
return a.major - b.major
case a.minor != b.minor:
return a.minor - b.minor
default:
return a.patch - b.patch
}
}
// pluginCompat 是一个与新内核 SDK 主版本不匹配的插件。
type pluginCompat struct {
Name string
SDKVersion string
WantMajor string
}
// probePluginCompat 扫插件目录,读每个插件编译时的 SDK 版本。
//
// 数据来源:plugin.json 里**没有** sdk_version 字段,所以读不到就诚实地说
// "无法预判",而不是瞎猜。能读到的(有 meta 文件的)才比较。
func probePluginCompat(dir, wantSDK string) []pluginCompat {
if dir == "" || wantSDK == "" {
return nil
}
wantMajor := majorOf(wantSDK)
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
var out []pluginCompat
for _, e := range entries {
if !e.IsDir() {
continue
}
pj := filepath.Join(dir, e.Name(), "plugin.json")
b, err := os.ReadFile(pj)
if err != nil {
continue
}
var m struct {
SDKVersion string `json:"sdk_version"`
}
json.Unmarshal(b, &m)
if m.SDKVersion == "" {
continue // plugin.json 不含该字段:无法预判,不虚报
}
if majorOf(m.SDKVersion) != wantMajor {
out = append(out, pluginCompat{Name: e.Name(), SDKVersion: m.SDKVersion, WantMajor: wantMajor})
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
func majorOf(v string) string {
if i := strings.IndexByte(v, '.'); i > 0 {
return v[:i]
}
return v
}
func shortCommit(c string) string {
if c == "" || c == "unknown" {
return "unknown"
}
if len(c) > 8 {
return c[:8]
}
return c
}
// extractPkg 解包并返回包含 bin/ 的根目录。
func extractPkg(pkg, dst string) (string, error) {
f, err := os.Open(pkg)
if err != nil {
return "", err
}
defer f.Close()
gz, err := gzip.NewReader(f)
if err != nil {
return "", fmt.Errorf("非 gzip: %w", err)
}
defer gz.Close()
tr := tar.NewReader(gz)
for {
h, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return "", err
}
// 防路径穿越(包是被信任的,但这条成本几乎为零)
clean := filepath.Clean(h.Name)
if strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) {
return "", fmt.Errorf("包内有非法路径: %s", h.Name)
}
p := filepath.Join(dst, clean)
switch h.Typeflag {
case tar.TypeDir:
os.MkdirAll(p, 0755)
case tar.TypeReg:
os.MkdirAll(filepath.Dir(p), 0755)
out, err := os.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(h.Mode))
if err != nil {
return "", err
}
if _, err := io.Copy(out, tr); err != nil {
out.Close()
return "", err
}
out.Close()
}
}
// 找含 bin/homed 的那层
if findFile(dst, "homed") == "" {
return "", fmt.Errorf("包内无 homed")
}
return dst, nil
}
// findFile 在 root 下递归找文件名匹配的第一个。
func findFile(root, name string) string {
var found string
filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() || found != "" {
return nil
}
if info.Name() == name {
found = p
}
return nil
})
return found
}
func fileSize(p string) int64 {
if st, err := os.Stat(p); err == nil {
return st.Size()
}
return 0
}
func fail(format string, a ...interface{}) {
fmt.Fprintf(os.Stderr, format+"\n", a...)
os.Exit(1)
}
// doUpgrade 执行真实升级:备份 → 预检 → 原子替换 → 重启 → 后置验证 → 失败回滚。
//
// 每一步失败都往回退,且**回滚后必须能正常启动**——这是本函数的核心承诺:
// 宁可升级失败,不可让机器起不来。
func doUpgrade(root, binPath, newDG, pluginsDir string) error {
newBin := findFile(root, "homed")
if newBin == "" {
return fmt.Errorf("包内无 homed")
}
// ── 预检:新二进制必须能自报版本(说明它是可执行的、架构对的)──
nv, err := readBinVersion(newBin)
if err != nil {
return fmt.Errorf("预检失败,新二进制不可执行: %w", err)
}
fmt.Printf("\n== 预检通过 ==\n新内核 %s 可执行\n", nv.Version)
// ── 备份:只备本次会动的文件(不整目录拷贝,那是几百 MB)──
ts := nowStamp()
backup := filepath.Join("/root", "homed-upgrade-backup-"+ts)
if err := os.MkdirAll(backup, 0755); err != nil {
return fmt.Errorf("建备份目录: %w", err)
}
targets := []string{binPath}
if dg := filepath.Join(filepath.Dir(binPath), "depgraph.bin"); fileExists(dg) {
targets = append(targets, dg)
}
for _, t := range targets {
if err := copyFile(t, filepath.Join(backup, filepath.Base(t))); err != nil {
return fmt.Errorf("备份 %s: %w", t, err)
}
}
fmt.Printf("已备份 %d 个文件 → %s\n", len(targets), backup)
rollback := func(cause error) error {
fmt.Printf("\n❌ %v\n== 开始回滚 ==\n", cause)
for _, t := range targets {
src := filepath.Join(backup, filepath.Base(t))
if err := copyFile(src, t); err != nil {
fmt.Printf(" ⚠️ 回滚 %s 失败: %v(需人工处理)\n", t, err)
continue
}
fmt.Printf(" 已恢复 %s\n", t)
}
if err := restartService(); err != nil {
fmt.Printf(" ⚠️ 重启失败: %v(需人工处理)\n", err)
} else if !waitHealthy(60) {
fmt.Println(" ⚠️ 回滚后仍不健康,需人工介入")
} else {
fmt.Println(" ✅ 已回滚并恢复正常运行")
}
return fmt.Errorf("升级失败已回滚: %w", cause)
}
// ── 替换(depgraph 必须与 homed 同批,顺序无关但都要成)──
if newDG != "" {
dst := filepath.Join(filepath.Dir(binPath), "depgraph.bin")
if err := copyFile(newDG, dst); err != nil {
return rollback(fmt.Errorf("替换 depgraph.bin: %w", err))
}
fmt.Println("已替换 depgraph.bin")
}
if err := copyFile(newBin, binPath); err != nil {
return rollback(fmt.Errorf("替换 homed: %w", err))
}
fmt.Println("已替换 homed")
// ── 重启 + 后置验证 ──
if err := restartService(); err != nil {
return rollback(fmt.Errorf("重启: %w", err))
}
if !waitHealthy(90) {
return rollback(fmt.Errorf("新版本未在超时内达到健康状态"))
}
// 版本复核:确认跑起来的**确实**是新版本(而不是重启失败后旧的还在)
got, err := readBinVersion(binPath)
if err != nil || got.Version != nv.Version || got.Commit != nv.Commit {
return rollback(fmt.Errorf("版本复核不符:期望 %s/%s,实际 %v", nv.Version, shortCommit(nv.Commit), got))
}
fmt.Printf("\n✅ 升级完成:%s (commit %s)\n", got.Version, shortCommit(got.Commit))
fmt.Printf(" 备份保留在 %s(确认稳定后可删)\n", backup)
return nil
}
// waitHealthy 轮询 systemd 的 active 状态。
func waitHealthy(seconds int) bool {
for i := 0; i < seconds; i++ {
out, err := exec.Command("systemctl", "is-active", "homeagent").Output()
if err == nil && strings.TrimSpace(string(out)) == "active" {
// active 还不够:给它几秒让插件真正加载完再判一次
if i > 3 {
return true
}
}
sleepSecond()
}
return false
}
func restartService() error {
out, err := exec.Command("systemctl", "restart", "homeagent").CombinedOutput()
if err != nil {
return fmt.Errorf("%v (%s)", err, strings.TrimSpace(string(out)))
}
return nil
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
st, err := in.Stat()
if err != nil {
return err
}
tmp := dst + ".tmp-upgrade"
out, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, st.Mode())
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
os.Remove(tmp)
return err
}
if err := out.Close(); err != nil {
os.Remove(tmp)
return err
}
// 原子替换:先写临时文件再 rename,避免"写到一半进程在跑"
return os.Rename(tmp, dst)
}
func fileExists(p string) bool {
_, err := os.Stat(p)
return err == nil
}
func nowStamp() string {
return time.Now().Format("20060102-150405")
}
// sleepSecond 隔一秒(抽出来便于将来换成可注入的等待,测试不必真睡)。
func sleepSecond() { time.Sleep(time.Second) }
// inferVersionFromRunning 在老内核不支持 --version 时,尽力推断当前版本。
//
// 手段按可靠性排序,任一成功即返回:
// 1. 读 /proc/<pid>/exe 指向的文件,用 strings 找 "version=" 形态(不保证有)
// 2. 读 WebUI 的 /api/v1/agents(那里返回 meta.Version)—— 需服务在跑
//
// 都失败则返回 nil,调用方把比较降级为"未知",**不阻断**升级。
func inferVersionFromRunning() *binVersion {
// 手段 1:从二进制里找注入的版本串。
//
// ldflags 注入的字符串在 rodata 里是裸的,形如 "1.4.0dev";
// 配合 "commit=" 一起找才有意义,所以这里只认成对出现的情况。
if v := scanBinaryForVersion("/usr/local/bin/homed"); v != nil {
return v
}
// 手段 2:问 waiter。
//
// ★ 这条最可靠:waiter 与 homed **同批构建**(同一个版本、同一个 commit),
// 而且它一直支持 --version(输出形如
// `waiter 1.4.0 (commit 98850e1, built 2026-10-17T09:20:25Z)`)。
// 老内核没有 --version,但同批的 waiter 有 —— 用它反推。
if v := versionFromWaiter("/usr/local/bin/waiter"); v != nil {
return v
}
return nil
}
// versionFromWaiter 解析 waiter 的版本行反推内核版本。
//
// 只认 "waiter X.Y.Z (commit ABC, built ...)" 这一种形态;
// 格式变了就返回 nil(宁可说"未知",不可瞎猜)。
func versionFromWaiter(path string) *binVersion {
if _, err := os.Stat(path); err != nil {
return nil
}
out, err := exec.Command(path, "--version").CombinedOutput()
if err != nil {
return nil
}
line := strings.TrimSpace(string(out))
// 取第一个语义化版本号
v := firstSemverLike(line)
if v == "" {
return nil
}
commit := ""
if i := strings.Index(line, "commit "); i >= 0 {
rest := line[i+len("commit "):]
if j := strings.IndexAny(rest, ",)"); j > 0 {
commit = strings.TrimSpace(rest[:j])
}
}
return &binVersion{Version: v, Commit: commit}
}
// scanBinaryForVersion 在二进制里搜版本串(老内核的兜底手段)。
func scanBinaryForVersion(path string) *binVersion {
f, err := os.Open(path)
if err != nil {
return nil
}
defer f.Close()
// 只读前 200MB:版本串在 rodata,通常在文件前 1/3
buf := make([]byte, 200<<20)
n, _ := io.ReadFull(f, buf)
buf = buf[:n]
// 找 SDKCompatibleVersion 附近的三元组:本仓 meta 变量相邻存放,
// 命中 "1.3.0" 且附近有语义化版本形态时,取最近的一个当内核版本。
// ★ 这是**尽力而为**:可能撞到别的版本号,所以只作为"未知"的替代,
// 且返回的 commit 恒为 unknown(无法可靠恢复)。
idx := strings.Index(string(buf), "SDKCompatibleVersion")
if idx < 0 {
return nil
}
// 在附近 4KB 里找 X.Y.Z 形态
window := string(buf[maxInt(0, idx-4096):minInt(len(buf), idx+4096)])
if v := firstSemverLike(window); v != "" {
return &binVersion{Version: v, Commit: "unknown"}
}
return nil
}
func firstSemverLike(s string) string {
for i := 0; i+5 <= len(s); i++ {
if s[i] < '0' || s[i] > '9' {
continue
}
j := i
dots := 0
for j < len(s) && ((s[j] >= '0' && s[j] <= '9') || s[j] == '.') {
if s[j] == '.' {
dots++
}
j++
}
if dots == 2 && j-i >= 5 {
return s[i:j]
}
}
return ""
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}

View File

@ -1,6 +1,11 @@
package main
import "testing"
import (
"strings"
"testing"
"github.com/JianFeeeee/HologramAgent/internal/kernelupgrade"
)
// ── 版本比较的判据 ────────────────────────────────────────────
//
@ -10,47 +15,47 @@ import "testing"
func TestCompareVersions(t *testing.T) {
cases := []struct {
name string
cur *binVersion
nv *binVersion
cur *kernelupgrade.BinVersion
nv *kernelupgrade.BinVersion
wantSub string // 结论文案必须包含的片段
}{
{
name: "正常升级",
cur: &binVersion{Version: "1.3.0", Commit: "aaa"},
nv: &binVersion{Version: "1.4.0", Commit: "bbb"},
cur: &kernelupgrade.BinVersion{Version: "1.3.0", Commit: "aaa"},
nv: &kernelupgrade.BinVersion{Version: "1.4.0", Commit: "bbb"},
wantSub: "可升级",
},
{
name: "降级必须显式警告",
cur: &binVersion{Version: "1.4.0", Commit: "aaa"},
nv: &binVersion{Version: "1.3.0", Commit: "bbb"},
cur: &kernelupgrade.BinVersion{Version: "1.4.0", Commit: "aaa"},
nv: &kernelupgrade.BinVersion{Version: "1.3.0", Commit: "bbb"},
wantSub: "降级",
},
{
name: "同版本同 commit = 无需升级",
cur: &binVersion{Version: "1.4.0", Commit: "aaa"},
nv: &binVersion{Version: "1.4.0", Commit: "aaa"},
cur: &kernelupgrade.BinVersion{Version: "1.4.0", Commit: "aaa"},
nv: &kernelupgrade.BinVersion{Version: "1.4.0", Commit: "aaa"},
wantSub: "完全相同",
},
{
// ★ 这条最容易被忽略:版本号没变但内容变了(同版本重发)。
// 不报出来会让用户以为"升级了其实没升"或反之。
name: "同版本不同 commit 必须点明",
cur: &binVersion{Version: "1.4.0", Commit: "aaa"},
nv: &binVersion{Version: "1.4.0", Commit: "bbb"},
cur: &kernelupgrade.BinVersion{Version: "1.4.0", Commit: "aaa"},
nv: &kernelupgrade.BinVersion{Version: "1.4.0", Commit: "bbb"},
wantSub: "同版本重发",
},
{
// 非标准形态(1.4.0dev):走字符串回退,不能说"降级"
name: "非标准版本号走字符串比较",
cur: &binVersion{Version: "1.4.0", Commit: "aaa"},
nv: &binVersion{Version: "1.4.0dev", Commit: "bbb"},
cur: &kernelupgrade.BinVersion{Version: "1.4.0", Commit: "aaa"},
nv: &kernelupgrade.BinVersion{Version: "1.4.0dev", Commit: "bbb"},
wantSub: "commit 不同",
},
}
for _, c := range cases {
got := compareVersions(c.cur, c.nv)
if !containsStr(got, c.wantSub) {
got := kernelupgrade.CompareVersions(c.cur, c.nv)
if !strings.Contains(got, c.wantSub) {
t.Errorf("%s: 结论应含 %q,实际 %q", c.name, c.wantSub, got)
}
}
@ -61,11 +66,11 @@ func TestCompareVersions(t *testing.T) {
// 这是实测踩到的:线上内核是加 --version 之前的版本,第一次探测必然失败。
// 若此时直接退出,所有用户第一次升级都要人工介入。
func TestCompareVersions_UnknownCurrentDoesNotBlock(t *testing.T) {
got := compareVersions(nil, &binVersion{Version: "1.4.0", Commit: "bbb"})
if containsStr(got, "❌") {
got := kernelupgrade.CompareVersions(nil, &kernelupgrade.BinVersion{Version: "1.4.0", Commit: "bbb"})
if strings.Contains(got, "❌") {
t.Errorf("当前版本未知不应判失败,实际: %s", got)
}
if !containsStr(got, "跳过比较") {
if !strings.Contains(got, "跳过比较") {
t.Errorf("应说明跳过比较,实际: %s", got)
}
}
@ -73,13 +78,22 @@ func TestCompareVersions_UnknownCurrentDoesNotBlock(t *testing.T) {
// ── 版本解析 ─────────────────────────────────────────────────
func TestParseSemver(t *testing.T) {
if v := parseSemver("1.4.0"); v == nil || v.major != 1 || v.minor != 4 || v.patch != 0 {
t.Errorf("1.4.0 解析错: %+v", v)
// 只测**可观察行为**(返回 nil 与否),不探私有字段 ——
// 跨包断言内部字段会把重构锁死,而"解析对不对"最终要由
// CompareVersions 的行为体现(上面那组用例已覆盖)。
if kernelupgrade.ParseSemver("1.4.0") == nil {
t.Error("1.4.0 应能解析")
}
// dev 后缀不算正式语义版本(否则 1.4.0dev 会被当成 1.4.0 比大小)
if v := parseSemver("1.4.0dev"); v != nil {
if v := kernelupgrade.ParseSemver("1.4.0dev"); v != nil {
t.Errorf("1.4.0dev 应回退字符串比较,实际解析成 %+v", v)
}
// 不完整串同样不算
for _, bad := range []string{"", "1", "1.4", "v1.4.0", "1.4.x"} {
if v := kernelupgrade.ParseSemver(bad); v != nil {
t.Errorf("%q 不应被解析成语义版本,实际 %+v", bad, v)
}
}
}
// ── waiter 兜底解析 ──────────────────────────────────────────
@ -87,20 +101,13 @@ func TestParseSemver(t *testing.T) {
func TestFirstSemverLike(t *testing.T) {
// waiter 的真实输出形态
line := "waiter 1.4.0 (commit 98850e1, built 2026-10-17T09:20:25Z)"
if got := firstSemverLike(line); got != "1.4.0" {
if got := kernelupgrade.FirstSemverLike(line); got != "1.4.0" {
t.Errorf("应从 waiter 输出取到 1.4.0,实际 %q", got)
}
// 不能把日期当版本号
if got := firstSemverLike("built 2026-10-17"); got != "" {
if got := kernelupgrade.FirstSemverLike("built 2026-10-17"); got != "" {
t.Errorf("日期不应被识别为版本号,实际 %q", got)
}
}
func containsStr(s, sub string) bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}

View File

@ -0,0 +1,543 @@
// Package kernelupgrade 实现**内核**的升级闭环:探测 / 校验 / 备份 / 原子替换 / 重启 / 回滚。
//
// 抽成独立包而不是留在 cmd/homed-upgrade:内核侧的 kernel_update 工具
// 与 CLI 必须走**同一套**逻辑。两份实现必然漂移,而这条路径上漂移的
// 后果是"CLI 能升级、agent 调用却把机器搞坏"。
//
// ## 范围:只面向内核
//
// 只处理 homed / depgraph.bin / waiter。**不碰插件** —— 插件有独立通道
// (plugin_update),且与内核的兼容性由"自动禁用不兼容插件"兜底。
package kernelupgrade
import (
"archive/tar"
"compress/gzip"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
)
// binVersion 是 homed --version 的输出。
type BinVersion struct {
Version string
Commit string
BuildTime string
SDKVersion string
}
// readBinVersion 跑 `<bin> --version` 并解析。
//
// ★ 为什么用"跑它自己"而不是读文件里的字符串:
//
// 版本是 ldflags 注入的,从 ELF 里 grep 只能撞运气;而 --version 是内核
// 自己算出来的,还能一并拿到 commit("同版本号不同 commit"很难察觉)。
func ReadBinVersion(bin string) (*BinVersion, error) {
if _, err := os.Stat(bin); err != nil {
return nil, err
}
out, err := exec.Command(bin, "--version").CombinedOutput()
if err != nil {
// ★ 回退:老版本内核没有 --version(它是 2026-10-25 才加的)。
//
// 这正是 OTA 的**第一次**升级必然撞到的情况:新版工具要读线上版本,
// 而线上那个版本还不支持被读。此时不能失败退出——否则所有人第一次
// 升级都要人工介入。改为从运行中进程的环境/文件里找线索,找不到就
// 诚实地说"未知",让版本比较降级(其余预检照常)。
if v := InferVersionFromRunning(); v != nil {
return v, nil
}
return nil, fmt.Errorf("执行 --version: %v(老版本内核不支持 --version,且无法从运行进程推断)", err)
}
v := &BinVersion{}
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
k, val, ok := strings.Cut(line, "=")
if !ok {
continue
}
switch k {
case "version":
v.Version = val
case "commit":
v.Commit = val
case "build_time":
v.BuildTime = val
case "sdk":
v.SDKVersion = val
}
}
if v.Version == "" {
return nil, fmt.Errorf("--version 输出里没有 version= 行: %q", strings.TrimSpace(string(out)))
}
return v, nil
}
// compareVersions 给出可读结论。返回文案而不是 bool:
// "能升但不能降"这类判断需要说明理由,用户才敢按 -apply。
func CompareVersions(cur, nv *BinVersion) string {
if cur == nil {
return "无法读取当前版本,跳过比较(其余预检照常)"
}
c, n := ParseSemver(cur.Version), ParseSemver(nv.Version)
switch {
case n == nil || c == nil:
// 版本号形态不标准(如 1.4.0dev):退化成字符串比较并说明
if cur.Version == nv.Version && cur.Commit == nv.Commit {
return "⚠️ 版本与 commit 都相同 —— 升级后内容不会变化,请确认包是否拿错"
}
return "版本号非标准形态,按字符串比较;实测内容有差异(commit 不同)"
case CmpSemver(n, c) > 0:
return "✅ 可升级:包内版本高于当前"
case CmpSemver(n, c) == 0:
if cur.Commit != nv.Commit {
return fmt.Sprintf("⚠️ 同为 %s 但 commit 不同(%s → %s)—— 这是一次**同版本重发**,请确认是否有意为之",
nv.Version, ShortCommit(cur.Commit), ShortCommit(nv.Commit))
}
return "⚠️ 与当前完全相同 —— 无需升级"
default:
return "❌ 这是**降级**(包内版本低于当前)。默认不建议;确认要回退请加 -allow-downgrade"
}
}
type Semver struct{ major, minor, patch int }
func ParseSemver(s string) *Semver {
// 只认纯 X.Y.Z(1.4.0dev 这类走字符串回退,避免把 dev 当成正式版)
if strings.ContainsAny(s, "dev-+") {
return nil
}
// ★ 必须恰好三段。原来只挡 len<2,于是 "1.4" 会漏过去,
// 缺失的 patch 被零值填成 1.4.0 —— 这会让「1.4 → 1.4.0」被判定为
// 完全相同(进而说"无需升级"),而两者内容未必相同。
parts := strings.Split(s, ".")
if len(parts) != 3 {
return nil
}
var nums [3]int
for i, p := range parts {
n, err := strconv.Atoi(p)
if err != nil {
return nil
}
nums[i] = n
}
return &Semver{nums[0], nums[1], nums[2]}
}
func CmpSemver(a, b *Semver) int {
switch {
case a.major != b.major:
return a.major - b.major
case a.minor != b.minor:
return a.minor - b.minor
default:
return a.patch - b.patch
}
}
// pluginCompat 是一个与新内核 SDK 主版本不匹配的插件。
type PluginCompat struct {
Name string
SDKVersion string
WantMajor string
}
// probePluginCompat 扫插件目录,读每个插件编译时的 SDK 版本。
//
// 数据来源:plugin.json 里**没有** sdk_version 字段,所以读不到就诚实地说
// "无法预判",而不是瞎猜。能读到的(有 meta 文件的)才比较。
func ProbePluginCompat(dir, wantSDK string) []PluginCompat {
if dir == "" || wantSDK == "" {
return nil
}
wantMajor := MajorOf(wantSDK)
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
var out []PluginCompat
for _, e := range entries {
if !e.IsDir() {
continue
}
pj := filepath.Join(dir, e.Name(), "plugin.json")
b, err := os.ReadFile(pj)
if err != nil {
continue
}
var m struct {
SDKVersion string `json:"sdk_version"`
}
json.Unmarshal(b, &m)
if m.SDKVersion == "" {
continue // plugin.json 不含该字段:无法预判,不虚报
}
if MajorOf(m.SDKVersion) != wantMajor {
out = append(out, PluginCompat{Name: e.Name(), SDKVersion: m.SDKVersion, WantMajor: wantMajor})
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
func MajorOf(v string) string {
if i := strings.IndexByte(v, '.'); i > 0 {
return v[:i]
}
return v
}
func ShortCommit(c string) string {
if c == "" || c == "unknown" {
return "unknown"
}
if len(c) > 8 {
return c[:8]
}
return c
}
// extractPkg 解包并返回包含 bin/ 的根目录。
func ExtractPkg(pkg, dst string) (string, error) {
f, err := os.Open(pkg)
if err != nil {
return "", err
}
defer f.Close()
gz, err := gzip.NewReader(f)
if err != nil {
return "", fmt.Errorf("非 gzip: %w", err)
}
defer gz.Close()
tr := tar.NewReader(gz)
for {
h, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return "", err
}
// 防路径穿越(包是被信任的,但这条成本几乎为零)
clean := filepath.Clean(h.Name)
if strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) {
return "", fmt.Errorf("包内有非法路径: %s", h.Name)
}
p := filepath.Join(dst, clean)
switch h.Typeflag {
case tar.TypeDir:
os.MkdirAll(p, 0755)
case tar.TypeReg:
os.MkdirAll(filepath.Dir(p), 0755)
out, err := os.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(h.Mode))
if err != nil {
return "", err
}
if _, err := io.Copy(out, tr); err != nil {
out.Close()
return "", err
}
out.Close()
}
}
// 找含 bin/homed 的那层
if FindFile(dst, "homed") == "" {
return "", fmt.Errorf("包内无 homed")
}
return dst, nil
}
// findFile 在 root 下递归找文件名匹配的第一个。
func FindFile(root, name string) string {
var found string
filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() || found != "" {
return nil
}
if info.Name() == name {
found = p
}
return nil
})
return found
}
func FileSize(p string) int64 {
if st, err := os.Stat(p); err == nil {
return st.Size()
}
return 0
}
// doUpgrade 执行真实升级:备份 → 预检 → 原子替换 → 重启 → 后置验证 → 失败回滚。
//
// 每一步失败都往回退,且**回滚后必须能正常启动**——这是本函数的核心承诺:
// 宁可升级失败,不可让机器起不来。
func DoUpgrade(root, binPath, newDG, pluginsDir string) error {
newBin := FindFile(root, "homed")
if newBin == "" {
return fmt.Errorf("包内无 homed")
}
// ── 预检:新二进制必须能自报版本(说明它是可执行的、架构对的)──
nv, err := ReadBinVersion(newBin)
if err != nil {
return fmt.Errorf("预检失败,新二进制不可执行: %w", err)
}
fmt.Printf("\n== 预检通过 ==\n新内核 %s 可执行\n", nv.Version)
// ── 备份:只备本次会动的文件(不整目录拷贝,那是几百 MB)──
ts := NowStamp()
backup := filepath.Join("/root", "homed-upgrade-backup-"+ts)
if err := os.MkdirAll(backup, 0755); err != nil {
return fmt.Errorf("建备份目录: %w", err)
}
targets := []string{binPath}
if dg := filepath.Join(filepath.Dir(binPath), "depgraph.bin"); FileExists(dg) {
targets = append(targets, dg)
}
for _, t := range targets {
if err := CopyFile(t, filepath.Join(backup, filepath.Base(t))); err != nil {
return fmt.Errorf("备份 %s: %w", t, err)
}
}
fmt.Printf("已备份 %d 个文件 → %s\n", len(targets), backup)
rollback := func(cause error) error {
fmt.Printf("\n❌ %v\n== 开始回滚 ==\n", cause)
for _, t := range targets {
src := filepath.Join(backup, filepath.Base(t))
if err := CopyFile(src, t); err != nil {
fmt.Printf(" ⚠️ 回滚 %s 失败: %v(需人工处理)\n", t, err)
continue
}
fmt.Printf(" 已恢复 %s\n", t)
}
if err := restartService(); err != nil {
fmt.Printf(" ⚠️ 重启失败: %v(需人工处理)\n", err)
} else if !WaitHealthy(60) {
fmt.Println(" ⚠️ 回滚后仍不健康,需人工介入")
} else {
fmt.Println(" ✅ 已回滚并恢复正常运行")
}
return fmt.Errorf("升级失败已回滚: %w", cause)
}
// ── 替换(depgraph 必须与 homed 同批,顺序无关但都要成)──
if newDG != "" {
dst := filepath.Join(filepath.Dir(binPath), "depgraph.bin")
if err := CopyFile(newDG, dst); err != nil {
return rollback(fmt.Errorf("替换 depgraph.bin: %w", err))
}
fmt.Println("已替换 depgraph.bin")
}
if err := CopyFile(newBin, binPath); err != nil {
return rollback(fmt.Errorf("替换 homed: %w", err))
}
fmt.Println("已替换 homed")
// ── 重启 + 后置验证 ──
if err := restartService(); err != nil {
return rollback(fmt.Errorf("重启: %w", err))
}
if !WaitHealthy(90) {
return rollback(fmt.Errorf("新版本未在超时内达到健康状态"))
}
// 版本复核:确认跑起来的**确实**是新版本(而不是重启失败后旧的还在)
got, err := ReadBinVersion(binPath)
if err != nil || got.Version != nv.Version || got.Commit != nv.Commit {
return rollback(fmt.Errorf("版本复核不符:期望 %s/%s,实际 %v", nv.Version, ShortCommit(nv.Commit), got))
}
fmt.Printf("\n✅ 升级完成:%s (commit %s)\n", got.Version, ShortCommit(got.Commit))
fmt.Printf(" 备份保留在 %s(确认稳定后可删)\n", backup)
return nil
}
// waitHealthy 轮询 systemd 的 active 状态。
func WaitHealthy(seconds int) bool {
for i := 0; i < seconds; i++ {
out, err := exec.Command("systemctl", "is-active", "homeagent").Output()
if err == nil && strings.TrimSpace(string(out)) == "active" {
// active 还不够:给它几秒让插件真正加载完再判一次
if i > 3 {
return true
}
}
sleepSecond()
}
return false
}
func restartService() error {
out, err := exec.Command("systemctl", "restart", "homeagent").CombinedOutput()
if err != nil {
return fmt.Errorf("%v (%s)", err, strings.TrimSpace(string(out)))
}
return nil
}
func CopyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
st, err := in.Stat()
if err != nil {
return err
}
tmp := dst + ".tmp-upgrade"
out, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, st.Mode())
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
os.Remove(tmp)
return err
}
if err := out.Close(); err != nil {
os.Remove(tmp)
return err
}
// 原子替换:先写临时文件再 rename,避免"写到一半进程在跑"
return os.Rename(tmp, dst)
}
func FileExists(p string) bool {
_, err := os.Stat(p)
return err == nil
}
func NowStamp() string {
return time.Now().Format("20060102-150405")
}
// sleepSecond 隔一秒(抽出来便于将来换成可注入的等待,测试不必真睡)。
func sleepSecond() { time.Sleep(time.Second) }
// inferVersionFromRunning 在老内核不支持 --version 时,尽力推断当前版本。
//
// 手段按可靠性排序,任一成功即返回:
// 1. 读 /proc/<pid>/exe 指向的文件,用 strings 找 "version=" 形态(不保证有)
// 2. 读 WebUI 的 /api/v1/agents(那里返回 meta.Version)—— 需服务在跑
//
// 都失败则返回 nil,调用方把比较降级为"未知",**不阻断**升级。
func InferVersionFromRunning() *BinVersion {
// 手段 1:从二进制里找注入的版本串。
//
// ldflags 注入的字符串在 rodata 里是裸的,形如 "1.4.0dev";
// 配合 "commit=" 一起找才有意义,所以这里只认成对出现的情况。
if v := scanBinaryForVersion("/usr/local/bin/homed"); v != nil {
return v
}
// 手段 2:问 waiter。
//
// ★ 这条最可靠:waiter 与 homed **同批构建**(同一个版本、同一个 commit),
// 而且它一直支持 --version(输出形如
// `waiter 1.4.0 (commit 98850e1, built 2026-10-17T09:20:25Z)`)。
// 老内核没有 --version,但同批的 waiter 有 —— 用它反推。
if v := versionFromWaiter("/usr/local/bin/waiter"); v != nil {
return v
}
return nil
}
// versionFromWaiter 解析 waiter 的版本行反推内核版本。
//
// 只认 "waiter X.Y.Z (commit ABC, built ...)" 这一种形态;
// 格式变了就返回 nil(宁可说"未知",不可瞎猜)。
func versionFromWaiter(path string) *BinVersion {
if _, err := os.Stat(path); err != nil {
return nil
}
out, err := exec.Command(path, "--version").CombinedOutput()
if err != nil {
return nil
}
line := strings.TrimSpace(string(out))
// 取第一个语义化版本号
v := FirstSemverLike(line)
if v == "" {
return nil
}
commit := ""
if i := strings.Index(line, "commit "); i >= 0 {
rest := line[i+len("commit "):]
if j := strings.IndexAny(rest, ",)"); j > 0 {
commit = strings.TrimSpace(rest[:j])
}
}
return &BinVersion{Version: v, Commit: commit}
}
// scanBinaryForVersion 在二进制里搜版本串(老内核的兜底手段)。
func scanBinaryForVersion(path string) *BinVersion {
f, err := os.Open(path)
if err != nil {
return nil
}
defer f.Close()
// 只读前 200MB:版本串在 rodata,通常在文件前 1/3
buf := make([]byte, 200<<20)
n, _ := io.ReadFull(f, buf)
buf = buf[:n]
// 找 SDKCompatibleVersion 附近的三元组:本仓 meta 变量相邻存放,
// 命中 "1.3.0" 且附近有语义化版本形态时,取最近的一个当内核版本。
// ★ 这是**尽力而为**:可能撞到别的版本号,所以只作为"未知"的替代,
// 且返回的 commit 恒为 unknown(无法可靠恢复)。
idx := strings.Index(string(buf), "SDKCompatibleVersion")
if idx < 0 {
return nil
}
// 在附近 4KB 里找 X.Y.Z 形态
window := string(buf[maxInt(0, idx-4096):minInt(len(buf), idx+4096)])
if v := FirstSemverLike(window); v != "" {
return &BinVersion{Version: v, Commit: "unknown"}
}
return nil
}
func FirstSemverLike(s string) string {
for i := 0; i+5 <= len(s); i++ {
if s[i] < '0' || s[i] > '9' {
continue
}
j := i
dots := 0
for j < len(s) && ((s[j] >= '0' && s[j] <= '9') || s[j] == '.') {
if s[j] == '.' {
dots++
}
j++
}
if dots == 2 && j-i >= 5 {
return s[i:j]
}
}
return ""
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}

171
scripts/migrate-plugins-to-org.sh Executable file
View File

@ -0,0 +1,171 @@
#!/usr/bin/env bash
# 把 SDK 仓 example/ 下的示例插件迁移到 GitHub 组织下成为**独立仓库**。
#
# 用法:
# scripts/migrate-plugins-to-org.sh [--dry-run] [--only name1,name2]
#
# 环境:
# GH_ORG_TOKEN fine-grained PAT,Resource owner = 目标组织,
# 带 Repository permissions: Administration(Write) 或
# Repository creation(Write) + Contents(Write)
# GH_ORG 目标组织名(默认 HologramAgent)
#
# 为什么这么做:
# 插件散落在 SDK 仓 example/ 下有三个问题:
# ① 插件版本与 SDK 版本绑死(一次 SDK 发版把所有插件都带上)
# ② 插件作者不能单独为自己的插件开发/发版(都在别人的仓里)
# ③ plugin_update 需要「仓库地址 → 拉源码 → 编译」,而
# example/ 是别人的仓库,不是插件自己的仓库
#
# 纪律:
# 1. 默认 --dry-run:只打印会做什么,不碰 GitHub。
# 2. 产物不入库:dist/ build/ rawfile/ node_modules/ *.hmap 全部排除
# (实测源码 3MB,产物 1.9G —— 后者进了仓库就再也删不干净)
# 3. 测试与 go.sum 保留:它们是仓库的一部分,不是产物。
set -euo pipefail
DRY=0
ONLY=""
for arg in "$@"; do
case "$arg" in
--dry-run) DRY=1 ;;
--only) shift; ONLY="${1:-}" ;;
*) echo "未知参数: $arg" >&2; exit 2 ;;
esac
shift || true
done
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
EXAMPLES="$ROOT/third_party/homeagent-sdk/example"
GH_ORG="${GH_ORG:-HologramAgent}"
TOKEN="${GH_ORG_TOKEN:-}"
if [ "$DRY" -eq 0 ] && [ -z "$TOKEN" ]; then
echo "ERROR: 非 dry-run 需要 GH_ORG_TOKEN" >&2
exit 2
fi
api() {
# $1=method $2=path $3=payload(optional)
local method="$1" path="$2" payload="${3:-}"
if [ -n "$payload" ]; then
curl -sS -X "$method" \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com${path}" -d "$payload"
else
curl -sS -X "$method" \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com${path}"
fi
}
created=0; pushed=0; skipped=0
for dir in "$EXAMPLES"/*/; do
name="$(basename "$dir")"
if [ -n "$ONLY" ]; then
case ",$ONLY," in *",$name,"*) ;; *) continue ;; esac
fi
# 入口判据:Go 插件是 plugin.go,Lua 插件是 main.lua(与 build-examples.sh 同)
if [ ! -f "$dir/plugin.go" ] && [ ! -f "$dir/main.lua" ]; then
continue
fi
desc=$(python3 -c "import json,sys;print(json.load(open('$dir/plg.json')).get('description',''))" 2>/dev/null || echo "")
ver=$(python3 -c "import json,sys;print(json.load(open('$dir/plg.json')).get('version',''))" 2>/dev/null || echo "")
echo ""
echo "── $name (v$ver) ──"
echo " desc: ${desc:0:60}"
if [ "$DRY" -eq 1 ]; then
echo " [dry-run] 将创建仓库 $GH_ORG/$name 并推送源码(排除 dist/build/rawfile/node_modules)"
continue
fi
# ① 创建仓库(已存在则跳过 —— 幂等,重跑安全)
code=$(curl -s -o /tmp/ghresp -w "%{http_code}" -X POST \
-H "Authorization: Bearer $TOKEN" -H "Accept: application/vnd.github+json" \
https://api.github.com/orgs/$GH_ORG/repos \
-d "{\"name\":\"$name\",\"private\":false,\"description\":\"$desc\",\"has_issues\":true}")
if [ "$code" = "201" ]; then
echo " ✓ 仓库已创建"
created=$((created+1))
elif [ "$code" = "422" ]; then
echo " · 仓库已存在(跳过创建)"
skipped=$((skipped+1))
else
echo " ✗ 创建失败 HTTP=$code: $(head -c 200 /tmp/ghresp)"
continue
fi
# ② 准备干净的源码副本
#
# 用 tar 管道而不是 rsync/cp -r:rsync 在最小化系统里常常没有
# (本机实测就是),而 cp -r 无法精确排除多类产物。
# --exclude 用**不带前导斜杠**的形态,匹配任意层级同名目录。
work=$(mktemp -d)
mkdir -p "$work/$name"
( cd "$dir" && tar -cf - \
--exclude='dist' --exclude='build' --exclude='rawfile' \
--exclude='node_modules' --exclude='*.hmap' --exclude='.git' \
. ) | ( cd "$work/$name" && tar -xf - )
# plg.json 里补 repository 字段,让装好的插件能反过来找到自己的仓库
# (plugin_update 靠它拉源码;不填的话插件永远无法自动更新)
python3 - "$work/$name/plg.json" "https://github.com/$GH_ORG/$name" <<'PY'
import json, sys
path, repo = sys.argv[1], sys.argv[2]
d = json.load(open(path))
d["repo"] = repo
json.dump(d, open(path, "w"), indent=2, ensure_ascii=False)
open(path, "a").write("\n")
PY
# 每个独立仓库都要有自己的 README 与 LICENSE 指引
cat > "$work/$name/README.md" <<EOF
# $name
$desc
HologramAgent 插件,随 SDK \`${ver:-?}\` 的示例插件拆分而来。
## 构建
\`\`\`bash
hmapdev build
\`\`\`
产物 \`dist/${name}_bundle.hmap\` 经 \`plugin_install\` 安装。
## 自动更新
本仓库地址登记在 \`plg.json\` 的 \`repo\` 字段,安装后随产物写入
\`plugin.json\` 的 \`repository\` 字段 —— 内核的 \`plugin_update\` 靠它
从本仓库拉最新源码并重编安装。
EOF
# ③ 推送
(
cd "$work/$name"
git init -q
git checkout -q -b main
git add -A
git -c user.name="$GH_ORG" -c user.email="$GH_ORG@users.noreply.github.com" \
commit -q -m "feat: 从 SDK example/ 迁移为独立仓库
保留全部源码与测试(dist/build/rawfile/node_modules 等产物不入库)。
plg.json 补 repo 字段,使 plugin_update 能定位本仓库。"
git remote add origin "https://x-access-token:$TOKEN@github.com/$GH_ORG/$name.git"
git push -q origin main 2>&1 | tail -1 || echo " ✗ 推送失败(见上)"
)
echo " ✓ 已推送"
pushed=$((pushed+1))
rm -rf "$work"
done
echo ""
echo "=== 完成 ==="
echo " 创建仓库: $created / 已存在: $skipped / 推送成功: $pushed"