Skip to content

feat: botmux update 支持本地 checkout 走 git pull + 构建 + 重启(CLI 与 Dashboard) - #930

Open
deepcoldy wants to merge 6 commits into
masterfrom
feat/local-dev-git-update
Open

feat: botmux update 支持本地 checkout 走 git pull + 构建 + 重启(CLI 与 Dashboard)#930
deepcoldy wants to merge 6 commits into
masterfrom
feat/local-dev-git-update

Conversation

@deepcoldy

Copy link
Copy Markdown
Owner

背景 / 为什么

原来 botmux update(= upgrade 别名)只认 npm/pnpm/bun 全局安装。跑在本地
checkout(有 .git/src)时:

  • CLI 会报「无法安全识别当前安装方式」直接退出;
  • Dashboard「版本与更新」卡片把「更新到最新版」按钮置灰,只提示「请手动 git
    pull + 构建后重启」。

本 PR 给「本地开发从源码运行」这种部署补上一键更新:定位 checkout → git pull
--ff-only → 构建 → 重启,CLI 与 Dashboard 都支持。

改了什么

共享层 src/utils/local-dev-update.ts

把 checkout 定位、git 干净检查、HEAD 读取、更新步骤定义抽成共享纯/薄函数,CLI 与
Dashboard 共用,避免逻辑漂移:

  • checkout 定位:解析全局瘦 wrapper ~/.botmux/bin/botmux 里指向的 dist/cli.js
    → 往上两级得 checkout 根(那才是用户实际敲 botmux 跑的目录),拿不到回退到本
    进程安装根,再校验是 git 工作树;
  • localDevUpdateSteps()git pull --ff-onlypnpm build(重启单独由各调用方施加)。

CLI(src/cli.ts

cmdUpgrade 检测到本地 checkout 走新分支 cmdUpgradeLocalDev

  1. git status --porcelain 有未提交改动 → fail closed 中止并列出改动(不自动 stash);
  2. git pull --ff-only(分叉/冲突直接报错停下,不自动 merge);
  3. pnpm build关键dist/ 被 gitignore,只 pull 不 build 重启后跑的还是旧代码);
  4. node <checkout>/dist/cli.js restart 从本 checkout 重启(不走 PATH,避免被更靠前的全局 botmux 抢先)。

