# @zhengjunyao/dsh-updater

[English](README.md) | 中文

跟踪 DeepSeek Harness 自身的版本并一键更新：跟 npm dist-tags（`latest` / `next` / `alpha`）与
GitHub Release（tag `dsh-v*`，双语正文），在 Web GUI 里显示**当前版本到目标版本之间每一个版本的更新点**
和一份分级风险清单，然后把安装交给一个分离助手——用 `mv` 把当前安装瞬时移开做备份 →
`npm install --prefix <当前安装前缀>` → 校验落地版本 → 用完全相同的命令重启 →
启动失败自动回滚。

## 一句话定位

DSH 的更新频率高（`0.1.x-rc.x` 通道多），而 `dsh` 自身不带任何自更新机制。

- `npm i -g @deepseek-ai/dsh` 之后还得自己重启一次，而且**重启命令必须和当初启动时一模一样**
  （端口、profile、cwd、node 路径都可能被改过）。
- 本机**可能有多份 DSH 安装**：`process.argv[1]` 反查出的运行安装、`PATH` 里最先命中的那份、
  以及 `npm config get prefix` 指向的那份，三者完全可以是三个不同目录——一次天真的
  `npm i -g` 会更新到最后那个，然后什么变化都不会发生。
- npm 发布频繁但只有 GitHub Release 写变更说明，且 npm 上有的版本不一定有对应 release；
  反过来，只在 GitHub 发布的版本根本装不上。

本插件把「看版本 → 读更新点 → 评风险 → 装 → 校验 → 重启 → 失败回滚」串成一条链，并让
**正在运行的安装目录**成为唯一的更新目标。

## 四个能力

### 1. 跟踪最新版本

- 三个通道：npm dist-tag `latest` / `next` / `alpha`，由配置项 `channel` 决定跟随哪一个。
- 后台定时检查：`checkIntervalMinutes`（默认 180 分钟，0 关闭）+ `checkOnBoot`（启动后 8 秒跑一次）。
  后台检查的目的不是打扰，而是让缓存里的 registry / releases 快照保持温热，
  打开面板或问 Agent 时不用等两次网络往返。
- 缓存：`cacheTtlMinutes`（默认 30 分钟）内的结果直接复用，落地在 `cache/registry.json`
  与 `cache/releases.json`；`force` 参数可绕过缓存。
- **上游故障不致命**：registry 或 GitHub 拉取失败时，沿用上一次成功的结果并在
  `upstream.warnings` 里标注「可能是旧的」——「连不上 GitHub」不会被显示成「没有新版本」。
- dist-tag 缺失或滞后时按通道阶梯回退：`next` / `alpha` 没打 tag 时，回退到
  `latest` 之上最新的预发布版本，避免「明明有更新的预发布却报无更新」。
- 面板同时显示 `newerElsewhere`：当前通道之外是否存在更新的版本。

### 2. 显示更新点

- 版本之间的事实来自 npm（**能不能装**），变更说明来自 GitHub Release（**改了什么**）。
- 更新点覆盖 `(当前版本, 目标版本]` 区间内**每一个已发布版本**，不只是一个 diff；
  用户跑 `latest` 常常一次跨过好几个发布。
- 每个版本按 GitHub Release 正文里的 `<h3>` / `#` 标题分组渲染条目；
  正文是双语的（中文段 + `---` + 英文段），解析优先取中文段，缺失时回退英文段。
- 拿不到对应 release 的版本**明确标出**「没有找到该版本的发布说明」（`notesMissing`），
  不假装没有变化。
- 只在 GitHub 发布、npm 上不存在的版本会被**排除**出可选目标（`installMissing`）并说明原因。
- GitHub 未认证限流是 60 次/小时；配置 `githubToken` 可提高上限，面板会显示剩余额度与重置时间。

### 3. 一键更新

入口是设置页「DSH 更新」卡片里的按钮或侧边栏入口，也可以由 `dsh_update` 工具触发。
真正的安装由分离助手 `helper/update-helper.mjs` 完成，因为**一个进程无法安全替换自己正在运行的代码**：

