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>
This commit is contained in:
JianFeeeee
2026-10-17 19:44:15 +08:00
committed by GitHub
parent 65d2bd9e61
commit 81a3efa4df
91 changed files with 21184 additions and 224 deletions

View File

@ -0,0 +1,637 @@
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
}