Dashboard 后端(src/dashboard.ts

  • POST /api/update/run:local-dev 分支不再拒绝,改为在既有 install 锁内执行
    git 干净检查(脏工作区返回 dirty_worktree + 文件列表,不 stash)→ git pull --ff-onlypnpm build(300s 超时、捕获输出);用 HEAD 前后 sha 判断 changed
    版本走 resolveCurrentVersion(git describe)。复用既有 updateInFlight / 跨进程锁 / restart-lease 门禁;
  • GET /api/update/status:新增 localDevUpdatable(本地开发且定位到 git 工作树才为 true);
  • POST /api/update/restart:local-dev 从 resolveLocalDevCheckoutDir() 重启,与 CLI 一致;
  • rollback 对 local-dev 仍拒绝(本地开发无回滚语义)。

Dashboard 前端(settings-page.tsx / i18n.ts

  • localDevUpdatable 时按钮启用,文案「本地更新(git pull + 构建 + 重启)」,走既有
    run→restart 两步流;dirty_worktree 错误在 UI 回显改动文件列表;
  • 新增中英文案(本地可更新提示、确认框、进行中提示、脏工作区提示等)。

影响面

改动集中在版本更新链路:CLI cmdUpgrade + Dashboard /api/update/* + 更新卡片 UI。

  • 不动 npm/pnpm/bun 线上升级路径;不动 maintenance 定时器(对 local-dev 仍 skip);
  • 不涉及 worker/后端/PTY/IM 等多 CLI × 多后端共用代码路径;
  • 跨平台:git/pnpm 子进程在 win32 走 shell:true 解析 .cmd shim;daemon 实际
    跑在 Linux,路径/进程调用两边都已考虑。

测试

  • pnpm build + pnpm exec tsc --noEmit 通过;
  • test/local-dev-update.test.ts(11 例):wrapper 文本解析 + 目录推导 + 更新步骤定义,
    并用真实 git 仓库验证 isGitWorktree / gitPorcelainStatus(干净/脏)/
    gitHeadSha(跨 commit 变化);
  • test/cli-update-alias.test.ts:按新本地分支行为更新断言(update == upgrade 别名契约不变);
  • test/dashboard-update-action.test.ts 现有 8 例仍通过(响应字段形状未变);
  • 新增 i18n key 与后端路由字符串确认已进 dist bundle;
  • CLI 本地分支实测:脏工作区正确中止不 stash、git pull --ff-only 快进(隔离仓库验证)、
    wrapper 定位到正确 checkout。

待 live 验证

Dashboard「本地更新」按钮的端到端点击需要一个「从本 checkout 跑起来的 dashboard」,
部署会重启 daemon,留到 review 后 live 验证并补 UI 截图。

@deepcoldy

Copy link
Copy Markdown
Owner Author

复审修复已推(commit 072d5f5

按复审意见收敛 4 处边界:

  1. wrapper 解析收紧parseWrapperCliEntry 只在 exec … node … 行匹配以 dist/cli.js[\\/] 兼容 Windows)结尾的目标,注释里的其它 .js 不再抢先;resolveLocalDevCheckoutDir 叠加 isBotmuxCheckout.git + package.json.name==="botmux")repo 身份校验,非 botmux 回退 running root。要求 dist 已存在(use:here 允许先 checkout 后 build)。
  2. build 成功后始终重启/api/update/run local-dev 恒返回 restartRequired:true;前端改用 updateResponseNeedsRestartrestartRequired || changed)决定重启,changed 仅展示。修复「已 pull 未 build → 点更新只 build、HEAD 不变、UI 报已最新跳过重启、新 dist 不生效」。
  3. restart 前校验目标 cli.js/api/update/restart 对 local-dev 用 checkout 前校验 botmuxCliEntryAt(target) 存在,否则回退 running root,避免 spawn 出 pid 又立刻因模块缺失退出、lease 得不到清理。
  4. 版本按实际 checkout 读:新增 resolveCurrentVersionAt(dir)runLocalDevUpdate 返回该 checkout 的 old/new version,不再混用 running root。

已知语义(本 PR 不扩范围):restart intent 仍按 version 判 update/manual,同 tag 不同 SHA 会落 manual intent,只影响 owner「已更新到 vX」DM 的措辞,不影响更新/build/lease/实际重启。

测试local-dev-update.test.ts 补注释假路径负测、非 dist/cli.js 负测、Windows 分隔符正测、isBotmuxCheckout 双条件;dashboard-update-action.test.tsupdateResponseNeedsRestart 三例(锁 changed=false && restartRequired=true 仍重启)。pnpm build + tsc --noEmit 通过;相关单测 47 例全过;CLI 本地分支实测仍正确。

原来 `botmux update` 只认 npm/pnpm/bun 全局安装,跑在本地 checkout(有 .git/src)
时会报「无法安全识别安装方式」退出。现给 cmdUpgrade 补一条本地开发分支:

- 定位运行目录:优先解析全局瘦 wrapper(~/.botmux/bin/botmux)里指向的
  dist/cli.js,往上两级得 checkout 根;解析不到回退到本进程安装根,再校验确是
  git 工作树。逻辑抽成纯函数 src/utils/local-dev-update.ts 便于单测。
- 更新四步(在该目录内):git status --porcelain 有未提交改动即 fail closed
  中止并提示(不自动 stash);git pull --ff-only(分叉/冲突直接报错停下,不
  自动 merge);pnpm build(dist/ 被 gitignore,只 pull 不 build 重启后仍跑旧
  代码,故不可省);node <dir>/dist/cli.js restart 从本 checkout 重启。

影响面:纯 CLI 侧改动,只动 cmdUpgrade 的本地分支 + 新增一个 utils 纯函数;
不动 npm/pnpm/bun 线上升级路径,也不动 maintenance 定时器与任何 worker/后端
/PTY 共用代码。跨平台:git/pnpm 调用在 win32 走 shell:true 解析 .cmd。

测试:
- 新增 test/local-dev-update.test.ts(7 例,wrapper 文本解析 + 目录推导)
- 更新 test/cli-update-alias.test.ts:断言改为新的本地分支行为(横幅 +
  update==upgrade 别名契约不变)
- 实测:脏工作区正确中止不 stash;隔离仓库验证 ff-only 快进;wrapper 定位
  到正确 checkout
- pnpm build 通过;相关单测全绿(其余环境类失败在 stash 后基线上同样存在,与本
  改动无关)
延续 CLI cmdUpgrade 的本地开发更新能力,把同一套逻辑接到 Dashboard「版本与
更新」卡片:本地 checkout(git 工作树)现在也能一键 git pull --ff-only +
构建 + 重启,不再只是置灰提示「请手动 git pull」。

共享层(src/utils/local-dev-update.ts):
- 把 checkout 定位(解析 ~/.botmux/bin/botmux wrapper → dist/cli.js → 上两级)、
  git 干净检查、HEAD 读取、更新步骤定义抽成共享纯/薄函数,CLI 与 dashboard
  共用,避免两边逻辑漂移。CLI cmdUpgradeLocalDev 改为复用这些函数。

Dashboard 后端(dashboard.ts):
- POST /api/update/run:local-dev 分支改为在 install 锁内执行 git 干净检查
  (脏工作区 fail closed,返回 dirty_worktree + 文件列表,不 stash)→
  git pull --ff-only(分叉/冲突报错停下)→ pnpm build(300s 超时、捕获输出);
  用 HEAD 前后 sha 判断 changed;版本走 resolveCurrentVersion(git describe)。
  复用既有 updateInFlight / 跨进程锁 / restart-lease 门禁。
- GET /api/update/status:新增 localDevUpdatable(local-dev 且定位到 git 工作树)。
- POST /api/update/restart:local-dev 从 resolveLocalDevCheckoutDir 重启,
  与 CLI 一致(不再只用 dashboard 进程自身 cli.js)。
- rollback 对 local-dev 仍拒绝(本地开发无回滚语义)。

Dashboard 前端(settings-page.tsx / i18n.ts):
- localDevUpdatable 时启用按钮,文案「本地更新(git pull + 构建 + 重启)」,
  走既有 run→restart 两步流;dirty_worktree 错误在 UI 回显改动文件列表。
- 新增 zh/en 文案:本地可更新提示、确认框、进行中提示、脏工作区提示等。

影响面:改动集中在版本更新链路(CLI cmdUpgrade + dashboard /api/update/*
+ 更新卡片 UI)。不动 npm/pnpm/bun 线上升级路径与 maintenance 定时器(后者对
local-dev 仍 skip);不涉及 worker/后端/PTY/IM 共用代码。跨平台:git/pnpm
子进程在 win32 走 shell:true 解析 .cmd。

测试:
- test/local-dev-update.test.ts:纯函数 + 真实 git 仓库验证 isGitWorktree /
  gitPorcelainStatus(干净/脏)/ gitHeadSha(跨 commit 变化)/ 更新步骤定义,共 11 例
- test/cli-update-alias.test.ts:按新本地分支行为更新断言(update==upgrade 别名契约不变)
- test/dashboard-update-action.test.ts 现有 8 例仍通过(响应字段形状未变)
- pnpm build + tsc --noEmit 通过;新 i18n key 与后端路由字符串确认已进 dist bundle
- CLI 本地分支实测:脏工作区正确中止、ff-only 快进(隔离仓库)、wrapper 定位正确
回应对本地 checkout 更新路径的复审,修 4 个边界问题:

1. wrapper 解析收紧,避免更新错仓库:parseWrapperCliEntry 只在 `exec … node …`
   行上匹配以 `dist/cli.js`(`[\\/]` 兼容 Windows 分隔符)结尾的目标,注释里的
   其它 `.js`(如 `# preload "…/hook.js"`)不再被当成 CLI。resolveLocalDevCheckoutDir
   进一步用 isBotmuxCheckout 做 repo 身份校验(`.git` + package.json.name==="botmux"),
   非 botmux 仓库回退到 running root。**不**要求 dist 已存在——use:here 允许先有
   checkout 后 build,本地更新正是要产出那个 dist。

2. build 成功后始终重启:local-dev 只 build 不动 HEAD(checkout 已被手动 pull 过)
   时,旧逻辑因 changed=false 跳过重启,导致新 dist 不生效、与按钮承诺不符。/api/update/run
   local-dev 分支恒返回 restartRequired:true;前端改用 updateResponseNeedsRestart
   (restartRequired || changed)决定是否重启,changed 仅用于展示。

3. restart 前校验目标 cli.js 存在:/api/update/restart 对 local-dev 用 checkout
   目标前,先校验 botmuxCliEntryAt(target) 存在;否则回退 running root。避免 wrapper
   指向尚未 build 的 checkout 时,重启驱动 spawn 出 pid 又立刻因模块缺失退出、
   restart lease 得不到清理(UI 空等 90s 且后续重启被挡)。

4. 版本按实际更新的 checkout 读:新增 resolveCurrentVersionAt(dir),runLocalDevUpdate
   返回该 checkout 的 old/new version,不再混用 running root 的版本(wrapper→B、
   dashboard 跑 A 时会显示错版本)。

已知语义(本 PR 不扩范围):restart intent 仍按 version 判 update/manual,同 tag
不同 SHA 会落 manual intent,只影响 owner「已更新到 vX」DM 的措辞,不影响更新/
build/lease/实际重启。

测试:
- local-dev-update.test.ts 补:注释假路径负测、非 dist/cli.js 目标负测、Windows
  分隔符正测、isBotmuxCheckout(.git + name 双条件、无 .git 拒绝)
- dashboard-update-action.test.ts 补 updateResponseNeedsRestart 三例,锁住
  changed=false && restartRequired=true 仍重启的核心回归
- pnpm build + tsc --noEmit 通过;相关单测 47 例全过;CLI 本地分支实测仍正确
  (定位到真实 checkout、脏工作区中止)
二轮复审指出上一版 parseWrapperCliEntry 只判断行内出现 exec/node,未锚定为该行
实际执行的命令:`# old: exec node ".../dist/cli.js" "$@"` 这类「保留旧命令作注释」
写法,以及 `echo exec node "..."`,仍会被选中——若旧路径也是 botmux checkout,
后续 repo 身份校验照样通过,最终更新/重启错 checkout。

改为按本仓库生成 wrapper 的固定形状精确匹配:行首(仅允许前导空白)`exec` →
`node`/绝对 node 路径 → 引号包裹的以 `dist/cli.js`(`[\\/]` 兼容 Windows)结尾的
路径 → 结尾 `"$@"`。锚定行首 + 要求尾部 `"$@"` 直接排除注释行与 echo 行。

测试:补「注释掉的旧 exec 行在真 exec 前 → 返回真路径」「echo exec 行 → null」
「缺尾部 $@ → null」负测;原有正例(标准/绝对 node/Windows 分隔符)保留。
build + tsc 通过;解析相关 30 例全过;对线上实际 wrapper 解析定位仍正确。
上一版正则允许捕获相对路径(如 `exec node "nested/botmux/dist/cli.js" "$@"`)。
相对路径在 shell exec 时相对调用者 cwd 解析,而 Dashboard/CLI resolver 在这里对它
做 dirname/repo 身份校验时相对的是读取方进程的 cwd——两者 cwd 不同就会解释成不同
checkout,Dashboard cwd 下恰有同名 botmux repo 时仍可能更新错目标。函数注释与前序
commit 也都承诺只接受绝对路径。

parseWrapperCliEntry 捕获后加 `posix.isAbsolute(entry) || win32.isAbsolute(entry)`
校验,不绝对则跳过该行。跨平台:POSIX `/…` 与 Windows drive(`C:\…`)/UNC 前缀都算绝对。

测试:补相对路径(`nested/…` 与 `./dist/cli.js`)→ null 负测;原有绝对路径正例
(POSIX/绝对 node 路径/Windows 分隔符)保留。build + tsc 通过;解析相关单测全过。
@deepcoldy
deepcoldy force-pushed the feat/local-dev-git-update branch from 514bb3c to dec2745 Compare August 18, 2026 16:45

Copy link
Copy Markdown
Owner Author

建议合并前再收口一处本地 checkout 的事务边界:

POST /api/update/runrunLocalDevUpdate() 开头解析并构建 checkout B,完成后释放 install lock、清掉 updateInFlight;Settings 页随后还会等待用户确认是否重启。此时若另一个 worktree 执行 pnpm use:here / 启动 daemon,把全局 wrapper 从 B 改指 C,POST /api/update/restart 会再次调用 resolveLocalDevCheckoutDir(),于是实际重启 C;如果 C 尚无 dist/cli.js,当前代码又会静默回退到 running root A。这样本次操作可能“更新/构建 B,却重启 C 或 A”,restart intent 里仍带 B 的版本。现有 file lock 只覆盖各自请求,restart lease 只序列化重启动作,都不能绑定本次 build 的目标,因此这个窗口确实存在;多-checkout 正是本功能要支持的场景,wrapper 在两次请求之间变化并非纯理论情况。

建议把成功构建的绝对 checkout(最好连同 build 后 HEAD)作为 server-side update plan 固定下来,/api/update/restart 使用并复验该 plan 的 dist/cli.js / HEAD;目标不可用或漂移时 fail closed。也可将 run→lease→restart handoff 合为服务端原子流程。手动“仅重启”仍可保留当前按 wrapper 解析的行为。请补一个 wrapper 在 run/restart 间由 B 切到 C 的回归测试。

另一个相关边界是 cmdUpgradeLocalDev 当前没有进入同一把 cross-process update lock,也不参与 restart lease;它可与 Dashboard 的 git pull / pnpm build 并发写同一 checkout。建议至少把 CLI 本地更新的 pull+build 纳入共享锁,避免两个 build 交错清理/生成 dist

这是自动评审的初步意见,最终以维护者审阅为准。

回应评审对多-checkout 并发的两处收口:

A. run→restart 之间 wrapper 漂移(TOCTOU):/api/update/run 更新 checkout B 后,
   /api/update/restart 原来会重新解析 wrapper——若期间另一个 worktree `use:here`
   把 wrapper 改指 C,可能「更新 B 却重启 C 或回退 A」。现 run 成功后把实际构建的
   checkout 绝对路径 + 构建后 HEAD 固定为 server 端 pending plan;restart 复用并
   复验:目标 cli.js 不存在→update_target_unavailable,HEAD 漂移→update_target_drifted,
   均在 claim restart lease 前 fail closed(不留悬空 lease)。无 pending plan 的
   纯手动重启仍按 wrapper 实时解析(cli.js 不存在则回退 running root)。决策逻辑抽成
   纯函数 resolveLocalDevRestartTarget,注入探针便于单测。前端对两个新错误码回显
   可操作文案并刷新状态。

B. CLI 本地更新未进共享锁:cmdUpgradeLocalDev 的 git 干净检查 + pull + build 现
   包进 withFileLockSync(globalInstallUpdateLockTarget())——与 dashboard 的
   /api/update/run 同一把跨进程锁,避免 CLI 与 dashboard 并发对同一 checkout 交错
   build 互相清理/覆盖 dist。restart 不在锁内(有自己的 restart lease)。锁文件父目录
   先 mkdir,避免 daemon 从未起过的机器上 ENOENT 盖掉真实错误。

测试:resolveLocalDevRestartTarget 5 例(pinned 正常/目标丢失/HEAD 漂移/无 plan
命中/无 plan 回退,覆盖 wrapper B→C 回归);build + tsc 通过;相关单测 55 例全过;
CLI 本地分支实测脏工作区仍正确中止。

已知语义(不扩范围):restart intent 仍按 version 判 update/manual,同 tag 不同 SHA
落 manual intent,只影响通知措辞。
@deepcoldy

Copy link
Copy Markdown
Owner Author

已收口两处并发边界(commit 9c2a21b

A. run→restart 之间 wrapper 漂移(TOCTOU)
/api/update/run 成功后,把实际构建的 checkout 绝对路径 + 构建后 HEAD 固定为 server 端 pending plan;/api/update/restart 复用并复验:

  • 目标 dist/cli.js 不存在 → update_target_unavailable
  • HEAD 相对构建时漂移 → update_target_drifted
  • 两者都在 claim restart lease 之前 fail closed,不留悬空 lease

无 pending plan 的纯手动重启仍按 wrapper 实时解析(cli.js 不存在则回退 running root)。决策逻辑抽成纯函数 resolveLocalDevRestartTarget(注入探针,单测覆盖 wrapper B→C 漂移)。前端对两个新错误码回显可操作文案。

B. CLI 本地更新未进共享锁
cmdUpgradeLocalDev 的 git 干净检查 + pull + build 现包进 withFileLockSync(globalInstallUpdateLockTarget())——与 dashboard /api/update/run 同一把跨进程锁,避免并发对同一 checkout 交错 build。restart 不在锁内(有自己的 restart lease)。

测试resolveLocalDevRestartTarget 5 例(pinned 正常 / 目标丢失 / HEAD 漂移 / 无 plan 命中 / 无 plan 回退);build + tsc 通过;相关单测 55 例全过。

已知语义(不扩范围):restart intent 仍按 version 判 update/manual,同 tag 不同 SHA 落 manual intent,只影响通知措辞。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant