Files
homeagent-sdk/tools/hmapdev/rawfiles.go
JianFeeeee 0111ac3a7a fix(sdk): 协议通道纪律 + hmapdev 模板升级 + 示例插件 stdout 污染
## 核心缺陷:插件往 stdout 打日志 = 污染 JSON-RPC 协议通道

子进程插件与内核用 stdout 传 NDJSON 帧,stderr 才是日志通道。
脚手架模板(及 4 个示例插件)用 `fmt.Printf` 打日志 ⇒ 内核收到非 JSON 文本:

    [proc] weather 收到非法 JSON 帧(21 字节): invalid character 'i'

**生产实测 1038 条**(huawei_smarthome 720 / weather 113 / rss 103 / calendar 102)。
后果不只是日志噪音:那一行日志被**丢弃**,插件看起来"没启动"。

修法:
- hmapdev 模板 `fmt.Printf` → `log.Printf`(9 处)
- 示例插件 4 个 14 处:ai_image(2) calendar(5) rss(4) weather(3)
  + 补 "log" import
- 内核侧(主仓)同步让报错可定位:内容摘要 + 可能原因 + 修法

判别法:`package main` = 子进程(协议通道,禁用 stdout 打日志);
普通库包 = 进程内插件(编译进内核,写真实终端,**不是**缺陷)——
`internal/plugins/cli` 的 `fmt.Println` 属后者。

## hmapdev

- `cmd_build.go` +136:平台产物精确匹配(`--target` 是**append** 语义,
  原脚本 `ls dist/*.hmap | head -1` 会按字典序抓到 amd64,
  **导致 arm64 包里装的是 x86-64 二进制**,且退出码 0、校验和通过)
- `cmd_init.go` +46:脚手架 stdio 纪律
- `templates/proc_main.go.tmpl`:补齐 mgrStop/mgrStart/mgrIsRuntimeStopped
  三态能力(插件管理器运行态停启)
- `templates/plg.json.tmpl` 删除(合并进 cmd_init)

## 判据

- `scaffold_stdout_test.go`(3 条):扫模板生成的代码不得写 stdout。
  ★ 判据自身先假红过一次:**模板的注释里就写了 `fmt.Printf`** ⇒
  必须 `stripTemplateComments()` 之后才判
- `example_stdout_test.go`(2 条):扫全部 20 个子进程示例插件,
  报错给**文件:行号**;变异咬住并给出准确行号
- `package_examples_test.go`(2 条):**执行真实打包脚本**
  (第一版测的是 hmapdev 而不是脚本 ⇒ 变异不咬)
- `cmd_build` / `cmd_init` 新判据

## 其他

- sdk/settings.go +15(三态能力透传)
- `docs/versions.md` +§示例插件协议通道纪律(含生产 1038 条实测表格)
- `skills/homeagent-plugin-dev/SKILL.md` +§协议通道纪律(正误代码对比)
2026-10-17 16:34:29 +08:00