1. 宿主写 `pending-spec.json`，spawn 分离助手，回 202 之后才退出自己；
2. 助手等旧宿主释放端口（超时后 SIGKILL 旧进程兜底）；
3. **`mv`（rename）把当前安装原子移开做备份**——不是复制三万个文件；同目录 rename 是瞬时且原子的，
   因此回滚不依赖剩余空间；
4. `npm install -g --prefix <当前安装前缀> --registry <registry> --no-audit --no-fund @deepseek-ai/dsh@<版本>`
   （`--prefix` 永远显式传入，不用 `npm config get prefix`）；npm 输出实时流入日志；
5. **校验落地结果**：读 `package.json` 版本必须等于目标版本、入口脚本 `<root>/lib/bin.js` 必须存在、
   `node_modules/@deepseek-ai` 下必须有包（npm 退出码为 0 也可能留下半装目录）；
6. 用**完全相同的调用**重启（`process.execArgv` + `argv[1:]` + 原 cwd + 原可执行文件），
   所以 `dsh web --port 3080`、自定义 profile、自定义路径都原样保留；
7. 启动失败则把备份 `rename` 回原位并拉起旧版本；
8. 期间恢复控制台始终可用（默认 `http://127.0.0.1:3098`，被占用时依次试 +1…+9）。

### 4. 更新风险提示

版本号本身不足以回答「该不该按这个按钮」。风险引擎把目标版本、本机安装拓扑、
已装插件契约与本次操作的可行性汇总成一份有序清单，分三级：

- `high`：不要盲目执行，默认拦截一键更新直到显式确认；
- `warn`：先读一遍；
- `info`：背景信息。

检查项（每一条都来自代码，不是猜测）：

| 风险项 | 检查内容 | 级别 |
| --- | --- | --- |
| `prerelease` | 目标版本带预发布标识（rc / alpha / beta）。跟随 `latest` 通道却拿到预发布时升为 `high` | `warn` / `high` |
| `breaking` | 扫描更新点文本里的破坏性措辞（`BREAKING`、`不兼容`、`破坏性`、`重大变更`、`不再支持`、`已移除`、`移除了`、`废弃`、`已弃用`、`必须升级`、`需要手动`、`要迁移`、`升级指南`、`deprecat`、`backwards-incompatib` 等） | `high`（命中时）；否则一条 `info` 说明「未发现」 |
| `migration` | 更新点里提到迁移 / 改名（`migrat`、`迁移`、`改为`、`改名`、`重命名`、`rename`） | `warn` |
| `version-jump` / `version-gap` | 跨版本幅度：跨 ≥2 个次版本或跨主版本记 `warn`（跨 ≥4 个次版本或跨主版本时）；小跨度只记 `info` | `warn` / `info` |
| `notes-missing` | 区间内有版本在 npm 上存在却没有对应 GitHub release，变化内容未知 | `warn` |
| `not-on-npm` | 区间内有版本只在 GitHub 发布、npm 上没有，无法安装，已从可选目标排除 | `info` |
| `install-unknown` | 反查 `process.argv[1]` 得不到安装根，更新不知道要替换哪个目录 | `high` |
| `multi-install` | 本机存在多份 DSH 安装：说明本次只更新**正在运行**的那一份，并列出其它份；若 `PATH` 首选与运行中的不是同一个目录，明确指出「更新完重启后手动敲 `dsh` 可能又跑回旧的那一份」 | `high` |
| `path-mismatch` | 只有一份安装，但 `PATH` 首选仍与运行中的不一致 | `warn` |
| `prefix-drift` | `npm config get prefix` 与运行安装的前缀不一致（直接 `npm i -g` 会装到前者） | `info` |
| `preflight:*` | 预检结果逐条并入清单（见下） | 按检查项 |
| `plugin-incompatible` | 已装插件在各自 `package.json` 的 `dsh.engines.dsh` 里声明的范围是否覆盖目标版本；不覆盖时提示「bundle 加载失败会让插件树整体起不来」 | `high`（有插件不兼容时）；否则一条 `info` 汇总（含未声明范围的插件数） |
| `backup` | 更新前是否备份；关闭备份时明确写出「npm 会留下半装目录且无法自动恢复」 | `info` / `warn` |
| `session-interrupt` | 更新必然中断当前会话，并说明自动回滚是否开启 | `info` |

