Files
homeagent-sdk/example/commondev/endpoints.go
JianFeeeee 81a3efa4df feat(commondev): 主管式多仓库开发流水线 + sidecar 后端抽象(pi/zcode) (#3)
* feat(commondev): 主管式多仓库开发流水线 + sidecar 后端抽象(pi/zcode)

## 是什么

agent 统筹开发流程的插件:登记项目 → 开工程线(每条线一个隔离工作目录 +
一个 sidecar 子进程 + 一对通道)→ 投递任务 → 同会话续投 → 评审/合入。

38 个工具:project_*/branch_*/workdir_*/job_*/review_*/test_*/
endpoint_*/agent_*/pi_*/conflict_check。

## sidecar 后端抽象(本轮核心)

传输层(local/ssh)与后端(pi/zcode/自定义)**正交**,四个自由组合:

  driver.json 描述一个后端:script / cliPath / env / args / llmConfig
  三级查找:插件数据目录 → **随包 rawfile** → 内置 pi 描述
  占位符:{workdir} {dataDir} {pluginDir} {rawfile} {sessionId}

★ 内置 pi 回退**与默认后端解耦**(2026-10-17 修):原实现写
`if cli == defaultAgentCLI { return builtinPiDriver() }`,把"给谁兜底"
和"默认选谁"耦合到同一常量 —— 一旦有人改默认,pi 当场失去兜底,
显式 cli="pi" 开始报「驱动不存在」。现在判断字面 "pi"。

默认后端保持 **pi**(我们本地主用);交付方用 zcode 走两种局部开关:
 · 单线:commondev_branch_open(cli="zcode")
 · 按机器:commondev_endpoint_set(host=..., cli="zcode")

## zcode 接入(三条真机判据,全部要求真改工程文件)

 · TestZcodeFullPipeline(29-34s):随包 driver + 真实 ZCode CLI
 · TestZcodeOverSSH(39s):真 ssh + scp,远端 node 跑 zcode
 · TestZcodeOverSSHWithPerHostEndpoints(68s):两个 host 别名配不同
   cli_path,走端点表而不是全局 settings

★ 会话库必须隔离到工作区(sidecar.mjs 的 ensureWorkspaceSessionStore):
实测 ZCode 的会话库路径**硬编码 homedir()**,`ZCODE_DATA_BASE_DIR` 对它
完全无效(26 个会话全落全局库)。但 `<workdir>/.zcode/config.json` 的
`storage.sessionDbPath` **确实生效**。所以启动前写一份工作区配置,
一举解决:多线不共用历史 / 不污染全局 ~/.zcode / 删线不留孤儿会话。
只在不存在时写 —— 用户手工放的要尊重。

## 其他

 · rawfile:项目根 rawfile/ 默认全收,解到 <插件目录>/rawfile/,绝对路径拒绝
 · 凭证:命名引用 + ${ENV_VAR},独立 0600 文件,绝不进 config.db(0644)
 · secretshell:给不受信 agent 用的安全 shell(ackey + safeshell)
 · 冲突检查 / 评审判据 / 镜像 / 每条线独立 workdir(hash 命名,合并后删除)

判据 30+ 条,含 4 条「判据本身的判据」(防止判据假绿)。

* 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` +§协议通道纪律(正误代码对比)

* fix(examples): 恢复 go.mod 相对路径 + 移除 plg.json 的 sdk 固定(修 CI 红)

CI 上 `Examples (linux/amd64)` 与 `(darwin/arm64)` 双红,只有两个失败:

  ✗ commondev   构建失败
  ✗ editdoc     构建失败
  error: 项目声明 sdk=1.4.0,但本地 SDK 存储里没有这个版本

其余 20 个示例全部成功 —— 说明不是通用问题,而是这两个被改坏了。

## 根因(两个叠加)

**① go.mod 被本机绝对路径污染**

我在上一个提交里用 `git add -A`,把本机 `go build` **自动改写**过的
go.mod 一并提交了:

  require github.com/JianFeeeee/homeagentsdk v1.4.0
  replace github.com/JianFeeeee/homeagentsdk => /root/.homeagent/hmapdev/sdk/v1.4.0

基线(origin/main)是:

  require github.com/JianFeeeee/homeagentsdk v0.0.0
  replace github.com/JianFeeeee/homeagentsdk => ../../

本机那个目录真的存在 ⇒ 本地全绿;CI 没有 ⇒ 必红。

**② plg.json 声明了 `"sdk": "1.4.0"`**

一声明版本,hmapdev 就去**本地 SDK store** 找它,找不到直接拒绝构建。
全仓 20+ 示例**都不声明**,只有我改过的这两个声明了。
示例的正确依赖表达是 go.mod 的 `v0.0.0` + `replace ../../`。

## 修法

两个文件都恢复到与其余示例一致的写法。

## 判据(3 条,都是新加的)

 · `TestExampleGoModHasNoAbsoluteReplace`:任何示例 go.mod 不得含
   `/root/`、`/home/`、`/Users/`、`C:\`
 · `TestExampleGoModUsesRelativeSDKReplace`:replace 必须是相对路径 `../../`
 · `TestExamplePlgJsonDoesNotPinSDKVersion`:plg.json 不得声明 sdk

变异 2/2 咬住(重新引入绝对路径 / 重新声明 sdk,都准确报出文件路径)。

## 本地验证(模拟 CI 干净环境)

用干净 HOME 跑 `package/build-examples.sh`:
 · 修复前:成功 18 / 失败 4(commondev, editdoc, browser, rss)
 · 修复后:成功 20 / 失败 2(browser, rss —— **本机代理不通**,
   `proxy.golang.org` 超时;用本地 modcache 验证这两个可正常构建)

即 22/22 源码都能构建,CI 有网络不受影响。

## 教训

`git add -A` 会把「构建工具自动改写的文件」一起提交。
提交前必须 `git status` 逐个看,尤其 go.mod / go.sum。

---------

Co-authored-by: JianFeeeee <JianFeeeee@users.noreply.gitcode.com>
2026-10-17 19:44:15 +08:00

638 lines
22 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
// 远端端点表:**按 host 存一套,按 (host, cli) 覆盖一套**。
//
// ## 为什么必须按 host 分开
//
// 改动前 SSH 相关的东西只有**一组全局 settings**:
//
// ssh_node_path 远端 node 路径
// ssh_runtime_root 远端放 sidecar 脚本的目录
// ssh_workdir_root 远端放工程目录的根
//
// 单台远端时够用。**两台就开始互相打架**:A 机 node 在 `/usr/local/bin/node`、
// B 机在 `/opt/node/bin/node`;A 把脚本放 `/home/a/rt`、B 在 `/srv/rt`。
// 全局只有一个值 ⇒ 后配的那台覆盖前一台,表现为"换台机器就报找不到 node/
// 找不到脚本",而报错指向的是**另一台机器的路径**,排查方向完全错。
//
// ## 为什么还要按 (host, cli) 再分
//
// 同一个 host 上不同后端 CLI 的路径天然不同:zcode 在
// `/opt/zcode/zcode.cjs`,将来接别的 CLI 又在别处。而且同一台机器上
// 可能出现**同一 CLI 的多个版本**(系统装的 + 用户目录装的),
// 需要在不改 host 默认值的前提下为某个后端单独指定。
//
// ## 结构(优先级从高到低)
//
// ① endpoints[host].CLIs[cli].CLIPath 该机器该后端的专属 CLI 路径
// ② endpoints[host].CLIPath 该机器上所有后端的默认 CLI 路径
// ③ driver.json 的 cliPath 随包/全局默认(本地与远端共用同一份声明)
//
// node/runtime_root/workdir_root 同理:host 级默认 + (host,cli) 级覆盖。
//
// ★ 关键约束:**本机路径绝不发给远端**。见 resolveRemoteEndpoint 的说明。
//
// ## 为什么放独立文件而不是塞进 ledger.json
//
// ledger 是"工程状态"(可重建的派生数据);端点是"环境配置"
// (人手工维护、丢了要重配、且含机器专属信息)。混在一起会让
// ledger 的保存路径(每次分支变动都写)碰到与它无关的配置,
// 且一次台账损坏会连带丢掉配置。参照 credentials.json 的先例。
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
)
// cliEndpoint 是**某台机器上某个后端**的专属设置。
type cliEndpoint struct {
// CLIPath 该后端在这台机器上的 CLI 入口(如 /opt/zcode/zcode.cjs)。
CLIPath string `json:"cli_path,omitempty"`
// NodePath 该后端用的 node(留空则用 host 级或默认)。
NodePath string `json:"node_path,omitempty"`
// RuntimeRoot 该后端 sidecar 脚本落地的远端目录(留空则用 host 级)。
RuntimeRoot string `json:"runtime_root,omitempty"`
// Env 该后端额外的远端环境变量(展开占位符后前置到 ssh 命令)。
Env map[string]string `json:"env,omitempty"`
// Note 备注(给人看;例如"这台机器是测试机")。
Note string `json:"note,omitempty"`
}
// hostEndpoint 是**一台机器**的设置。
type hostEndpoint struct {
// CLIPath 这台机器上后端的**默认** CLI 路径(被 CLIs[cli].CLIPath 覆盖)。
CLIPath string `json:"cli_path,omitempty"`
// NodePath 这台机器的 node(默认 `node`,走远端 PATH)。
NodePath string `json:"node_path,omitempty"`
// RuntimeRoot 这台机器放 sidecar 脚本的目录。
RuntimeRoot string `json:"runtime_root,omitempty"`
// WorkdirRoot 这台机器放工程目录的根。
WorkdirRoot string `json:"workdir_root,omitempty"`
// CLIs 按后端覆盖(key 是 agent_cli,如 "zcode")。
CLIs map[string]*cliEndpoint `json:"clis,omitempty"`
// Note 备注。
Note string `json:"note,omitempty"`
}
// endpointTable 是端点表的磁盘形态。
type endpointTable struct {
// Version 供将来迁移用(现在只有 1)。
Version int `json:"version"`
// Hosts key 是 ssh_host(与 branch_open 的 ssh_host 参数逐字一致,
// 包括 `user@host:port` 或 ssh config 别名)。
Hosts map[string]*hostEndpoint `json:"hosts"`
}
// endpointsPath 端点表与台账同目录(与 credentials.json 一致)。
func (p *Plugin) endpointsPath() string {
return filepath.Join(filepath.Dir(p.path), "endpoints.json")
}
// loadEndpoints 读端点表。
//
// ★ 缺文件**不是错误**(首次运行、或只用本地后端的人永远不会有它)。
// 但**损坏必须是错误** —— 静默用空表会让所有远端配置凭空消失,
// 表现为"我明明配过,怎么又要配",而用户会先怀疑自己。
func (p *Plugin) loadEndpoints() error {
p.endpoints = &endpointTable{Version: 1, Hosts: map[string]*hostEndpoint{}}
b, err := os.ReadFile(p.endpointsPath())
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
var t endpointTable
if err := json.Unmarshal(b, &t); err != nil {
return fmt.Errorf("端点表损坏(拒绝猜默认,否则所有远端配置会静默消失): %s: %w",
p.endpointsPath(), err)
}
if t.Hosts == nil {
t.Hosts = map[string]*hostEndpoint{}
}
if t.Version == 0 {
t.Version = 1
}
p.endpoints = &t
return nil
}
// saveEndpoints 原子写(tmp + rename),权限 0600。
//
// 0600 而不是 0644:端点表可能含机器内网地址、端口、用户目录结构,
// 也**可能**含 env 里塞进去的令牌(虽然不推荐,但不该由文件权限替人决定)。
func (p *Plugin) saveEndpoints() error {
b, err := json.MarshalIndent(p.endpoints, "", " ")
if err != nil {
return err
}
path := p.endpointsPath()
tmp := path + ".tmp"
if err := os.WriteFile(tmp, b, 0600); err != nil {
return err
}
return os.Rename(tmp, path)
}
// getHost 取(或按需创建)某台机器的设置。
//
// create=false 时**不**创建 —— 读路径不该有副作用(否则一个查询工具
// 就会往表里塞一堆空条目,把真实配置淹掉)。
func (p *Plugin) getHost(host string, create bool) *hostEndpoint {
if p.endpoints == nil {
p.endpoints = &endpointTable{Version: 1, Hosts: map[string]*hostEndpoint{}}
}
if p.endpoints.Hosts == nil {
p.endpoints.Hosts = map[string]*hostEndpoint{}
}
h, ok := p.endpoints.Hosts[host]
if !ok {
if !create {
return nil
}
h = &hostEndpoint{CLIs: map[string]*cliEndpoint{}}
p.endpoints.Hosts[host] = h
}
if h.CLIs == nil {
h.CLIs = map[string]*cliEndpoint{}
}
return h
}
// resolvedEndpoint 是"算完之后"的最终取值(三层合并的结果)。
type resolvedEndpoint struct {
Host string
CLI string
CLIPath string
CLIPathFrom string // 取值来源,**必须在错误信息里出现**
NodePath string
RuntimeRoot string
WorkdirRoot string
Env map[string]string
}
// resolveEndpoint 把三层配置合并成最终端点。
//
// cliPathFrom 记录取值来源:出问题时要能一句话说清"这个路径是从哪来的"。
// 没有它,用户面对"远端找不到 CLI"根本不知道该改哪个文件 ——
// 本会话已经因为"错误不指出位置"吃过多次教训。
func (p *Plugin) resolveEndpoint(host, cli string, drv driverSpec, ctx driverCtx) resolvedEndpoint {
r := resolvedEndpoint{Host: host, CLI: cli, NodePath: "node", Env: map[string]string{}}
h := p.getHost(host, false)
if h != nil {
r.CLIPath, r.CLIPathFrom = h.CLIPath, "host 默认(endpoints.json)"
r.NodePath = h.NodePath
r.RuntimeRoot = h.RuntimeRoot
r.WorkdirRoot = h.WorkdirRoot
}
if h != nil && h.CLIs != nil {
if c, ok := h.CLIs[cli]; ok && c != nil {
if strings.TrimSpace(c.CLIPath) != "" {
r.CLIPath, r.CLIPathFrom = c.CLIPath, "该后端专属(endpoints.json)"
}
if strings.TrimSpace(c.NodePath) != "" {
r.NodePath = c.NodePath
}
if strings.TrimSpace(c.RuntimeRoot) != "" {
r.RuntimeRoot = c.RuntimeRoot
}
for k, v := range c.Env {
r.Env[k] = v
}
}
}
if strings.TrimSpace(r.NodePath) == "" {
r.NodePath = "node"
}
// 兜底:driver.json 的 cliPath(随包/全局声明)。
//
// ★ 只有**未被任何端点配置覆盖**时才用它。而且它可能含 {rawfile} 这类
// **本地**占位符 —— 那种路径绝不能发给远端(见 resolveRemoteCLIPath)。
if r.CLIPath == "" && strings.TrimSpace(drv.CLIPath) != "" {
r.CLIPath = drv.expand(drv.CLIPath, ctx)
r.CLIPathFrom = "driver.json 的 cliPath 默认值"
}
// 兼容旧配置:ssh_node_path / ssh_runtime_root / ssh_workdir_root
// 只在端点表没有对应值时生效,避免"配了端点表却还被旧键盖住"。
//
// ★ 经 settingStr 而不是裸 p.sdk.Settings():Settings 未装配时
// 裸调用会 **nil 解引用 panic**,而这里在 ssh 拉起的热路径上 ——
// 一个配置读取的健壮性问题会变成"拉 agent 直接崩"。
if r.RuntimeRoot == "" {
r.RuntimeRoot = p.settingStr("ssh_runtime_root")
}
if r.WorkdirRoot == "" {
r.WorkdirRoot = p.settingStr("ssh_workdir_root")
}
if r.NodePath == "node" {
if s := p.settingStr("ssh_node_path"); s != "" {
r.NodePath = s
}
}
return r
}
// settingStr 读一个字符串 setting,**容忍 Settings 未装配**。
//
// 返回 "" 表示"没配/取不到";调用方据此走默认值。
// 修这个是因为判据造了不带 Settings 的插件就 panic —— 那说明
// 任何"Settings 尚未注入"的调用点都会崩,属真实健壮性缺口。
func (p *Plugin) settingStr(key string) string {
if p.sdk == nil {
return ""
}
st := p.sdk.Settings()
if st == nil {
return ""
}
v, _ := st.Get(key)
if v == nil {
return ""
}
s := strings.TrimSpace(fmt.Sprint(v))
if s == "" || s == "<nil>" {
return ""
}
return s
}
// localPathPlaceholders 是**只在本机有意义**的占位符。
//
// ★ 这是一条安全/正确性边界,不是审美问题:`{rawfile}` 展开成
// `<本机插件目录>/rawfile`,把它发给远端 100% 是个不存在的路径,
// 而远端报的错误是"文件不存在"——用户会去远端找,方向完全错。
var localPathPlaceholders = []string{"{rawfile}", "{pluginDir}", "{dataDir}"}
// containsLocalPlaceholder 报告路径里是否含本机专属占位符。
func containsLocalPlaceholder(s string) bool {
for _, ph := range localPathPlaceholders {
if strings.Contains(s, ph) {
return true
}
}
return false
}
// validateRemoteCLIPath 在**启动 ssh 之前**校验远端 CLI 路径。
//
// 两条规则(都是实测踩出来的):
//
// 1. **本机占位符不得下发**:展开后是本机路径,远端一定不存在。
// 2. **本机绝对路径不得下发**:即使不是占位符,用户也可能把本机的
// `/home/program/.zcode-src/.../zcode.cjs` 直接填进 host 端点,
// 而远端是另一台机器。这比第 1 条更隐蔽 —— 它看起来就是个正常路径。
//
// 两条都必须**在 ssh 之前**拦:让远端去失败的话,错误来自远端 shell
// ("No such file or directory"),用户会以为远端缺文件而不是本地配错了。
func validateRemoteCLIPath(cliPath, cli string) error {
if strings.TrimSpace(cliPath) == "" {
return fmt.Errorf(
"远端 %s 没有可用的 CLI 路径。请在 endpoints.json 里为该机器配置 "+
"hosts.<ssh_host>.cli_path(该机所有后端的默认)或 "+
"hosts.<ssh_host>.clis.%s.cli_path(该后端的专属路径)。"+
"可用 commondev_endpoint_set 写入(会校验路径在远端是否存在)。", cli, cli)
}
if containsLocalPlaceholder(cliPath) {
return fmt.Errorf(
"★ 远端 CLI 路径含**本机专属占位符**(%s):%q。"+
"{rawfile}/{pluginDir}/{dataDir} 展开后是**本机**路径,发给远端必然不存在。"+
"请把它换成该机器上的真实路径(endpoint_set host=<ssh_host> cli=%s cli_path=<远端路径>)",
strings.Join(localPathPlaceholders, "/"), cliPath, cli)
}
return nil
}
// endpointListRows 把端点表摊平成可读行(供 commondev_endpoint_list)。
//
// 一并报出**解析后的默认值**,因为"我这台机器最终会用哪个路径"才是
// 用户真正要回答的问题;只给原始 JSON 等于让人自己心算三层合并。
func (p *Plugin) endpointListRows() []map[string]interface{} {
rows := []map[string]interface{}{}
if p.endpoints == nil {
return rows
}
hosts := make([]string, 0, len(p.endpoints.Hosts))
for h := range p.endpoints.Hosts {
hosts = append(hosts, h)
}
sort.Strings(hosts)
for _, h := range hosts {
he := p.endpoints.Hosts[h]
if he == nil {
continue
}
row := map[string]interface{}{
"host": h,
"cli_path": he.CLIPath,
"node_path": he.NodePath,
"runtime_root": he.RuntimeRoot,
"workdir_root": he.WorkdirRoot,
"note": he.Note,
}
if len(he.CLIs) > 0 {
clis := make([]string, 0, len(he.CLIs))
for c := range he.CLIs {
clis = append(clis, c)
}
sort.Strings(clis)
cm := map[string]interface{}{}
for _, c := range clis {
ce := he.CLIs[c]
if ce == nil {
continue
}
cm[c] = map[string]interface{}{
"cli_path": ce.CLIPath,
"node_path": ce.NodePath,
"runtime_root": ce.RuntimeRoot,
"env": ce.Env,
"note": ce.Note,
}
}
row["clis"] = cm
}
rows = append(rows, row)
}
return rows
}
// ---------------- 工具处理 ----------------
// handleEndpointSet 写某台机器(或该机器某后端)的端点配置。
//
// ★ 默认会 ssh 到远端**确认 cli_path 真实存在**。
// 为什么默认开:本会话反复出现的失败模式就是"配置写对了但指向一个
// 不存在的东西",而症状出现在很远的地方(远端 shell 报 No such file)。
// 在这里花一次 ssh 往返,能把它变成一条立刻可见的、指出位置的错误。
func (p *Plugin) handleEndpointSet(args map[string]interface{}) (interface{}, error) {
host := strings.TrimSpace(str(args, "host"))
if host == "" {
return map[string]interface{}{"error": "必须给 host(与 branch_open 的 ssh_host 逐字一致)"}, nil
}
cli := strings.TrimSpace(str(args, "cli"))
cliPath := strings.TrimSpace(str(args, "cli_path"))
nodePath := strings.TrimSpace(str(args, "node_path"))
runtimeRoot := strings.TrimSpace(str(args, "runtime_root"))
workdirRoot := strings.TrimSpace(str(args, "workdir_root"))
note := strings.TrimSpace(str(args, "note"))
// ★ 本机路径不得写进**远端**配置:写了就是 100% 起不来,
// 而报错来自远端 shell,排查方向完全错。在这里拦最省事。
if cliPath != "" {
if containsLocalPlaceholder(cliPath) {
return map[string]interface{}{
"error": fmt.Sprintf(
"cli_path 含本机专属占位符(%s):%q。这些展开后是**本机**路径,"+
"远端必然不存在。请填该机器上的真实路径",
strings.Join(localPathPlaceholders, "/"), cliPath),
}, nil
}
if isLocalPathHint(cliPath) {
return map[string]interface{}{
"error": fmt.Sprintf(
"cli_path %q 看起来是**本机**路径(在 %s 下)。请填该**远端机器**上的路径;"+
"若该机器确实与本地同构(如 localhost 回环),传 verify=false 并确认无误",
cliPath, p.localRootHint()),
}, nil
}
}
if workdirRoot != "" && cli != "" {
return map[string]interface{}{
"error": "workdir_root 是**机器级**设置(该机器上所有后端共用),请去掉 cli 参数单独设置",
}, nil
}
// 远端验存(默认开)
verify := true
if v, ok := args["verify"]; ok {
if b, isB := v.(bool); isB {
verify = b
}
}
if verify && cliPath != "" {
t, err := parseSSHTarget(&Branch{SSHHost: host})
if err != nil {
return map[string]interface{}{"error": err.Error()}, nil
}
if _, err := p.sshRunCapture(t, "test -f "+shellQuote(cliPath)+" && echo __OK__"); err != nil {
return map[string]interface{}{
"error": fmt.Sprintf("远端 %s 上不存在 %s(ssh 验存失败,未写入配置):%v。"+
"确认路径后重试;若机器暂时不可达,传 verify=false 先记下配置", host, cliPath, err),
}, nil
}
}
p.mu.Lock()
defer p.mu.Unlock()
he := p.getHost(host, true)
if cli != "" {
ce, ok := he.CLIs[cli]
if !ok || ce == nil {
ce = &cliEndpoint{}
he.CLIs[cli] = ce
}
if cliPath != "" {
ce.CLIPath = cliPath
}
if nodePath != "" {
ce.NodePath = nodePath
}
if runtimeRoot != "" {
ce.RuntimeRoot = runtimeRoot
}
if note != "" {
ce.Note = note
}
} else {
if cliPath != "" {
he.CLIPath = cliPath
}
if nodePath != "" {
he.NodePath = nodePath
}
if runtimeRoot != "" {
he.RuntimeRoot = runtimeRoot
}
if workdirRoot != "" {
he.WorkdirRoot = workdirRoot
}
if note != "" {
he.Note = note
}
}
if err := p.saveEndpoints(); err != nil {
return map[string]interface{}{"error": "保存端点表失败: " + err.Error()}, nil
}
return map[string]interface{}{
"status": "ok",
"host": host,
"cli": cli,
"scope": map[bool]string{true: "该后端专属", false: "该机器默认"}[cli != ""],
}, nil
}
// handleEndpointList 列出端点配置。
//
// 一并给出**解析后的最终值**:用户真正要回答的是"这台机器最终用哪个路径",
// 只给原始 JSON 等于让人自己心算三层合并。给 host+cli 时才计算。
func (p *Plugin) handleEndpointList(args map[string]interface{}) (interface{}, error) {
host := strings.TrimSpace(str(args, "host"))
p.mu.Lock()
defer p.mu.Unlock()
// ★ 端点表可能尚未加载(裸插件 / 加载失败前的调用)。
// 空参数调用不得 panic —— 工具会遇到模型乱传参数与半初始化状态。
if p.endpoints == nil {
p.endpoints = &endpointTable{Version: 1, Hosts: map[string]*hostEndpoint{}}
}
if host != "" {
he := p.getHost(host, false)
if he == nil {
return map[string]interface{}{
"status": "ok", "host": host, "configured": false,
"hint": "该机器没有端点配置。用 commondev_endpoint_set host=" + host +
" cli_path=<远端 CLI 路径> runtime_root=<远端目录> workdir_root=<远端工程根> 配置",
}, nil
}
m := map[string]interface{}{
"status": "ok", "host": host, "configured": true,
"cli_path": he.CLIPath, "node_path": he.NodePath,
"runtime_root": he.RuntimeRoot, "workdir_root": he.WorkdirRoot,
"note": he.Note,
}
if len(he.CLIs) > 0 {
cm := map[string]interface{}{}
for c, ce := range he.CLIs {
if ce == nil {
continue
}
cm[c] = map[string]interface{}{
"cli_path": ce.CLIPath, "node_path": ce.NodePath,
"runtime_root": ce.RuntimeRoot, "env": ce.Env, "note": ce.Note,
}
}
m["clis"] = cm
}
return m, nil
}
return map[string]interface{}{
"status": "ok", "count": len(p.endpoints.Hosts), "hosts": p.endpointListRows(),
}, nil
}
// handleEndpointRemove 删端点配置。
//
// ★ 该机器有后端覆盖时,只给 host 会**拒绝**(除非 force):
// 否则会出现"以为删了机器,实际子项还在,下次又把老路径拿出来用"。
func (p *Plugin) handleEndpointRemove(args map[string]interface{}) (interface{}, error) {
host := strings.TrimSpace(str(args, "host"))
cli := strings.TrimSpace(str(args, "cli"))
force := false
if v, ok := args["force"]; ok {
if b, isB := v.(bool); isB {
force = b
}
}
if host == "" {
return map[string]interface{}{"error": "必须给 host"}, nil
}
p.mu.Lock()
defer p.mu.Unlock()
if p.endpoints == nil {
p.endpoints = &endpointTable{Version: 1, Hosts: map[string]*hostEndpoint{}}
}
he := p.getHost(host, false)
if he == nil {
return map[string]interface{}{"status": "ok", "host": host, "removed": false,
"note": "该机器本来就没有配置(幂等)"}, nil
}
if cli != "" {
if _, ok := he.CLIs[cli]; !ok {
return map[string]interface{}{"status": "ok", "host": host, "cli": cli, "removed": false,
"note": "该后端本来就没有专属配置(幂等)"}, nil
}
delete(he.CLIs, cli)
if err := p.saveEndpoints(); err != nil {
return map[string]interface{}{"error": err.Error()}, nil
}
return map[string]interface{}{"status": "ok", "host": host, "cli": cli, "removed": true}, nil
}
// 只给 host:有子项时拒绝
if len(he.CLIs) > 0 && !force {
names := make([]string, 0, len(he.CLIs))
for c := range he.CLIs {
names = append(names, c)
}
sort.Strings(names)
return map[string]interface{}{
"status": "refused",
"reason": fmt.Sprintf("该机器还有 %d 个后端专属配置(%s)。请先逐个删"+
"(commondev_endpoint_remove host=%s cli=<名>),或传 force=true 一并删除",
len(names), strings.Join(names, ", "), host),
}, nil
}
delete(p.endpoints.Hosts, host)
if err := p.saveEndpoints(); err != nil {
return map[string]interface{}{"error": err.Error()}, nil
}
return map[string]interface{}{"status": "ok", "host": host, "removed": true}, nil
}
// isLocalPathHint 粗略判断路径是否"像是本机路径"。
//
// 用本机真实存在的根(插件数据目录 / 工程目录)去比:
// 比"硬编码 /home/program 或 /home/newqqagent"可靠 —— 换台机器部署仍然对。
// 只做**提示**不做强制(确有 localhost 回环这种同构合法情形)。
func isLocalPathHint(cliPath string) bool {
hint := currentLocalRootHint()
if hint == "" {
return false
}
return strings.HasPrefix(cliPath, hint)
}
// localRootHint 给用户看的本机根(错误信息里说"本机在 X 下")。
func (p *Plugin) localRootHint() string {
return currentLocalRootHint()
}
// currentLocalRootHint 取一个"本机特征路径"用于提示。
func currentLocalRootHint() string {
if exe, err := os.Executable(); err == nil {
d := filepath.Dir(exe)
if d != "" && d != "/" {
return d
}
}
if hd, err := os.UserHomeDir(); err == nil && hd != "" {
return hd
}
return ""
}
// sshRunCapture 在远端跑一条命令并取回 stdout(stderr 一并带在错误里)。
//
// 供端点验存等**轻量**场景使用;重活(准备目录、推脚本)走各自专门函数。
func (p *Plugin) sshRunCapture(t sshTarget, cmd string) (string, error) {
args := append(t.sshBaseArgs(), t.Host, cmd)
out, err := exec.Command("ssh", args...).CombinedOutput()
s := string(out)
if err != nil {
return s, fmt.Errorf("%w: %s", err, strings.TrimSpace(s))
}
if !strings.Contains(s, "__OK__") {
return s, fmt.Errorf("远端未返回预期标记(命令可能未生效): %s", strings.TrimSpace(s))
}
return s, nil
}