mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-09-30 22:33:03 +00:00
配套核心仓「webui 通用反向代理」。SDK 1.4.0 尚未发布,接口未冻结,
本次按开发期自由变更处理(正式发版时并入版本号推进)。
## 声明契约
plugin.json 的 proxies 字段(声明式,静态可发现)或 RegisterProxy
(运行期,供没有 plugin.json 的内置插件用):
{"name":"ui","host":"myapp","path":"/p/myapp","strip_path":true,
"target":"127.0.0.1:12100","auth":"homeagent"}
命名与既有能力对齐(ToolDef / ChannelDef / ConfigDef / RegisterTool /
ToolRegistrar)——第一版写成 ProxyDecl / DeclareProxy / ProxyDeclarer
被评审指出「跟 SDK 其他接口不是一个风格」,已全面改名。
## strip_path:Path 的两种语义
Path 不能一刀切成「原样保留」,真实需求有两种且**不能自动判定**
(同一个 path 在两种语义下都说得通,猜错即全部 404 且像上游故障):
strip_path 缺省/false(别名模式)—— path 是上游真实路径的一部分
/api/v1/device/ws + path=/api/v1/device → 上游收到原样
适用:客户端**已硬编码**路径的机器接口(设备网关即如此)
strip_path=true(前缀模式)—— path 只是门户上的挂载点
/p/myapp/api/status + path=/p/myapp → 上游收到 /api/status
适用:自带 UI 的服务(前端用相对路径)
非法组合(strip_path 而无 path)被 ValidateProxyDef 挡住。
## 单一入口原则(契约级要求)
一个声明 = 一个入口。两种挂载形态对「根路径」处理截然不同:
Host 形态下根路径是插件的根(fetch('/api/x') 天然正确);
Path 形态下根路径**属于门户**,同样代码会打到门户自己身上
(静默错路由:页面能开、功能全坏)。
故被反代的插件必须**一律使用相对路径**,绝不硬编码以 / 开头的绝对路径。
这样同一份前端在两种形态下都正确,插件不必知道自己被挂在哪,
反代层也能按外部条件(子域是否有证书/放行)自由选择形态。
## 判据
sdk/proxy_test.go:两种语义的映射、非法组合、单一入口原则的契约存在性。
hmapdev proxy_config_test.go:schema 漂移保护(新增字段忘了同步就判红)、
非法声明在**打包时**就被拒(不必装到 HomeAgent 才看到)。
两模块 go test 全绿;文档站已重新生成(ProxyDef 与单一入口原则进入
docs/api/misc.md 与 llms-full.txt)。
## 顺带修回的一处(此前随工作树丢失)
writePluginJSON 漏写 proxies 键 —— 漏写的话插件装得上、启动正常、
就是不出现,没有任何报错。该 bug 曾在核心仓侧出现过(判据抓到过),
这次移植时由 TestWritePluginJSONPreservesProxies 再次判红并修复。
431 lines
14 KiB
Go
431 lines
14 KiB
Go
package main
|
||
|
||
import (
|
||
"fmt"
|
||
"net"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"text/template"
|
||
)
|
||
|
||
func (p *PlgConfig) ReplacesToSlice() []string {
|
||
var s []string
|
||
for from, to := range p.Replaces {
|
||
s = append(s, from+"="+to)
|
||
}
|
||
sort.Strings(s) // deterministic order
|
||
return s
|
||
}
|
||
|
||
// validateProxies 在打包前校验反代声明,让插件作者**本地**就发现写错,
|
||
// 而不是装到 HomeAgent 上才看到「声明被拒」。
|
||
//
|
||
// 校验规则与 SDK 的 sdk.ValidateProxyDef 保持一致(同一套 DNS label / auth /
|
||
// target 规则);工具链不 import SDK 是为了保持"打包机只需工具链"的独立性,
|
||
// 两侧一致性由 SDK 仓与主仓的同名测试分别钉住。
|
||
func validateProxies(list []ProxyConfig) error {
|
||
seen := map[string]bool{}
|
||
for i, p := range list {
|
||
if strings.TrimSpace(p.Target) == "" {
|
||
return fmt.Errorf("proxies[%d] (%s): target 不能为空", i, p.Name)
|
||
}
|
||
switch p.Auth {
|
||
case "", "homeagent", "none":
|
||
default:
|
||
return fmt.Errorf("proxies[%d] (%s): auth 只允许 \"\"/\"homeagent\"/\"none\",得到 %q", i, p.Name, p.Auth)
|
||
}
|
||
if p.Host != "" {
|
||
if !validHostLabel(p.Host) {
|
||
return fmt.Errorf("proxies[%d] (%s): host %q 不是合法子域名标签(小写字母/数字/连字符,不以连字符开头结尾,≤63)", i, p.Name, p.Host)
|
||
}
|
||
if seen[p.Host] {
|
||
return fmt.Errorf("proxies[%d]: host %q 在同一声明里重复", i, p.Host)
|
||
}
|
||
seen[p.Host] = true
|
||
}
|
||
// 端口必须是数字:SplitHostPort 不校验数字,"host:abc" 会溜过去
|
||
raw := p.Target
|
||
if j := strings.Index(raw, "://"); j >= 0 {
|
||
raw = raw[j+3:]
|
||
}
|
||
if j := strings.IndexByte(raw, '/'); j >= 0 {
|
||
raw = raw[:j]
|
||
}
|
||
if h, port, err := net.SplitHostPort(raw); err == nil {
|
||
if h == "" {
|
||
return fmt.Errorf("proxies[%d] (%s): target %q 缺少主机", i, p.Name, p.Target)
|
||
}
|
||
if n, err := strconv.Atoi(port); err != nil || n < 1 || n > 65535 {
|
||
return fmt.Errorf("proxies[%d] (%s): target 端口非法(应为 1-65535): %q", i, p.Name, p.Target)
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func validHostLabel(s string) bool {
|
||
if s == "" || len(s) > 63 || s[0] == '-' || s[len(s)-1] == '-' {
|
||
return false
|
||
}
|
||
for i := 0; i < len(s); i++ {
|
||
c := s[i]
|
||
switch {
|
||
case c >= 'a' && c <= 'z', c >= '0' && c <= '9', c == '-':
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
type PlgConfig struct {
|
||
Name string `json:"name"`
|
||
NameZh string `json:"name_zh"`
|
||
NameEn string `json:"name_en"`
|
||
Version string `json:"version"`
|
||
Description string `json:"description"`
|
||
Author string `json:"author"`
|
||
Entry string `json:"entry"`
|
||
Tags []string `json:"tags"`
|
||
Targets string `json:"targets"`
|
||
OutDir string `json:"outdir,omitempty"`
|
||
Bundle *bool `json:"bundle,omitempty"`
|
||
SDKPath string `json:"sdk_path,omitempty"`
|
||
GoVersion string `json:"go_version,omitempty"`
|
||
Replaces map[string]string `json:"replaces,omitempty"`
|
||
SourceDirs []string `json:"source_dirs,omitempty"`
|
||
|
||
// SDK 声明本插件针对的 SDK **接口版本**(中版本或完整版本,如 "1.2" / "1.2.1")。
|
||
//
|
||
// 为何需要:工具链存储里可能装有多个 SDK 版本,而插件产物与内核是协议绑定的——
|
||
// 不给声明就只能猜(旧行为是直接用 current:谁改过 current 就拿谁的版本编,
|
||
// 出错时表现为莫名其妙的编译错误)。写中版本表示「只要 1.2 这条接口线,
|
||
// 补丁由工具链挑最新」(patch 只含工具链/打包修复,接口不变,见 README 版本语义)。
|
||
SDK string `json:"sdk,omitempty"`
|
||
|
||
// Proxies 声明本插件需要 HomeAgent 反代出去的服务(自带 Web UI / HTTP API)。
|
||
//
|
||
// 为什么声明在 plugin.json 而不是运行期注册:静态可发现(插件没起来时
|
||
// 也能报「声明了 ui 但目标不可达」,而不是静默 404)、可版本化、旧内核无害。
|
||
// 字段语义见 SDK 的 sdk.ProxyDef(工具链与内核共用同一套校验规则)。
|
||
Proxies []ProxyConfig `json:"proxies,omitempty"`
|
||
|
||
// ResolvedSDK 是本次构建实际选中的 SDK 版本(build 按 SDK 声明解析后回填),
|
||
// 只写进产物里的 plugin.json,便于事后追溯「这个 .hmap 是哪版 SDK 编的」。
|
||
ResolvedSDK string `json:"-"`
|
||
}
|
||
|
||
// TargetList parses the Targets string into a slice.
|
||
func (p *PlgConfig) TargetList() []string { return parseTargets(p.Targets) }
|
||
|
||
// BundleDefault returns true if bundle mode is not explicitly disabled.
|
||
func (p *PlgConfig) BundleDefault() bool { return p.Bundle == nil || *p.Bundle }
|
||
|
||
// OutDirDefault returns the output directory, defaulting to "dist".
|
||
func (p *PlgConfig) OutDirDefault() string {
|
||
if p.OutDir != "" {
|
||
return p.OutDir
|
||
}
|
||
return "dist"
|
||
}
|
||
|
||
type TemplateData struct {
|
||
Plg PlgConfig
|
||
IsLua bool
|
||
|
||
// Go module info (for go.mod)
|
||
ModulePath string
|
||
GoVersion string
|
||
SDKModule string
|
||
SDKVersion string
|
||
|
||
// SDKLocalPath 是本机 SDK 源码绝对路径,写入生成的 go.mod 作为 replace 目标。
|
||
//
|
||
// 为何必须写:gitcode 的模块不在 proxy.golang.org 上,只 require 一个
|
||
// 版本号的 go.mod 配上缺失的 go.sum,新用户第一次 `hmapdev build`
|
||
// 必定死在 "missing go.sum entry",而 `go mod tidy` 又会去公共 proxy 拉
|
||
// 一个不存在的条目。有了本地 replace,go 完全不需要 go.sum 条目。
|
||
SDKLocalPath string
|
||
}
|
||
|
||
func cmdInit(args []string) {
|
||
if len(args) < 1 {
|
||
fmt.Println("Usage: hmapdev init <name> [--lua] [--type remotedevice]")
|
||
os.Exit(1)
|
||
}
|
||
|
||
name := args[0]
|
||
isLua := false
|
||
isRemoteDevice := false
|
||
for _, a := range args[1:] {
|
||
switch a {
|
||
case "--lua":
|
||
isLua = true
|
||
case "--type", "-t":
|
||
// handled in next iteration
|
||
}
|
||
}
|
||
// also check --type remotedevice as a single arg
|
||
for i, a := range args[1:] {
|
||
if a == "--type" || a == "-t" {
|
||
if i+1 < len(args[1:]) {
|
||
if args[1:][i+1] == "remotedevice" {
|
||
isRemoteDevice = true
|
||
}
|
||
}
|
||
}
|
||
if a == "--type=remotedevice" || a == "-t=remotedevice" {
|
||
isRemoteDevice = true
|
||
}
|
||
}
|
||
|
||
if isRemoteDevice && isLua {
|
||
fmt.Println("error: --type remotedevice and --lua are mutually exclusive")
|
||
os.Exit(1)
|
||
}
|
||
|
||
// Remote device projects use different scaffold
|
||
if isRemoteDevice {
|
||
scaffoldRemoteDevice(name)
|
||
return
|
||
}
|
||
|
||
dir := name
|
||
if _, err := os.Stat(dir); !os.IsNotExist(err) {
|
||
fmt.Printf("error: directory %q already exists\n", dir)
|
||
os.Exit(1)
|
||
}
|
||
|
||
// Go 插件统一产出 plugin.bin(v1.0.0 子进程模式)。
|
||
//
|
||
// 此前这里写 "plugin.so",scaffold 出来的 plg.json 就带着一个已退场的
|
||
// entry 值,新手跟着模板走会误以为自己在做 C ABI 插件。
|
||
// build 实际不看这个值(只用它区分 Lua),但模板不应误导。
|
||
entry := "plugin.bin"
|
||
var targets string
|
||
if isLua {
|
||
entry = "main.lua"
|
||
targets = "lua"
|
||
} else {
|
||
targets = "linux/amd64,windows/amd64"
|
||
}
|
||
|
||
nameEn := strings.ReplaceAll(name, "-", " ")
|
||
nameEn = strings.Title(nameEn)
|
||
|
||
data := TemplateData{
|
||
Plg: PlgConfig{
|
||
Name: name,
|
||
NameZh: "中文名",
|
||
NameEn: nameEn,
|
||
Version: "0.1.0",
|
||
Description: name + " plugin",
|
||
Author: "HomeAgent",
|
||
Entry: entry,
|
||
Tags: []string{name},
|
||
Targets: targets,
|
||
},
|
||
IsLua: isLua,
|
||
}
|
||
|
||
// Detect SDK info for Go plugin go.mod.
|
||
// 生成的 go.mod 除 require 外还写一条指向本机 SDK 的 replace:
|
||
// 否则 scaffold 出来的项目第一次 build 必定失败(详见 SDKLocalPath 注释)。
|
||
if isLua {
|
||
// Lua 插件也要记录它按哪版 SDK 语义编写:Lua `sdk.*` 是公开契约,
|
||
// 与内核能力版本挂钩;不写版本就只能靠“调用时才发现是 nil”。
|
||
if root := tryActiveSDKRoot(); root != "" {
|
||
data.Plg.SDK = normalizeSDKVersion(readMetaVersion(root))
|
||
}
|
||
} else {
|
||
sdkMod, goVer, sdkRoot, sdkVer := detectSDKInfo()
|
||
data.ModulePath = name
|
||
data.GoVersion = goVer
|
||
data.SDKModule = sdkMod
|
||
data.SDKVersion = "v" + sdkVer
|
||
data.SDKLocalPath = strings.ReplaceAll(sdkRoot, "\\", "/")
|
||
// 声明**完整版本号**:SDK 版本跟随内核中版本、patch 位恒为 .0,
|
||
// 一条内核线只对应一个 SDK 版本(build 时按此解析,见 ResolveSDKForProject)。
|
||
data.Plg.SDK = normalizeSDKVersion(sdkVer)
|
||
}
|
||
|
||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||
fmt.Printf("error: create dir: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
|
||
// write plg.json
|
||
writeTemplate(filepath.Join(dir, "plg.json"), tmplPlgJSON, data)
|
||
|
||
// Lua plugins get main.lua + sdk.lua; Go plugins get plugin.go only
|
||
if isLua {
|
||
writeTemplate(filepath.Join(dir, "main.lua"), tmplMainLua, data)
|
||
// sdk.lua 是给 `lua main.lua` 离线测试用的 mock,单一事实源在 SDK 仓的
|
||
// sdk/lua/sdk.lua;优先从当前激活的 SDK 拷,拷不到才回退内嵌模板。
|
||
if !copyCanonicalLuaSDK(dir) {
|
||
if err := os.WriteFile(filepath.Join(dir, "sdk.lua"), []byte(fallbackLuaSDK), 0644); err != nil {
|
||
fmt.Printf("error: write sdk.lua: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
}
|
||
} else {
|
||
writeTemplate(filepath.Join(dir, "plugin.go"), tmplPluginGo, data)
|
||
}
|
||
|
||
// write README.md
|
||
writeTemplate(filepath.Join(dir, "README.md"), tmplReadme, data)
|
||
|
||
// write go.mod for Go plugins
|
||
if !isLua {
|
||
writeTemplate(filepath.Join(dir, "go.mod"), tmplGoMod, data)
|
||
}
|
||
|
||
// create thirdpart directory for external library sources
|
||
os.MkdirAll(filepath.Join(dir, "thirdpart"), 0755)
|
||
|
||
fmt.Printf("Created plugin project %q (%s)\n", dir, entry)
|
||
if isLua {
|
||
fmt.Printf(" cd %s && lua main.lua (standalone test)\n", dir)
|
||
}
|
||
fmt.Printf(" cd %s && hmapdev build\n", dir)
|
||
}
|
||
|
||
// activeSDKRoot 返回当前激活 SDK 的根目录,未安装/未激活则报错退出。
|
||
//
|
||
// 与 activeSDKRoot(fatal 版)区别:这里只探测,不退出。
|
||
// Lua 插件的 mock 是“锦上添花”,没装 SDK 不应该阻断 init。
|
||
func tryActiveSDKRoot() string {
|
||
store := sdkStore()
|
||
current := resolveCurrentVersion(store)
|
||
if current == "" {
|
||
return ""
|
||
}
|
||
root := sdkVersionDir(current)
|
||
if _, err := os.Stat(root); err != nil {
|
||
return ""
|
||
}
|
||
return root
|
||
}
|
||
|
||
// copyCanonicalLuaSDK 把激活 SDK 的 sdk/lua/sdk.lua 拷进新项目。
|
||
// 三份 sdk.lua(内核内嵌 / 工具链模板 / 项目副本)各自漂移是本工具链的历史债,
|
||
// 单一事实源在 SDK 仓,工具链只负责搬运。返回是否成功。
|
||
func copyCanonicalLuaSDK(dir string) bool {
|
||
root := tryActiveSDKRoot()
|
||
if root == "" {
|
||
return false
|
||
}
|
||
src := filepath.Join(root, "sdk", "lua", "sdk.lua")
|
||
data, err := os.ReadFile(src)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
if err := os.WriteFile(filepath.Join(dir, "sdk.lua"), data, 0644); err != nil {
|
||
return false
|
||
}
|
||
fmt.Printf(" sdk.lua <- %s\n", src)
|
||
return true
|
||
}
|
||
|
||
// detectSDKInfo reads the HomeAgent SDK's go.mod and meta to get module path, go version, and SDK version.
|
||
func detectSDKInfo() (modulePath, goVersion, sdkPath, sdkVersion string) {
|
||
root := activeSDKRoot()
|
||
gomodPath := filepath.Join(root, "go.mod")
|
||
data, err := os.ReadFile(gomodPath)
|
||
if err != nil {
|
||
fmt.Printf("error: cannot read SDK go.mod at %s: %v\n", gomodPath, err)
|
||
os.Exit(1)
|
||
}
|
||
|
||
for _, line := range strings.Split(string(data), "\n") {
|
||
line = strings.TrimSpace(line)
|
||
if strings.HasPrefix(line, "module ") {
|
||
modulePath = strings.TrimSpace(line[7:])
|
||
}
|
||
if strings.HasPrefix(line, "go ") {
|
||
goVersion = strings.TrimSpace(line[3:])
|
||
}
|
||
}
|
||
if modulePath == "" {
|
||
fmt.Printf("error: no module directive in %s\n", gomodPath)
|
||
os.Exit(1)
|
||
}
|
||
if goVersion == "" {
|
||
goVersion = "1.21"
|
||
}
|
||
sdkVersion = readMetaVersion(root)
|
||
return modulePath, goVersion, root, sdkVersion
|
||
}
|
||
|
||
// scaffoldRemoteDevice 创建远程设备适配器项目脚手架
|
||
func scaffoldRemoteDevice(name string) {
|
||
dir := name
|
||
if _, err := os.Stat(dir); !os.IsNotExist(err) {
|
||
fmt.Printf("error: directory %q already exists\n", dir)
|
||
os.Exit(1)
|
||
}
|
||
|
||
nameEn := strings.Title(strings.ReplaceAll(name, "-", " "))
|
||
|
||
data := TemplateData{
|
||
Plg: PlgConfig{
|
||
Name: name,
|
||
NameZh: "中文名",
|
||
NameEn: nameEn,
|
||
Version: "0.1.0",
|
||
Description: name + " remote device adapter",
|
||
Author: "HomeAgent",
|
||
Entry: name,
|
||
Tags: []string{name, "remotedevice"},
|
||
},
|
||
}
|
||
|
||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||
fmt.Printf("error: create dir: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
|
||
// 写入 main.c
|
||
writeTemplate(filepath.Join(dir, "main.c"), tmplRemoteDeviceMain, data)
|
||
|
||
// 写入 CMakeLists.txt
|
||
writeTemplate(filepath.Join(dir, "CMakeLists.txt"), tmplRemoteDeviceCMake, data)
|
||
|
||
// 创建 SDK 目录(symlink/copy)
|
||
sdkSrc := filepath.Join("..", "remotedevice")
|
||
sdkDst := filepath.Join(dir, "ha_remotedevice")
|
||
if _, err := os.Stat(sdkDst); os.IsNotExist(err) {
|
||
// 尝试创建符号链接,失败则提示
|
||
if err := os.Symlink(sdkSrc, sdkDst); err != nil {
|
||
fmt.Printf(" note: could not create symlink to SDK, copy manually:\n")
|
||
fmt.Printf(" cp -r %s %s\n", sdkSrc, sdkDst)
|
||
}
|
||
}
|
||
|
||
fmt.Printf("Created remote device adapter project %q\n", dir)
|
||
fmt.Printf(" cd %s && mkdir build && cd build && cmake .. && make\n", dir)
|
||
fmt.Printf(" Or include as subdirectory in your project:\n")
|
||
fmt.Printf(" add_subdirectory(%s)\n", dir)
|
||
}
|
||
|
||
func writeTemplate(path, content string, data TemplateData) {
|
||
tmpl, err := template.New("").Parse(content)
|
||
if err != nil {
|
||
fmt.Printf("error: parse template: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
f, err := os.Create(path)
|
||
if err != nil {
|
||
fmt.Printf("error: create %s: %v\n", path, err)
|
||
os.Exit(1)
|
||
}
|
||
defer f.Close()
|
||
if err := tmpl.Execute(f, data); err != nil {
|
||
fmt.Printf("error: execute template: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
}
|