预检项（`preflight`，全部来自 `src/installs.ts`，无论通过与否都会逐条返回）：

| 预检项 | 通过条件 | 不通过时级别 |
| --- | --- | --- |
| `target` | 从 npm registry 解析到了目标版本号 | `high` |
| `install-root` | 从 `process.argv[1]` 反查到了 `@deepseek-ai/dsh` 包根 | `high` |
| `install-writable` | 当前用户对安装根（及其前缀）有写权限，不需要 sudo | `high` |
| `install-complete` | 安装根下 `node_modules/@deepseek-ai` 有包（否则疑似损坏，更新会顺带修复，但回滚点也是这个损坏状态） | `warn` |
| `npm` | `PATH` 里能找到 npm | `high` |
| `prefix` | 能从安装目录推导出 npm 前缀（`<prefix>/lib/node_modules` 布局） | `warn` |
| `node` | Node 主版本 ≥ 20（`@deepseek-ai/dsh` 自己不声明 `engines`，npm 不会替你拦低版本 Node，所以这里自设下限）；解析不出主版本号时跳过 | `high` |
| `tarball` | 目标版本的 `dist.tarball` HEAD 返回成功（镜像/网络异常只记 `warn`，明确 404 才 `high`） | `warn` / `high` |
| `disk` | 剩余空间 ≥ 2 GB（一份新安装 + 一份等大的旧安装备份 + npm 的下载副本）；读不到空间时跳过 | `high` |

分级与拦截：

- `level` 是所有风险项里最严重的一级；`score` 是 0–100 的加权分（`high` 45 分 / `warn` 12 分 / `info` 2 分，上限 100），供面板画表盘。
- `vetted` 为 `false` 表示清单里仍有 `high` 项——「还没被审过」。
- `confirmOn` 决定一键更新的拦截门槛：`high`（默认）只拦 `high`；`warn` 连 `warn` 也拦；
  `never` 完全关闭拦截（仍然显示清单）。
- 被拦时：HTTP 返回 **428**（面板）或工具返回 `requiresRiskConfirmation: true`（Agent），
  并附上最严重的前几项原因；确认后带 `confirmRisk: true` 重试。
- 检查在**点击更新的那一刻重新跑一遍**：用户确认过的那份报告，必须是即将执行的那次操作对应的报告。

## 安装

```sh
# 从 GitHub（仓库带 dsh-plugin topic）
dsh plugin --profile web add github:zhengjy01/dsh-updater

# 本地开发：link / file 两种写法
dsh plugin --profile web add link:/path/to/dsh-updater
dsh plugin --profile web add file:/path/to/dsh-updater

# 发布到 npm 之后
dsh plugin --profile web add @zhengjunyao/dsh-updater
```

装完需要重启一次 `dsh web` 才会加载宿主侧代码。

## 界面位置

- **设置页「DSH 更新」卡片**（`settings.section`，id `updater`，order 339）：当前版本与运行安装目录、
  本机安装份数、各通道目标版本、更新点、风险清单、一键更新按钮、更新历史、上次失败报告、插件设置。
- **侧边栏入口**：由 `entry` 控制——`sidebar`（默认，内联在侧边栏）/ `ball`（固定在角落的球）/
  `both`（两者都有）/ `off`（不挂载入口）。
- 更新期间显示全屏遮罩（`showOverlay`，默认开），新宿主应答后自动刷新页面（`autoReload`，默认开）。

侧边栏入口的 tooltip / aria-label 与全屏遮罩的标题同样是「更新」文案，界面文字与上面描述一致。

### 界面截图

三张实机截图（本机 macOS，DSH `0.1.5-rc.1`，窗口宽约 800px）。点击可看全尺寸。

**① 默认 `latest` 通道：已是最新**

<img src="docs/screenshots/01-panel-latest-channel.png" alt="DSH 更新卡片：latest 通道、已是最新、两份安装诊断" width="720">

