Skip to content

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

Merged
deepcoldy merged 1 commit into
masterfrom
feat/local-dev-git-update
Aug 20, 2026
Merged

feat: botmux update 支持本地 checkout 走 git pull + 构建 + 重启(CLI 与 Dashboard)#930
deepcoldy merged 1 commit 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 本地分支实测仍正确。

@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

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

@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,只影响通知措辞。

@deepcoldy

Copy link
Copy Markdown
Owner Author

自动评审补充(初步意见,最终以维护者审阅为准)

感谢新一轮的 9c2a21bfd。两处收口经独立核验都到位了:

  • run→restart 目标漂移(TOCTOU)/api/update/run 成功后把本次构建的 {dir, 构建后 HEAD} 固定进服务端 pendingLocalDevRestart/api/update/restart 消费并经 resolveLocalDevRestartTarget 复验(cli.js 缺失 → update_target_unavailable、HEAD 漂移 → update_target_drifted),且这些判定都在 claim restart lease 之前 fail-closed,不会留悬空 lease。5 个用例覆盖了 B→C 回归,反向变异验证有效。
  • CLI 共享锁cmdUpgradeLocalDev 的 clean-check + pull + build 已包进与 dashboard 同一把 withFileLockSync(globalInstallUpdateLockTarget()),restart 留在锁外走自己的 lease,mkdirSync(dirname()) 防 ENOENT。

代码层这两点我们没有新增阻断性意见。

合并前有一个前置项(不是纯机械解冲突,建议一并处理):本分支现落后 master 较多,src/dashboard.ts 与 master #933 有真实冲突——#933runGlobalInstall 移进了 src/dashboard/managed-spawn.js 并新增了一条结构性测试 test/dashboard-managed-spawn.test.ts,断言 dashboard.ts 源码不得 import from 'node:child_process'(dashboard 派生的宿主子进程必须统一走 managed-spawnredactChildEnv)。

因此 rebase 时除了删掉本地的 runGlobalInstall 定义,还需要把本 PR 新增的 runLocalDevStep(目前是裸 spawn + env: { ...process.env }挪进 managed-spawn.ts 并改用 redactChildEnv(process.env),再由 dashboard.ts import 回来。这样做同时修掉一个安全点:dashboard 进程持有飞书 H5 登录凭证(BOTMUX_DASHBOARD_FEISHU_H5_APP_SECRET 等),当前 runLocalDevStep 会让 git/pnpm 子进程继承这些密钥——正是 #933 为另外三个宿主子进程堵掉的同类泄漏,本 PR 的更新子进程是新出现的第 4 个。redactChildEnv 是 denylist,会剥掉密钥但保留 PATH/HOME,git/pnpm 仍能正常跑(已在合并树上验证:结构测试 5/5、本 PR 测试 36/36 全绿)。

CLI 侧的 runInCheckout 不受影响——它跑在用户自己的 shell 进程里、不持有 dashboard 的 H5 凭证,属预期信任上下文,无需 redact。

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

@deepcoldy
deepcoldy force-pushed the feat/local-dev-git-update branch from 9c2a21b to afb9c22 Compare August 20, 2026 05:43
本地开发从源码运行(有 .git/src)现也能一键更新:定位 checkout → git
pull --ff-only → pnpm build → 从本 checkout 重启,CLI 与 Dashboard 都支持。
定位/干净检查/更新步骤/重启目标决策抽进 src/utils/local-dev-update.ts 共用。

关键不变量与边界:
- 脏工作区 fail closed(不自动 stash);git pull --ff-only(分叉/冲突报错停下);
  dist/ 被 gitignore,故 build 步不可省。
- run→restart 目标一致性:/api/update/run 成功后把本次构建的 checkout 绝对路径
  + 构建后 HEAD 固定为 server 端 pendingLocalDevRestart;/api/update/restart 经纯函数
  resolveLocalDevRestartTarget 复验(cli.js 缺失→update_target_unavailable、HEAD
  漂移→update_target_drifted),且都在 claim restart lease 之前 fail closed,杜绝
  并发 use:here 改指 wrapper 导致的“更新 B 却重启 C/A”。
- CLI cmdUpgradeLocalDev 的 clean-check + pull + build 进与 Dashboard 同一把跨进程
  update 锁(withFileLockSync(globalInstallUpdateLockTarget())),restart 走各自 lease。

安全:Dashboard 派生的 git/pnpm 子进程走 managed-spawn.ts 的 runLocalDevStep,
env 用 redactChildEnv(process.env)——与 start-bot/stop-bot/runGlobalInstall 同一条
不变量,杜绝 dashboard 的飞书 H5 凭证(APP_SECRET 等)随子进程外泄;denylist 仍保留
PATH/HOME,git/pnpm 正常解析。dashboard.ts 不再直接 import node:child_process
(由结构性测试 test/dashboard-managed-spawn.test.ts 守护)。

测试:local-dev-update(含真实 git 仓库集成 + resolveLocalDevRestartTarget 5 例覆盖
B→C 漂移回归)、dashboard-managed-spawn 结构测试、cli-update-alias、
dashboard-update-action 全绿;pnpm build + tsc --noEmit 通过。

Co-authored-by: Claude <noreply@anthropic.com>
@deepcoldy
deepcoldy force-pushed the feat/local-dev-git-update branch from afb9c22 to 6359e3a Compare August 20, 2026 05:56
@deepcoldy
deepcoldy merged commit 6080e36 into master Aug 20, 2026
5 of 6 checks passed
@github-actions

Copy link
Copy Markdown

🚀 Released in v3.16.0

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