docs+plugindev: README 同步子进程架构,scaffold 修 entry 与 go.sum 死路

三类问题,都会让新用户第一次上手就走错:

1. README 仍写 plugin.so
   plugin.json 示例的 entry、entry 字段说明、.hmap 包格式三处描述的都是
   已退场的 C ABI 产物。改为 plugin.bin,并补 bundle 模式下
   plugin.bin.<goos>.<goarch> 的命名与安装时挑平台的行为。

2. cmd_init.go 的 entry := "plugin.so"
   scaffold 出来的 plg.json 带着一个已退场的 entry 值。build 实际不看这个
   值(只用它区分 Lua),但跟着模板走会误以为自己在做 C ABI 插件。

3. 生成的项目第一次 build 必定失败
   go.mod 只 require 一个 gitcode 模块版本号且不生成 go.sum。gitcode 不在
   proxy.golang.org 上,于是:
     go build     → missing go.sum entry
     go mod tidy  → 去公共 proxy 拉一个不存在的条目,超时
   原来 cmdBuild 里那句 `go mod download <mod>` 走的正是这条死路,失败后
   只打一行 warn 就继续编译,紧接着死在同一个错误上——用户看到两段无关报错。

   修法分两处:
   - 生成的 go.mod 直接写指向本机 SDK 的 replace(replace 到目录时 go 不
     需要也不校验 go.sum)
   - 新增 ensureSDKResolvable:三级策略(已有本地 replace → 探测本机 SDK
     并写入 → 兜底 go mod tidy 带 -mod=mod,失败给可操作提示)。存量项目
     go.mod 无 replace 时走第二级救回。

bin/ 5 个预编译二进制不再进仓库(改为 release 附件):
5 个平台各 26-28MB,每次重编都在 git 历史里再叠一份,而它们本质是可从源码
复现的产物。README 的下载说明同步改为 release 附件 URL + 从源码编译。
This commit is contained in:
JianFeeeee
2026-09-03 19:27:03 +08:00
parent 092d8f4ab0
commit e256023399
11 changed files with 203 additions and 34 deletions

5
.gitignore vendored
View File

@ -10,6 +10,11 @@ plugin.json
build/ build/
dist/ dist/
# plugindev 预编译二进制:只作为 release 附件分发,不进仓库历史。
# 此前 5 个平台各 26-28MB 被 git 跟踪(约 137MB每次重编都在历史里
# 再叠一份,而它们本质是可从源码复现的产物。
bin/
# Test artifacts # Test artifacts
testdist/ testdist/

View File