注意画面里那块琥珀色的**安装诊断**：`latest` 通道下当前已是最新、没有任何更新可做，
但它照样显示——因为「本机有两份安装、PATH 首选与运行中的不是同一份」这件事与「有没有更新」无关，
用户随时都该看见。这是本机真实命中的情况，不是演示数据。

**② 折叠区：更新记录 / 更新日志 / 失败报告 / 可用备份 / 插件设置**

<img src="docs/screenshots/02-panel-collapsible-sections.png" alt="DSH 更新卡片下半部分：五个可折叠区块" width="720">

**③ 切到 `next` 通道：出现可更新版本与逐条更新点**

<img src="docs/screenshots/03-panel-update-notes.png" alt="DSH 更新卡片：next 通道、可更新到 0.1.5-rc.2、更新点分组条目" width="720">

徽章从「已是最新」变成「可更新到 0.1.5-rc.2」，右上角目标版本随通道切换，
下方展开「更新点 `0.1.5-rc.1 → 0.1.5-rc.2` · 跨 1 个版本」——每个版本一个可折叠区块，
带预发布徽章、发布日期与 GitHub release 链接，正文按 `体验优化` 等分组逐条列出。

## HTTP 路由

全部注册在 `/api/dsh-updater/*`，**loopback-only**（来源地址必须是 `127.0.0.1` / `::1` /
`::ffff:127.0.0.1`，`Host` 必须是 `127.0.0.1` / `localhost` / `[::1]`，`Sec-Fetch-Site: cross-site`
被拒，带 `Origin` 时必须同源）：

| 方法 | 路径 | 作用 | 关键状态码 |
| --- | --- | --- | --- |
| GET / HEAD | `/api/dsh-updater/probe` | 极小存活探针，页面重连时高频轮询 | 200；非 loopback 403；其它方法 405 |
| GET | `/api/dsh-updater/status` | 宿主 + 助手实时状态 + 配置 + 安装列表 + 备份 + 历史 + 失败报告 | 200；403 / 405 |
| GET | `/api/dsh-updater/check` | 解析通道、拉取更新点、给出风险清单（参数 `channel` / `version` / `force`） | 200 |
| POST | `/api/dsh-updater/update` | 交接更新：写 spec、spawn 助手，回 202 之后退出宿主 | **202** 已安排；400 无目标版本 / 识别不出安装目录 / 无 npm / 找不到助手脚本；**409** 已经是最新或目标不比当前新；**428** 需要风险确认；500 交接失败 |
| GET | `/api/dsh-updater/logs` | 日志尾部 + 疑似报错行（参数 `lines`、`which` = `latest` / `auto` / `spec` / 绝对路径） | 200 |
| GET | `/api/dsh-updater/history` | 更新记录（参数 `limit`，上限为 `historyLimit`） | 200 |
| GET | `/api/dsh-updater/failure` | 上次失败报告；`?format=md` 返回 `text/markdown` | 200 |
| GET | `/api/dsh-updater/installs` | 本机每一份 DSH 安装 + 工具链事实 | 200 |
| POST | `/api/dsh-updater/config` | 打补丁改配置；`{"reset": true}` 恢复默认 | 200；400 请求体不是合法 JSON |
| GET | `/api/dsh-updater/helper` | 经宿主读取助手实时状态 | 200 |
| POST | `/api/dsh-updater/helper/{retry\|rollback\|recheck}` | 把命令转发给恢复控制台 | 200；404 未知命令；502 控制台不可达 |

`/update` 明确先回包再退出进程：回复上线之后才调用 `commit()`，浏览器永远拿得到确定的答复。

## Agent 工具