295 lines
9.5 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 main
// rawfile:把**非代码资源**打进 .hmap,解包后供插件在运行期读取。
//
// ## 为什么需要
//
// 在它之前 `hmapdev build` 只收 `plugin.bin` + `plugin.json`(+README/LICENSE),
// **任何资源都得靠部署脚本手工拷** —— 那让"一个 .hmap 就是完整插件"这个
// 前提失效:白板机器上装完直接缺文件。
//
// 典型需求:随包分发的 node 与 pi、各 CLI 后端的 sidecar 脚本与 driver.json、
// 模板、prompt 文本、字典、默认配置、wasm、字体……
//
// ## ★ 规则(用户拍板 2026-10-15)
//
// 1. 工程根下的 `rawfile/` 目录 **默认全部打包**(不需要在 plg.json 里声明)。
// 2. 其他位置的资源,用户在 plg.json 的 `rawfiles` 数组里**显式列出**,一同打包。
// 3. 解包后**一律落在 `<插件目录>/rawfile/` 下**:
// · `rawfile/` 目录里的内容 → `<插件目录>/rawfile/<原名>`
// · 外部声明的路径 → `<插件目录>/rawfile/<声明名>`
//
// 即 `rawfile/` 是**唯一入口**,插件只需认一个目录。
//
// ## plg.json 形状
//
// {
// "rawfiles": [
// "config/defaults.json", // 字符串:源路径,落到 rawfile/defaults.json
// { "from": "assets", "exclude": ["assets/tmp"] }
// ]
// }
//
// ## 为什么统一落到 rawfile/ 下
//
// 若允许任意目标路径(比如把 node 直接放到 `runtime/node/bin/node`),
// 就会出现两类路径(工程路径 vs 包内路径)互相穿模,且**与插件自己写的
// 运行期文件混在一起**——升级时无法区分"包里带的"与"运行期生成的"。
// 单一入口让"包内的"与"运行期的"天然分离:包内只读,运行期在别处。
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
)
// RawFilesDir 是默认打包目录(工程根下)。
const RawFilesDir = "rawfile"
// RawFilesPrefix 是解包后这些资源的统一前缀。
const RawFilesPrefix = "rawfile"
// RawFileDecl 是一条 rawfiles 声明(支持字符串与对象两种写法)。
type RawFileDecl struct {
From string `json:"from"`
Exclude []string `json:"exclude,omitempty"`
Optional bool `json:"optional,omitempty"`
}
// UnmarshalJSON 同时接受 `"path"` 与 `{"from": "path"}` 两种写法。
//
// 为何两种都收:只要一个路径时写字符串最省事(`"config/x.json"`),
// 需要 exclude/optional 时才用对象。强迫所有人写对象是没必要的仪式。
func (d *RawFileDecl) UnmarshalJSON(b []byte) error {
s := strings.TrimSpace(string(b))
if strings.HasPrefix(s, `"`) {
var only string
if err := json.Unmarshal(b, &only); err != nil {
return err
}
d.From = only
return nil
}
type alias RawFileDecl
var a alias
if err := json.Unmarshal(b, &a); err != nil {
return err
}
*d = RawFileDecl(a)
return nil
}
type rawEntry struct {
src string // 磁盘路径
zip string // zip 内条目名(保留路径)
}
// collectRawFiles 收集要打包的资源。
//
// 两部分合起来:
//
// ① 工程根的 `rawfile/` 目录(默认全收)
// ② plg.json 里 rawfiles 声明的外部路径
//
// 输出按 zip 条目名排序(**确定性**:同样输入产出同样字节,
// 否则每次构建的 .hmap 都不同,校验和与 diff 全无意义)。
func collectRawFiles(root string, decls []RawFileDecl) ([]rawEntry, error) {
var out []rawEntry
// ① 默认目录
defDir := filepath.Join(root, RawFilesDir)
if fi, err := os.Stat(defDir); err == nil && fi.IsDir() {
err := filepath.Walk(defDir, func(p string, fi os.FileInfo, werr error) error {
if werr != nil {
return werr
}
if fi.IsDir() {
return nil
}
rel, err := filepath.Rel(defDir, p)
if err != nil {
return err
}
// ★ 排除脚手架文件:`.gitkeep`/`.gitignore` 是**目录占位与版本控制**
// 用的,不是资源。不打进包的原因有两个:
// ① 它们是工程内部约定,对插件运行期毫无意义;
// ② 它们是**噪声** —— 一个空工程会打出"已打包 2 个 rawfile",
// 让人误以为里面有资源。
// (实测发现:init 出的新工程第一次 build 就把这两个文件打进去了。)
if IsRawFileScaffold(rel) {
return nil
}
out = append(out, rawEntry{
src: p,
zip: filepath.ToSlash(filepath.Join(RawFilesPrefix, rel)),
})
return nil
})
if err != nil {
return nil, fmt.Errorf("展开 %s/ 失败: %w", RawFilesDir, err)
}
}
// ② 显式声明
for _, d := range decls {
from := strings.TrimSpace(d.From)
if from == "" {
return nil, fmt.Errorf("rawfiles 有条目缺 from")
}
// ★ from 的语义:**相对 plg.json 所在目录(工程根)**。
//
// 绝对路径**不接受** —— 不是因为技术上做不到,而是它会引入一个
// 无法判定合法性的输入:`/etc/passwd` 与 `/home/me/proj/x` 在语法上
// 没有区别,只能靠"是否在工程内"事后判断,而那个判断依赖 cwd
// (构建时 cwd 不保证是工程根)⇒ 同一个 plg.json 在不同目录下构建
// 结果不同,**构建不可复现**。
//
// 所以:拒绝绝对路径,只接受相对路径。这样 Clean 后前缀比较就是完备的
// (实测发现:初版允许绝对路径,`"a/../../../etc/passwd"` 能绕出去)。
if filepath.IsAbs(from) {
return nil, fmt.Errorf(
"rawfiles 不接受绝对路径: %q(请写成相对工程根的路径)", from)
}
abs := filepath.Join(root, from)
cleanRoot := filepath.Clean(root)
cleanAbs := filepath.Clean(abs)
if cleanAbs != cleanRoot && !strings.HasPrefix(cleanAbs, cleanRoot+string(os.PathSeparator)) {
return nil, fmt.Errorf(
"rawfiles 路径逃出工程目录: %q(资源必须放在工程内)", from)
}
info, err := os.Stat(abs)
if err != nil {
if d.Optional {
fmt.Printf(" warn: rawfile 可选源缺失,跳过: %s\n", from)
continue
}
return nil, fmt.Errorf("rawfiles 源不存在: %s(要允许缺失请加 optional)", from)
}
base := filepath.Base(filepath.Clean(from))
if !info.IsDir() {
// 显式声明也排除脚手架(同一套规则,避免"两条来源行为不一致")
if IsRawFileScaffold(from) {
continue
}
out = append(out, rawEntry{
src: abs,
zip: filepath.ToSlash(filepath.Join(RawFilesPrefix, base)),
})
continue
}
err = filepath.Walk(abs, func(p string, fi os.FileInfo, werr error) error {
if werr != nil {
return werr
}
if fi.IsDir() {
return nil
}
rel, err := filepath.Rel(abs, p)
if err != nil {
return err
}
relSlash := filepath.ToSlash(rel)
if IsRawFileScaffold(relSlash) {
return nil
}
for _, ex := range d.Exclude {
ex = filepath.ToSlash(strings.TrimSpace(ex))
if ex != "" && strings.HasPrefix(relSlash, ex) {
return nil
}
}
out = append(out, rawEntry{
src: p,
zip: filepath.ToSlash(filepath.Join(RawFilesPrefix, base, rel)),
})
return nil
})
if err != nil {
return nil, fmt.Errorf("展开 rawfiles 目录 %s 失败: %w", from, err)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].zip < out[j].zip })
// 撞名检测:两条来源落到同一包内路径 —— 不报的话"我改的没生效"极难查
seen := map[string]string{}
for _, e := range out {
if prev, dup := seen[e.zip]; dup {
return nil, fmt.Errorf("rawfile 目标冲突: %q 同时来自 %s 与 %s", e.zip, prev, e.src)
}
seen[e.zip] = e.src
}
return out, nil
}
// writeRawEntries 把资源写进 zip(**保留路径**)。
//
// ★ 与既有 createHmap 的区别:那个用 `filepath.Base(path)` 当条目名
// (只适合单文件),目录进去会全部拍平撞名。rawfile 必须保留结构,
// 否则 `rawfile/main/foo.json` 会变成 `foo.json` 落在插件目录根上。
func writeRawEntries(w *zip.Writer, entries []rawEntry) error {
for _, e := range entries {
info, err := os.Stat(e.src)
if err != nil {
return fmt.Errorf("rawfile 源消失: %s: %w", e.src, err)
}
hdr, err := zip.FileInfoHeader(info)
if err != nil {
return err
}
hdr.Method = zip.Deflate
hdr.Name = e.zip // ← 完整路径,不是 Base
writer, err := w.CreateHeader(hdr)
if err != nil {
return err
}
src, err := os.Open(e.src)
if err != nil {
return err
}
if _, err := io.Copy(writer, src); err != nil {
src.Close()
return err
}
src.Close()
}
return nil
}
// rawFilesSummary 给构建输出一行摘要。
func rawFilesSummary(entries []rawEntry) string {
if len(entries) == 0 {
return ""
}
var total int64
for _, e := range entries {
if fi, err := os.Stat(e.src); err == nil {
total += fi.Size()
}
}
return fmt.Sprintf("%d 个 rawfile(%.1f MiB)", len(entries), float64(total)/1048576)
}
// IsRawFileScaffold 判断某个**相对路径**是否是版本控制/目录占位用的脚手架文件。
//
// 这类文件随工程存在,但不是随包资源:
// - `.gitkeep` 空目录占位
// - `.gitignore` rawfile/ 自带的忽略规则
// - `.DS_Store` macOS Finder 的目录元数据(常被误提交)
//
// 排除它们既是"别把工程内部约定发给运行期",也是**去噪**:
// 不排的话一个空工程会报"已打包 2 个 rawfile",误导开发者以为包里有东西。
func IsRawFileScaffold(rel string) bool {
base := filepath.Base(filepath.ToSlash(rel))
switch base {
case ".gitkeep", ".gitignore", ".DS_Store":
return true
}
// 任意层级下的这些名字都不算资源
return false
}