@ -142,13 +142,21 @@ func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageReg
## plugindev 工具链 ## plugindev 工具链
`plugindev` 提供插件开发全流程支持。仓库 `bin/` 提供各平台预制二进制linux/darwin/windows × amd64/arm64下载后直接加入 PATH 即可: `plugindev` 提供插件开发全流程支持。预编译二进制作为 **release 附件**分发linux/darwin/windows × amd64/arm64
[Releases](https://gitcode.com/JianFeeeee/homeagent-sdk/releases) 下载后加入 PATH 即可:
```bash ```bash
curl -o plugindev https://gitcode.com/JianFeeeee/homeagent-sdk/-/raw/main/bin/plugindev_linux_amd64 # 从 release 附件下载(以 v1.0.0 / linux amd64 为例)
curl -Lo plugindev https://gitcode.com/JianFeeeee/homeagent-sdk/releases/download/v1.0.0/plugindev_linux_amd64
chmod +x plugindev chmod +x plugindev
# 或从源码自己编
cd tools/plugindev && go build -o plugindev .
``` ```
> 二进制不再随仓库分发(旧的 `bin/` 目录已停用5 个平台各 26-28MB
> 每次重编都在 git 历史里再叠一份,而它们本质是可从源码复现的产物。
| 命令 | 说明 | | 命令 | 说明 |
|------|------| |------|------|
| `plugindev init <name> [--lua]` | 初始化插件项目(生成 plg.json、plugin.go 或 main.lua、go.mod、README.md | | `plugindev init <name> [--lua]` | 初始化插件项目(生成 plg.json、plugin.go 或 main.lua、go.mod、README.md |
@ -180,7 +188,7 @@ chmod +x plugindev
"version": "1.0.0", "version": "1.0.0",
"description": "天气查询插件", "description": "天气查询插件",
"author": "HomeAgent", "author": "HomeAgent",
"entry": "plugin.so", "entry": "plugin.bin",
"tags": ["weather", "forecast"], "tags": ["weather", "forecast"],
"targets": "linux/amd64,windows/amd64", "targets": "linux/amd64,windows/amd64",
"outdir": "dist", "outdir": "dist",
@ -202,7 +210,7 @@ chmod +x plugindev
| `version` | string | 版本号 | | `version` | string | 版本号 |
| `description` | string | 插件描述 | | `description` | string | 插件描述 |
| `author` | string | 作者 | | `author` | string | 作者 |
| `entry` | string | 入口文件(`plugin.so` / `plugin.dll` / `main.lua` | | `entry` | string | 入口文件(`plugin.bin` / `main.lua`。v1.0.0 起 Go 插件统一为 `plugin.bin`,不再区分平台后缀 |
| `tags` | string[] | 标签 | | `tags` | string[] | 标签 |
| `targets` | string | 构建目标,逗号分隔(如 `linux/amd64,windows/amd64`Lua 插件为 `lua` | | `targets` | string | 构建目标,逗号分隔(如 `linux/amd64,windows/amd64`Lua 插件为 `lua` |
| `outdir` | string | 输出目录(默认 `dist` | | `outdir` | string | 输出目录(默认 `dist` |
@ -217,11 +225,14 @@ chmod +x plugindev
`.hmap` 为 ZIP 归档,包含: `.hmap` 为 ZIP 归档,包含:
- `plugin.json` — 插件元数据 - `plugin.json` — 插件元数据
- `plugin.so` — Go 编译产物(Linux - `plugin.bin` — Go 编译产物(单平台构建
- `plugin.dll` — Go 编译产物Windows - `plugin.bin.<goos>.<goarch>` — 多平台 bundle 模式下每平台一份,
- `plugin.dylib` — Go 编译产物macOSbundle 模式) 安装时 pluginmgr 挑当前平台那份重命名为 `plugin.bin`
- `main.lua` — Lua 插件入口Lua 插件时) - `main.lua` — Lua 插件入口Lua 插件时)
> v1.0.0 起不再使用 `plugin.so`/`plugin.dll`/`plugin.dylib`——进程边界即 ABI 边界,
> 不存在平台特定的动态库区分。旧产物新内核不会加载,会给出明确的重编提示。
## 插件生命周期 ## 插件生命周期
### 入口函数 ### 入口函数

View File

@ -142,14 +142,30 @@ Plugin developers only need to implement the `Plugin` interface and export a `Ne
## plugindev Toolchain ## plugindev Toolchain
`plugindev` provides full development workflow support: `plugindev` provides full development workflow support. Prebuilt binaries ship as **release assets**
(linux/darwin/windows × amd64/arm64); download from
[Releases](https://gitcode.com/JianFeeeee/homeagent-sdk/releases) and put it on your PATH:
```bash
# From release assets (v1.0.0 / linux amd64 shown)
curl -Lo plugindev https://gitcode.com/JianFeeeee/homeagent-sdk/releases/download/v1.0.0/plugindev_linux_amd64
chmod +x plugindev
# Or build from source
cd tools/plugindev && go build -o plugindev .
```
> Binaries no longer ship inside the repository (the old `bin/` directory is retired): five
> platforms at 26-28MB each piled another copy into git history on every rebuild, and they are
> reproducible from source anyway.
| Command | Description | | Command | Description |
|---------|-------------| |---------|-------------|
| `plugindev init` | Initialize plugin project (generates plg.json, entry template) | | `plugindev init <name> [--lua]` | Initialize plugin project (generates plg.json, plugin.go or main.lua, go.mod, README.md) |
| `plugindev build` | Build plugin, output .hmap package | | `plugindev build [flags]` | Build and package into a `.hmap` (supports cross-compilation and bundle mode) |
| `plugindev clean` | Clean build artifacts | | `plugindev clean` | Clean `build/` and `dist/` plus generated files |
| `plugindev debug` | Run plugin in local debug mode | | `plugindev debug [dir]` | Load plugin source through the Yaegi Go interpreter and start an interactive REPL |
| `plugindev sdk <command>` | SDK version management (list/install/use/path/current/latest) |
Supports both **Go** and **Lua** plugin languages. Supports both **Go** and **Lua** plugin languages.
@ -163,7 +179,7 @@ Supports both **Go** and **Lua** plugin languages.
"version": "1.0.0", "version": "1.0.0",
"description": "Weather plugin", "description": "Weather plugin",
"author": "HomeAgent", "author": "HomeAgent",
"entry": "plugin.so", "entry": "plugin.bin",
"tags": ["weather", "forecast"], "tags": ["weather", "forecast"],
"targets": "linux/amd64,windows/amd64", "targets": "linux/amd64,windows/amd64",
"outdir": "dist", "outdir": "dist",
@ -185,7 +201,7 @@ Supports both **Go** and **Lua** plugin languages.
| `version` | string | Version | | `version` | string | Version |
| `description` | string | Plugin description | | `description` | string | Plugin description |
| `author` | string | Author | | `author` | string | Author |
| `entry` | string | Entry file (`plugin.so` / `main.lua`) | | `entry` | string | Entry file (`plugin.bin` / `main.lua`). Since v1.0.0 Go plugins uniformly build to `plugin.bin`—no per-platform suffix |
| `tags` | string[] | Tags | | `tags` | string[] | Tags |
| `targets` | string | Build targets, comma-separated (e.g. `linux/amd64,windows/amd64`) | | `targets` | string | Build targets, comma-separated (e.g. `linux/amd64,windows/amd64`) |
| `outdir` | string | Output directory (default `dist`) | | `outdir` | string | Output directory (default `dist`) |
@ -198,10 +214,15 @@ Supports both **Go** and **Lua** plugin languages.
`.hmap` is a ZIP archive containing: `.hmap` is a ZIP archive containing:
- `plugin.json` — plugin metadata - `plugin.json` — plugin metadata
- `plugin.so` — Go compiled artifact (Linux) - `plugin.bin` — Go compiled artifact (single-platform build)
- `plugin.dll` — Go compiled artifact (Windows) - `plugin.bin.<goos>.<goarch>` — one per platform in bundle mode; on install pluginmgr picks
the one matching the current platform and renames it to `plugin.bin`
- `main.lua` — Lua plugin entry (for Lua plugins) - `main.lua` — Lua plugin entry (for Lua plugins)
> Since v1.0.0 `plugin.so`/`plugin.dll`/`plugin.dylib` are no longer used—the process boundary
> *is* the ABI boundary, so there is no platform-specific shared-library distinction. The new
> kernel will not load old artifacts; it emits an explicit rebuild hint instead.
## Plugin Lifecycle ## Plugin Lifecycle
### Start & Stop ### Start & Stop

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -73,18 +73,9 @@ func cmdBuild(args []string) {
// Ensure go.mod exists with correct SDK path // Ensure go.mod exists with correct SDK path
sdkModule := ensureGoMod(plg, sdkPath) sdkModule := ensureGoMod(plg, sdkPath)
// First build: fetch the SDK module (generates go.sum with zip hash) // 保证 SDK 模块可解析,否则编译必死在 "missing go.sum entry"。
if sdkModule != "" { if sdkModule != "" {
if _, err := os.Stat("go.sum"); os.IsNotExist(err) { ensureSDKResolvable(plg, sdkModule, sdkPath)
dl := exec.Command("go", "mod", "download", sdkModule)
dl.Env = os.Environ()
dl.Stdout = os.Stdout
dl.Stderr = os.Stderr
fmt.Println(" downloading SDK module deps...")
if err := dl.Run(); err != nil {
fmt.Printf(" error: go mod download: %v\n", err)
}
}
} }
// Merge plg.json replaces + CLI overrides // Merge plg.json replaces + CLI overrides
@ -343,6 +334,128 @@ func ensureGoMod(plg *PlgConfig, sdkPath string) string {
return sdkModule return sdkModule
} }
// ensureSDKResolvable 保证 SDK 模块在编译前可解析。
//
// 为何需要这个函数gitcode 的模块不在 proxy.golang.org 上。只要 go.mod
// 里的 SDK 靠 require 版本号解析,而本地又没 go.sum 条目go build 就报
// "missing go.sum entry";而原来那句 `go mod download <mod>` 会去公共 proxy
// 拉一个永远拉不到的条目,超时后只打一行 warn 就继继编译,紧接着死在
// 同一个错误上——新用户拿到的是两段无关的报错。
//
// 三级策略,按代价递增:
// 1. go.mod 已有指向本地目录的 replace —— 什么都不用做replace 到目录时
// go 不需要也不校验 go.sum
// 2. 能定位到本机 SDK 源码 —— 写入 replace。这是存量项目go.mod 旧、
// 无 replace的救场路径。
// 3. 都不行 —— 跑 `go mod tidy`(带 -mod=mod让它自己去试失败则给
// 可操作的提示而不是让用户去猜。
func ensureSDKResolvable(plg *PlgConfig, sdkModule, sdkPath string) {
data, err := os.ReadFile("go.mod")
if err != nil {
return
}
// 策略 1已有指向本地目录的 replace。
// replace 目标带 / 或 . 开头的才是路径;指向另一个模块的 replace 不算。
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "replace ") || !strings.Contains(line, sdkModule) {
continue
}
parts := strings.Fields(line)
if len(parts) < 4 {
continue
}
target := parts[3]
if strings.HasPrefix(target, ".") || strings.HasPrefix(target, "/") ||
strings.Contains(target, ":/") || strings.Contains(target, ":\\") {
return // 已指向本地目录,无需 go.sum
}
}
// 策略 2能定位到本机 SDK 就写 replace。
// resolveSDKPath 失败会 os.Exit所以只在能确定拿到路径时调用它背后的探测。
if root := findLocalSDK(sdkPath); root != "" {
if appendGoModReplace(sdkModule, root) {
fmt.Printf(" SDK 指向本机源码(已写入 go.mod replace%s\n", root)
return
}
}
// 策略 3交给 go mod tidy。
if _, err := os.Stat("go.sum"); err == nil {
return // 已有 go.sum不插手
}
fmt.Println(" 解析 SDK 依赖go mod tidy...")
tidy := exec.Command("go", "mod", "tidy")
tidy.Env = append(os.Environ(), "GOFLAGS=-mod=mod")
if out, err := tidy.CombinedOutput(); err != nil {
fmt.Printf(" warn: go mod tidy 失败:%v\n", err)
if len(out) > 0 {
fmt.Printf(" %s\n", strings.TrimSpace(string(out)))
}
fmt.Printf(" 提示:%s 不在公共 proxy 上。用以下任一方式指向本机 SDK\n", sdkModule)
fmt.Printf(" plugindev sdk install latest # 装一份到 ~/.homeagent/plugindev/sdk\n")
fmt.Printf(" plugindev build --sdk-path <路径> # 或直接指定源码目录\n")
}
}
// findLocalSDK 探测本机 SDK 源码根目录,找不到返回空串。
//
// 与 resolveSDKPath 的区别:后者找不到就 os.Exit适合“必须有”的调用点
// 这里是“有则更好”的探测,不能把构建搞挂。
func findLocalSDK(sdkPath string) string {
candidates := []string{}
if sdkPath != "" {
if abs, err := filepath.Abs(sdkPath); err == nil {
candidates = append(candidates, abs)
}
}
// plugindev 自身所在位置往上三级tools/plugindev/plugindev → SDK 根)
if self, err := os.Executable(); err == nil {
candidates = append(candidates, filepath.Dir(filepath.Dir(filepath.Dir(self))))
}
// plugindev sdk use 选定的版本
store := os.Getenv("HOMEAGENT_SDK_DIR")
if store == "" {
if home, err := os.UserHomeDir(); err == nil {
store = filepath.Join(home, ".homeagent", "plugindev", "sdk")
}
}
if store != "" {
if d, err := os.ReadFile(filepath.Join(store, "current")); err == nil {
if ver := strings.TrimSpace(string(d)); ver != "" {
candidates = append(candidates, filepath.Join(store, ver))
}
}
}
for _, c := range candidates {
if c == "" {
continue
}
if _, err := os.Stat(filepath.Join(c, "sdk", "plugin.go")); err == nil {
return c
}
}
return ""
}
// appendGoModReplace 向 go.mod 追加一条 replace成功返回 true。
func appendGoModReplace(module, localPath string) bool {
data, err := os.ReadFile("go.mod")
if err != nil {
return false
}
abs, err := filepath.Abs(localPath)
if err != nil {
return false
}
abs = strings.ReplaceAll(abs, "\\", "/")
s := strings.TrimRight(string(data), "\r\n")
s += fmt.Sprintf("\n\nreplace %s => %s\n", module, abs)
return os.WriteFile("go.mod", []byte(s), 0644) == nil
}
func resolveSDKPath(sdkPath string) string { func resolveSDKPath(sdkPath string) string {
if sdkPath != "" { if sdkPath != "" {
abs, _ := filepath.Abs(sdkPath) abs, _ := filepath.Abs(sdkPath)
@ -465,8 +578,8 @@ func buildTarget(plg *PlgConfig, target, outDir, sdkPath string) {
} }
type binEntry struct { type binEntry struct {
src string // 磁盘路径,如 build/plugin.so src string // 磁盘路径,如 build/plugin.bin
zip string // zip 中条目名,如 plugin.so zip string // zip 中条目名,如 plugin.bin.linux.amd64
} }
// createBundleHmap 创建包含多平台二进制的 bundle .hmap 文件。 // createBundleHmap 创建包含多平台二进制的 bundle .hmap 文件。

View File

@ -59,6 +59,14 @@ type TemplateData struct {
GoVersion string GoVersion string
SDKModule string SDKModule string
SDKVersion string SDKVersion string
// SDKLocalPath 是本机 SDK 源码绝对路径,写入生成的 go.mod 作为 replace 目标。
//
// 为何必须写gitcode 的模块不在 proxy.golang.org 上,只 require 一个
// 版本号的 go.mod 配上缺失的 go.sum新用户第一次 `plugindev build`
// 必定死在 "missing go.sum entry",而 `go mod tidy` 又会去公共 proxy 拉
// 一个不存在的条目。有了本地 replacego 完全不需要 go.sum 条目。
SDKLocalPath string
} }
func cmdInit(args []string) { func cmdInit(args []string) {
@ -109,7 +117,12 @@ func cmdInit(args []string) {
os.Exit(1) os.Exit(1)
} }
entry := "plugin.so" // Go 插件统一产出 plugin.binv1.0.0 子进程模式)。
//
// 此前这里写 "plugin.so"scaffold 出来的 plg.json 就带着一个已退场的
// entry 值,新手跟着模板走会误以为自己在做 C ABI 插件。
// build 实际不看这个值(只用它区分 Lua但模板不应误导。
entry := "plugin.bin"
var targets string var targets string
if isLua { if isLua {
entry = "main.lua" entry = "main.lua"
@ -137,14 +150,15 @@ func cmdInit(args []string) {
} }
// Detect SDK info for Go plugin go.mod. // Detect SDK info for Go plugin go.mod.
// 生成的 go.mod require SDK 线上模块版本,不写本地路径 replace // 生成的 go.mod require 外还写一条指向本机 SDK 的 replace
// 本地调试请用 `plugindev build --sdk-path <path>` 或手动加 replace // 否则 scaffold 出来的项目第一次 build 必定失败(详见 SDKLocalPath 注释)
if !isLua { if !isLua {
sdkMod, goVer, _, sdkVer := detectSDKInfo() sdkMod, goVer, sdkRoot, sdkVer := detectSDKInfo()
data.ModulePath = name data.ModulePath = name
data.GoVersion = goVer data.GoVersion = goVer
data.SDKModule = sdkMod data.SDKModule = sdkMod
data.SDKVersion = "v" + sdkVer data.SDKVersion = "v" + sdkVer
data.SDKLocalPath = strings.ReplaceAll(sdkRoot, "\\", "/")
} }
if err := os.MkdirAll(dir, 0755); err != nil { if err := os.MkdirAll(dir, 0755); err != nil {

View File

@ -19,7 +19,12 @@ const tmplGoMod = `module {{.ModulePath}}
go {{.GoVersion}} go {{.GoVersion}}
require {{.SDKModule}} {{.SDKVersion}} require {{.SDKModule}} {{.SDKVersion}}
` {{if .SDKLocalPath}}
// SDK 指向本机源码。gitcode 的模块不在 proxy.golang.org 上,
// 没有这条 replace 就需要 go.sum 条目,而那个条目无处可拉。
// 若你已有可访问的私有 proxy可删掉本行。
replace {{.SDKModule}} => {{.SDKLocalPath}}
{{end}}`
const tmplPluginGo = `package main const tmplPluginGo = `package main