| 工具 | 读写 | 参数 | 返回要点 |
| --- | --- | --- | --- |
| `dsh_update_status` | 只读 | `channel`、`force` | 运行版本与它实际加载的目录、本机安装份数、`PATH` 首选根、各通道目标版本、是否有更新、最近 6 条历史、可用备份、助手阶段与上次结果、失败报告正文、上游告警 |
| `dsh_update_check` | 只读 | `channel`、`version`、`force` | 逐版本更新点（含「没有找到该版本的发布说明」标记）、风险清单（`[级别] 标题｜详情`）、`riskLevel` / `riskScore` / `vetted`、不兼容插件、未通过的预检项 |
| `dsh_update` | **写：真正安装并重启** | `confirm`、`confirmRisk`、`channel`、`version`、`reason`、`delayMs` | 目标版本、助手 pid、备份目录、日志路径、恢复控制台地址、约多少毫秒后退出 |

`dsh_update` 的门槛：

- `confirm: true` 是必要条件——**未经用户同意不得更新**。本机铁律同样适用：不得私自重启或关闭 DSH，
  而未提供 `confirm` 时工具只返回提示、不执行任何动作。
- 风险清单里有 `high` 项时还需要 `confirmRisk: true`（被拦时返回 `requiresRiskConfirmation: true`
  与最严重的前几项）。
- 推荐流程：先 `dsh_update_check`，把**更新点与风险**讲给用户 → 得到明确同意 → 再调用 `dsh_update`。
- 更新必然中断当前回合并断开本会话连接；网页端会自动重连刷新。

## 配置

文件：`DSH_HOME/dsh-updater/config.json`（默认 `~/.dsh/dsh-updater/config.json`，0600）。
首次加载时按插件行里的种子生成一次，之后**文件为准**（每次更新前重新读取）。

| 字段 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `enabled` | boolean | `true` | 总开关；为 false 时不挂载路由与工具 |
| `announceToAgent` | boolean | `true` | 是否在 Agent 系统提示里公告本插件（工具与行为） |
| `entry` | `'sidebar' \| 'ball' \| 'both' \| 'off'` | `'sidebar'` | 面板入口在 GUI 里的位置 |
| `channel` | `'latest' \| 'next' \| 'alpha'` | `'latest'` | 解析目标版本时跟随的 npm dist-tag |
| `checkIntervalMinutes` | number | `180` | 后台重新检查的间隔（分钟），0 关闭定时器 |
| `checkOnBoot` | boolean | `true` | 宿主启动后不久跑一次检查 |
| `registry` | string | `'https://registry.npmjs.org'` | npm registry 基础地址 |
| `apiBase` | string | `'https://api.github.com'` | GitHub API 基础地址 |
| `githubToken` | string | `''` | 可选 GitHub token，提高未认证的 60 次/小时 release 限额 |
| `cacheTtlMinutes` | number | `30` | registry / releases 缓存的新鲜时长（分钟） |
| `npmArgsExtra` | string[] | `[]` | 追加到 npm install 命令后的额外参数（最多 20 项） |
| `backupWhenUpdating` | boolean | `true` | 替换前是否把当前安装完整备份（rename 移开） |
| `backupCount` | number | `3` | 保留几份更新前备份 |
| `autoRollback` | boolean | `true` | 新版本启动失败时是否自动恢复备份 |
| `confirmOn` | `'warn' \| 'high' \| 'never'` | `'high'` | 哪一级风险需要显式确认后才允许更新 |
| `fallbackPort` | number | `3098` | 分离恢复控制台的端口（被占用时依次试 +1…+9） |
| `bootTimeoutMs` | number | `180000` | 新宿主多久没应答算这次尝试失败 |
| `maxAttempts` | number | `2` | 单次更新请求的安装/启动尝试次数（1 = 不自动重试） |
| `killGraceMs` | number | `6000` | SIGTERM 旧宿主之后、助手 SIGKILL 之前的宽限 |
| `portFreeTimeoutMs` | number | `25000` | 等旧宿主释放端口的上限 |
| `lingerMs` | number | `4000` | 成功后助手保留控制台多久再退出 |
| `readyConfirmMs` | number | `4000` | 端口应答后、判定「已就绪」前必须守住的时长 |
| `bootWatchMs` | number | `30000` | 已报就绪后继续观察新宿主、把「起来又死」改判为失败的时间窗 |
| `installTimeoutMs` | number | `600000` | npm install 本身允许运行多久 |
| `logLines` | number | `400` | 面板与接口保留/显示的日志行数 |
| `autoReload` | boolean | `true` | 新宿主应答后自动刷新页面 |
| `showOverlay` | boolean | `true` | 等待期间显示全屏遮罩 |
| `probeIntervalMs` | number | `1200` | 重连探测间隔（毫秒） |
| `historyLimit` | number | `40` | `history.json` 里保留的更新记录条数 |

每个字段都会被钳制到合理区间（例如 `maxAttempts` 1–5、`backupCount` 0–20、
`installTimeoutMs` 30000–3600000），非法值静默回落到默认值，配置文件不会让插件抛错。

## 更新流程

```
  面板按钮 / dsh_update 工具
        │ POST /api/dsh-updater/update（先重新跑一遍检查与风险分级）
        ▼
  宿主（旧进程）
        ├─ 写 pending-spec.json（0600）+ 追加一条 history 记录（outcome: pending）
        ├─ spawn 分离助手 helper/update-helper.mjs --spec <spec>（detached, stdio ignore）
        ├─ 回 202（helperPid / logFile / consoleUrl / backupDir / exitInMs）
        └─ 回复上线后才 SIGTERM 自己（~700ms；15s 后硬退出兜底）
        ▼
  助手（零依赖纯 Node ESM，宿主死后继续活）
        ├─ 起恢复控制台（3098，占用则 +1…+9）
        ├─ 等 127.0.0.1:<port> 释放（超时则 SIGKILL 旧 pid）
        ├─ mv 当前安装 → 同级 .dsh-updater-backup-<版本>-<时间>（rename，原子）
        ├─ npm install -g --prefix <前缀> --registry <registry> --no-audit --no-fund @deepseek-ai/dsh@<版本>
        ├─ 校验：package.json 版本 == 目标；lib/bin.js 存在；node_modules/@deepseek-ai 非空
        ├─ 用完全相同的 file/argv/cwd/env 拉起新宿主（stdout+stderr → logs/<时间>-<pid>.log）
        ├─ 三重就绪判定：端口应答 + 进程存活 + 启动输出无 boot 致命行
        │                先守 readyConfirmMs（4s）再确认
        ├─ 就绪后继续观察 bootWatchMs（30s）：期间掉线或出现致命行 → 重新判为失败
        ├─ 失败 → rename 恢复备份 → 拉起旧版本（attempt 预算重置，再试一轮）
        ├─ 仍失败 → 停在 failed，控制台留着等人工处理
        └─ 成功 → 清理超出 backupCount 的旧备份 → 停留 lingerMs 后退出
        ▼
  页面轮询 /api/dsh-updater/probe → 新宿主应答 → location.reload()
```

助手与宿主通过 `status.json` 交换状态。宿主无法读到自己死后写下的结果，
所以每次启动时会做一次 **history 对账**：只有当 `status.json` 里的目标版本与来源版本
和最新那条记录吻合时，才把 `outcome` / `landedVersion` 补回历史。

## 文件与目录

插件自身的东西都在 `DSH_HOME/dsh-updater/` 下（可用 `DSH_UPDATER_HOME` 覆盖；
配置文件另可用 `DSH_UPDATER_CONFIG` 覆盖）：

| 路径 | 内容 |
| --- | --- |
| `config.json` | 插件设置（0600） |
| `history.json` | 每次更新请求一条记录，最新在前（上限 `historyLimit`） |
| `status.json` | 由分离助手写入的实时更新状态 |
| `pending-spec.json` | 下一次助手运行的交接载荷（0600） |
| `logs/<时间>-<pid>.log` | 一次更新的 npm 与宿主输出 |
| `cache/registry.json` | 最近一次 npm registry 拉取（带 TTL） |
| `cache/releases.json` | 最近一次 GitHub releases 拉取（带 TTL） |
| `last-failure.md` | 失败时写成的一份可整段复制的报告（成功启动时会被删除） |

更新前备份**不在**这里，而是刻意放在被保护安装的同级目录：

```
<安装根>/../.dsh-updater-backup-<版本>-<时间戳>/
例：/opt/homebrew/lib/node_modules/@deepseek-ai/.dsh-updater-backup-0.1.1-20260913-205812/
```

同目录才能保证 `rename` 成功，取出与放回都是单次系统调用；该目录名被 npm 视为普通目录而不会清理它。
`backupCount` 之外的旧备份在更新成功后会被删除。

## 安全与风险边界

诚实地讲清楚：

- **更新会中断当前回合、断开浏览器连接并重启宿主**。这是替换宿主进程的必然结果，不是可选项。
- **回滚恢复的是「更新前那一份安装」**，也就是旧代码；它不保证与更新后新版本写下的数据或配置兼容。
  「回滚成功」等于旧版本能起来，不等于一切回到更新之前。
- `confirmOn: 'never'` 会**关掉风险拦截**——风险清单照常显示，但一键更新不再被 `high` 项挡住。
  这是给用户对自己机器做的选择，默认值是 `high`。
- 预发布版本（`-rc.*` / `-alpha.*` / `-beta.*`）**官方并未标为稳定版**；接口与插件契约可能随时再变，
  回滚也不保证插件兼容。
- 路由只在 loopback 上注册，且有同源与 `Sec-Fetch-Site` 守卫；恢复控制台则是**开放 CORS**
  的本地 HTTP 服务（`Access-Control-Allow-Origin: *`），任何能在本机访问该端口的进程都能读到
  日志与失败报告，也能 POST 重试/回滚。它只应存在于一次更新的存活期间。
- **备份与安装目录必须在同一个文件系统上**：备份是 `rename`，跨文件系统无法完成，
  此时 `takeBackup()` 会失败并中止安装（不会降级为慢速复制）。
- 安装目录不可写时预检直接给 `high`：分离助手无法输入密码，需要 sudo 的场景只能手动在终端执行。
- 助手不会注册任何 OS 级后台服务；它只活一次更新，成功后停留数秒即退出，
  失败时留在原地等待处理（可随时 `kill`）。

## 故障排查

**恢复控制台**（默认 `http://127.0.0.1:3098`，被占用时端口号会 +1…+9；
`DSH_HOME/dsh-updater/status.json` 与 `/api/dsh-updater/helper` 里的 `fallbackPort` 是权威值）：

| 控制台路由 | 内容 |
| --- | --- |
| `GET /` | 页面：阶段与已用时、当前安装步骤（完整的 npm 命令行）、npm 输出、启动日志、疑似报错行、失败原因、退出码 |
| `GET /status` | 助手的状态快照（JSON） |
| `GET /report` | 完整失败报告（markdown），供整段复制 |
| `GET /log?lines=N` | 原始日志尾部（纯文本） |
| `POST /retry` | 重试启动（重置尝试预算、清掉上次失败信息） |
| `POST /rollback` | 恢复备份并拉起旧版本 |
| `POST /recheck` | 不重新拉起，只重新探测主端口 |

页面上的三个按钮（重试启动 / 回滚到旧版本 / 重新检测）就是最后三个路由。
主宿主还活着时，也可以用 `POST /api/dsh-updater/helper/{retry|rollback|recheck}` 把命令转发过去。

其他材料：

- `DSH_HOME/dsh-updater/last-failure.md` — 失败报告，比原始日志更适合整段贴给别人看。
- `DSH_HOME/dsh-updater/logs/` — 每次更新一个日志文件，npm 输出与宿主 stdout/stderr 都在里面。
- `GET /api/dsh-updater/logs` 或面板的日志区 — 尾部 + 疑似报错行（`Error` / `EADDRINUSE` /
  `MODULE_NOT_FOUND` / `npm ERR` / `Cannot find module` / 真实调用栈帧等）。
- 更新后宿主直接起不来（新版本也没能拉起来）时，日志里的 `lib/bin.js` 与依赖检查结果就是第一步；
  控制台报告会同时列出安装根、前缀、npm 命令与落地版本。

> 未验证：宿主自身的启动日志位置没有写进 README，因为插件把它作为「本次更新的日志」
> 记录在 `logs/` 与恢复控制台里，而 DSH 自己写到哪儿取决于启动方式（终端、launchd 等），
> 本仓库代码没有读取那个文件。

## 可移植性验证

本仓库遵循「发布前必须过可移植性门禁」：插件是给别人用的，只在开发机的 `link:` + 自己的
profile 下能跑不算通过。

```sh
npm run build            # tsc -p tsconfig.build.json && tsdown
npm run verify           # node scripts/portability.mjs --health /api/dsh-updater/probe
npm run verify:quick     # --skip-audit --stability 5
npm run verify:full      # --stability 30
```

**当前状态：`npm run verify` 连续两次 ✅ 通过**（隔离 `DSH_HOME` → tarball 装进空 profile →
独立实例就绪 → 健康路由 200 → 客户端 bundle 进 `__DSH_BOOT__.entries` → 就绪后再守 15 秒稳定，
各 7–8 次探测全部命中同一 pid）。

`scripts/portability.mjs` 会在**隔离的临时 `DSH_HOME`** 里新建空 profile、
用 `npm pack` 出来的 tarball 安装（不走 `link:`）、起一个独立实例、打健康路由
`/api/dsh-updater/probe`、抓 index 确认客户端 bundle 进入了 `__DSH_BOOT__.entries`，
并在就绪后再守一段稳定性观察窗。**必须隔离 `DSH_HOME`**：同机第二个 DSH 实例会等待
`~/.dsh/.credentials.yaml` 的写锁直到超时（`atomic-write: timed out waiting for the writer lock`）
而启动失败，不隔离的话验证结果全是假的。

**写动作（一键更新）有意不纳入脚本门禁**：它会对本机真实的全局部署执行 `npm install`
并重启真实宿主，自动化门禁不能触发这种副作用。

写动作的正确性由 `tests/helper.mjs` 在 `/tmp` 里端到端验证：每个场景都自建一个**假安装根**
与一个**假 npm**（按真实 npm 的行为写入包、或失败、或写错内容），再对它们运行**真的**
`helper/update-helper.mjs`，检查发生了什么。覆盖 `ok`（装完校验通过并拉起新版本）、
`wrong-version`（npm 退出 0 但版本不对）、`missing-entry`（版本对但入口脚本缺失）、
`empty-deps`（版本对但依赖域为空）、`npm-fails`、`boot-fails`（装好了但起不来 → 回滚 + 拉起旧版本）、
`boot-really-fails`（都起不来 → 控制台留着等人工）、`prune`（成功后清理超出 `backupCount` 的备份）、
`restart-only`（`install: false` 时绝不调用 npm）。

```sh
npm test   # smoke → versions → risk → helper → routes → handoff
```

全部测试都建立在**临时目录**与**假 npm** 之上，不会碰本机真实的 DSH 安装。当前工作树上
`npm run build && npm test` 的实测结果是 **517 项断言全部通过**（smoke 97 / versions 84 /
risk 64 / helper 65 / routes 110 / handoff 97）。

## 兼容性

- **要求**：DeepSeek Harness `>=0.1.5-rc.1`（即 `package.json` 的 `dsh.engines.dsh`）。
- **Node**：`^22.19.0 || >=24.0.0`。
- **peer**：`@deepseek-ai/dsh-*` 与 `react` / `react-dom` 见 `package.json` 的 `peerDependencies`。
- **平台**：代码在 macOS 上开发。安装根枚举覆盖 Homebrew 前缀、`/usr/local`、`~/.local`
  以及 Node 所在前缀；宿主由 launchd 托管时走 `observe` 模式——助手执行托管方的 kick 命令、
  跟随托管方写入的 stdout/stderr 日志判断存活，并**刻意不做长观察**（守住端口是托管方的职责），
  绝不自己再 spawn 一个宿主去抢端口。Linux / Windows 未实测。
- 与本作者的 `dsh-restart` 共用分离式助手模式（同一套「宿主先回包、助手接管、控制台兜底」结构）。

## License

MIT — 见 [LICENSE](LICENSE)。

仓库：<https://github.com/zhengjy01/dsh-updater>
