@kira905/dsh-butler-install
v0.3.1
Published
Thin installer for the DSH butler suite: one command installs the plugin + CLI bundle through the host's own commands, then tells you whether it actually took effect, and can roll the whole batch back. Also ships a local wizard page (--ui) that lives outs
Maintainers
Readme
dsh-butler-install
这是什么
上面那一整套的一键安装器:一条命令装 16 个包,装完逐项自检,装坏了整批回滚。
它解决什么
一套东西散成十几个包,最容易死在「装一半」:有的装上了、有的没有,报错还只说
ERR_PNPM_。这个安装器给三件东西 —— ① 清单驱动(16 项一次装齐,缺一项就算失败)② 整批回滚(不是只撤最后一个)③ 失败说人话(把 pnpm 的环境障碍翻成「安全软件拦了建链接」「store 不在同一块盘」)。一条命令跑起来
npm i -g @kira905/dsh-butler-install dsh-butler-install --profile web --dry-run # 先看要装什么 dsh-butler-install --profile web # 真装想点着鼠标装也行:
dsh-butler-install --ui会起一个本地向导页(只绑127.0.0.1、只读、不执行)。先看它长什么样(真实输出)
dsh-butler-install —— 大管家全家桶薄壳安装器 用法: dsh-butler-install --profile <profile> [选项] --profile <name> 要操作的 profile(必填)。宿主会用它定位 <数据根>/profiles/<name> --manifest <path> 清单文件,默认用包内自带的 suite-manifest.json另有一个自足的 Windows exe(双击出界面,自带 node 运行时,不用先装环境)。
大管家全家桶的薄壳安装器:一条命令按清单装齐插件与命令行工具,装完立刻告诉你成了没,红了能整批回滚。
卖点不是"一键",是"一键之后的可验证性"。 一个包"装完 rc=0 但其实没生效"是这套生态里真实存在过的坑(宿主只会吐一行
declares no dsh.bundle警告,然后把它装成普通依赖)。本工具逐包核对包实体在不在、 版本落不落在期望区间、插件到底有没有进组合列表,任一条不过就判这一步失败。
30 秒上手
# ① 先看它会做什么(默认就是演练档:一条命令都不跑,零写入)
npx @kira905/dsh-butler-install --profile web
# ② 真装
npx @kira905/dsh-butler-install --profile web --apply
# ③ 只装一部分(组名 / 包名 / 别名都认)
npx @kira905/dsh-butler-install --profile web --apply --only 面板,看板装完/以后任何时候的自检,是 dsh-butler-core 仓里那条装后自检:
node <dsh-butler-core 仓根>/selfcheck-replica.mjs --manifest suite-manifest.json⚠️ 这条自检命令的现状:它目前在
dsh-butler-core仓里,还没有接清单 (只跑它内置的那几项判据)。本仓的suite-manifest.json的字段是为它将来接清单准备的 —— 字段含义见下文「清单字段」一节。别把它当成"已经能按清单跑了"。
想要个界面?--ui:挂在 DSH 外面的本地向导页(能在页面上点着装)
觉得命令行清单不好对?加 --ui 起一个本地网页:每一行写清装没装 / 什么版本 / 期望区间,
而且能在页面上点着装 —— 点了才装,不点不装。
# 起向导页(起来的时候什么都不装;点了【确认,开始】才装)
npx @kira905/dsh-butler-install --ui
# 换端口(默认 45080;被占会自动往后挑一个并打印实际地址)
npx @kira905/dsh-butler-install --ui --port 45090打开它打印的地址(形如 http://127.0.0.1:45080/)就能看到清单表。页面上的动作:
| 按钮 | 干什么 | 不干什么 |
|---|---|---|
| 开始安装 | 算一遍"将对哪个 profile 做哪几件事",把 16 项、profile 名、回滚点位置逐条念给你看 | 那一步一条命令都不跑、一个文件都不写 |
| 确认,开始 | 真装。逐项显示 待装 / 安装中 / 成功 / 跳过 / 失败 | 不替你重启 DSH(装完要你自己重启,见下) |
| 取消 | 杀掉当前那条命令的进程树 | 不是干净撤回:已经装好的项会留着;页面会明说"已取消,装了一半" |
| 卸载这次装的(推荐) | 把这一轮装上去的真的卸掉:插件从 profile 摘掉、全局命令行包从全局前缀删掉 ⇒ 重查会真的变回"未装" | 不覆盖任何文件,所以你随后在侧边栏另装的插件不受影响 |
| 只恢复配置文件到装前 | 只把这些声明层还到开跑前那一份 | node_modules 实体与全局 CLI 包还在,所以重开仍显示"已装";期间有别的程序改过这些文件就拒动 |
| 复制命令 | 把这一行放进剪贴板 —— 与 --dry-run 打印的是同一行(同一个函数产出) | 不替你执行 |
| 重查 | 重跑一遍探测、把最新读数写回同一张表 | 不改任何状态、不安装、不写文件 |
🔴 装完第 ⑥ 步:要重启 DSH,插件才会出现在侧边栏。 宿主只在启动时读一次组合列表, 不重启侧边栏不会多出任何东西 —— 这是最容易让人以为"装了没用、被骗了"的地方。 顺手那行「复制命令」给的是单项原语命令;要整批自己跑,用页面上那条 带
--apply的整批命令(不带--apply的那条只是演练,跑完什么都不装)。
为什么挂在 DSH 外面,而不是做成插件侧边栏页
这是设计前提,不是偷懒:
| | 插件侧边栏页(最初的想法) | 宿主外的本地页面(现在这样) |
|---|---|---|
| 要装插件 / 重启 DSH 才能打开这个向导页 | 要(dsh.bundle + cordis.patch.yml + 重启) | 不要 |
| DSH 升级会不会崩 | 会(往页面里注入 client DOM,宿主一改就可能失效) | 不会(它根本不进宿主) |
| 装 DSH 之前能不能用 | 不能 | 能 —— 还没装宿主时,向导正好可以告诉你"你得先装 DSH" |
| 成本 | 约 8h | 约 4h |
| 代价 | 写入口的风险面大 | 要监听一个本地端口(所以只许 127.0.0.1) |
安全边界(写死的,不是可选项)
- 🔴 只绑
127.0.0.1:同一局域网的别的机器连不上。没有0.0.0.0开关,也不读环境变量改它。 - 🔴 写入口只有四条,全是
POST:/install/preview(算一遍,不装)、/install/start(真装,必须带confirm:true)、/install/cancel、/install/rollback(撤回的两个动作)。除这四条之外的任何写方法一律 405,未知路径 404;Allow响应头按路径给(写路径POST、读路径GET),不再是一刀切的Allow: GET。 - 🔴 每个写入口都要过两道闸:安装令牌(进程级,启动时生成一次、印在控制台、也内嵌在页面 meta 里;
只从页面读、绝不拼进 URL —— 拼进 URL 就落进历史记录与代理日志)+ 同源校验
(
Origin的host:port逐字等于server.address()的address:port;端口是动态的,--port 0也照样对)。Sec-Fetch-Site存在才校验:Chrome 76+ / Safari 16.4+ 才有这个头,双击入口打开的是系统默认浏览器 (可能是 360 兼容模式),硬要求它等于给所有按钮静默 403。 - 🔴 这条要如实说:它防不住本机、同一个 Windows 用户下的其他程序 —— 那些程序读得到这个页面、 也调得到这些接口。它是一个"给本机同一个人用的安装器",不是多用户隔离的服务,别当成后者。
- 双击两次
.cmd不会起两个服务:新入口先探127.0.0.1:45080,认(GET /state里的service标记) 是自家服务就把浏览器指过去 ⇒ 两个标签看同一个服务,天然单实例。 - 端口被占:自动往后挑一个(最多 20 个),都不行就让系统分配,并打印实际地址。
「按顺序装」是什么意思
页面会给你建议顺序 + 为什么这么建议,但请看清它的含义:
组件之间没有代码依赖,谁先装谁后装都能装上。 真正有先后的只有三格: Node(≥22)→
dsh主包 → profile。下面的"顺序"是给人看的排查路径,不是成功的前提。 看到"必须按 1→2→3 才能成功"这种话,那是假的仪式。
不想碰命令?下载 exe 版(给完全不会命令行的人)
上面那套都要敲命令。连 PowerShell 都不想开的话,下载一个 exe 就行:
下载地址:https://pan.baidu.com/s/1CxK4g-DAwuGoFVKLp4GqCA?pwd=9jir(提取码
9jir;文件名dsh-butler-install-0.3.0-win-x64.exe,约 89 MB · 93,148,160 B)
用法就三步:
- 下载 → 双击。
- 会弹出一个黑窗口,它先体检(
dsh在不在、npm在不在),然后自动打开浏览器 —— 浏览器里那个页面就是上面说的安装向导,点两下就装完了。 - 装完直接关掉黑窗口(关窗口 = 关掉向导页;装到一半别关)。
🔴 第一次运行会被 Windows 拦一下(这里必须说清楚)
没有买代码签名证书(约 ¥1000+/年,暂时不买),所以 Windows SmartScreen 会弹:
Windows 已保护你的电脑
十个人里有九个以为中病毒就关掉了。正确做法:
- 点弹窗里的 「更多信息」;
- 再点 「仍要运行」。
(这拦的是"没有数字签名的程序",不是"这个程序有毒"。看不放心就别装,用上面的 npx 命令版。)
这个 exe 的前提
- 这台电脑上已经装好了 DSH(
dsh --version能打出版本号)。exe 不负责装 DSH、也不装 Node; 缺了它会在黑窗口里用大字告诉你缺什么、怎么装,然后停住等你按任意键(不会一闪就没)。 - 只有 Windows x64 版。自带 node 运行时所以 89 MB —— 好处是你机器上 node 不在 PATH 也能起界面。
exe 里到底跑了什么
它就是同一个安装向导:界面 HTML 内嵌在 exe 里、清单 suite-manifest.json 也内嵌在 exe 里,
所以把 exe 单独拷到空目录也照样能起界面(不读旁边任何文件)。命令行的行为也一模一样 —— 页面上
「复制命令」给你的,就是 CLI 真会跑的那一行。
自己造一个(仓里只留"怎么造",exe 不进仓)
npm install # esbuild + postject(devDependencies)
npm run build:exe # → dist/dsh-butler-install-<版本>-win-x64.exe实现是三步(scripts/build-exe.mjs,改路线前先看每步的注释):esbuild 打单文件 CJS →
node --experimental-sea-config 生成 blob → 复制本机 node.exe + postject 注入。
dist/ 在 .gitignore 里:exe 走网盘分发,进 git 只会把仓撑爆。
这一版不改 npm 包的行为:
dsh-butler-install、--ui、--apply、退出码、参数一个都没动。 会敲命令的人继续用上面那些;exe 只是同一套东西的"双击壳"。
它只做两件事,且只用两个原语
| 原语 | 用来装什么 |
|---|---|
| dsh plugin --profile <p> add <包>@<期望区间> / remove <包> | 插件类(要进 profile 的组合列表) |
| npm i -g <包>@<期望区间> / npm rm -g <包> | 命令行类(不参与挂载) |
明确不做(这是设计红线,不是懒):
- 不写
dsh.profile.bundles—— 那是宿主的活;本工具只调dsh plugin add,让宿主自己登记。 - 不写
cordis.patch.yml—— 需要手工 insert 的包,本工具只告诉你"它要手工挂",不代劳。 - 不碰
node_modules—— 不自己下载、不解包、不建链接。代码里没有第二条装包路径。 - 不替你重启 DSH,不改别的仓,不帮你决定要不要装。 ⚠️ 但插件装完你确实要自己重启 DSH,不然侧边栏不会出现它们 —— 这不是本工具偷懒,是宿主只在启动时读组合列表。
删掉本工具,一切照旧:它没有留下任何自己的运行时状态(只有回滚快照,见下)。
为什么安装命令要带 <包>@<期望区间>
因为注册表的 latest 标签和"最高版本号"不是一回事。实测踩过:某个包的 latest
指着一个旧版本,而更新的版本确实已发布、却没被 latest 指着 —— 不带区间装上来的是
旧的那一版,于是"期望 ^0.1.1、实体 0.1.0",装完看着成功、实际装错了版本。
带上区间之后,解析器只能落在期望区间里:落不进去就报错停下(而不是装个别的版本还报成功)。
退出码
| 码 | 含义 |
|---|---|
| 0 | 全绿(含"本来就已满足、这次全跳过") |
| 2 | 用法 / 清单 / 前置条件错误 —— 什么都没改 |
| 3 | 有步骤失败,但已回滚干净 |
| 4 | 有步骤失败,且回滚不干净(要人看:报告里会指出哪份文件对不上、快照在哪) |
失败时那段英文是什么意思(人话归因)
失败时原始报错仍然原样打印(一个字都不改),人话归因追加在它后面:
❌ 安装命令退出码 1:…(原始 stderr 首行,原样)
💡 这不是包坏了:你的安全软件(360 / EDR)拦了 pnpm 往 store 里建符号链接。
…(为什么)
怎么办:① 在安全软件里给 pnpm / node 放行(或暂时退出) ② 换个不会被拦的位置做数据根(--home 指到别处) ③ 直接重试一次(这类拦截是间歇的)认得出的环境障碍(实现见 lib/explain-failure.mjs:纯函数,不跑命令、不读盘、不发网络):
| 归因 | 原始报错里长什么样 | 一句话 |
|---|---|---|
| blocked-symlink | EPERM … symlink(同一行)或栈里有 registerProject | 安全软件(360 / EDR)拦了 pnpm 建符号链接 —— 不是包坏了;放行 / 换位置 / 重试 |
| store-mismatch | ERR_PNPM_UNEXPECTED_STORE | 这个 profile 的 node_modules 是用另一处 pnpm store 装的,两边对不上;用回原来那处 / 在那个 profile 重装 / 换干净数据根 |
| unknown-pnpm | 任何 ERR_PNPM_<码> | 确定是 pnpm 自己报的错,但这个码还没收录 ⇒ 把原码带走,去搜 / 贴给维护者 |
认不出来就一个字都不加(不猜)。归因只做展示:不参与判据,也不改退出码(0/2/3/4 口径不变)。
--ui 页面与 exe 走同一条路:页面进度里另有一条摘要行(💡 …|怎么办:…),失败项的详情里也带这一句。
退出码本身也写人话:Windows 上子进程被信号/异常结束时退出码是无符号的,
会看到 退出码 4294963248(= -4048,0xFFFFF030) —— 原始数值仍在最前面,括号里是等价写法。
回滚:两级,别只做一级
失败时默认只退失败那一步(前面已装好的几步保留,不用全重来):
| 参数 | 干什么 |
|---|---|
| --rollback / --rollback=step(默认) | 回到失败那一步之前:先卸掉这一步装进去的包,再回放那一步之前拍的快照 |
| --rollback=batch | 回到本轮开跑之前:逐包卸掉本轮装过的,再回放开跑前的快照 |
| --uninstall | 不是回滚:把选中的包从 profile / 全局前缀卸掉("我不想装这套了") |
一句话分清:回滚看的是"这一轮做了什么",卸载看的是"现在装着什么"。
快照拍的是声明层文件:package.json、pnpm-lock.yaml、cordis.patch.yml,
外加包管理器自己会写的 pnpm-workspace.yaml(实测会出现,不拍就漏一份)。开跑前一份,
之后每成功一步再拍一份 —— 这样"退到上一步之前"才有落点。
🔴 诚实边界:node_modules 里的实体文件不在快照内。remove 能摘掉依赖链接,
但清不干净包管理器的缓存与虚拟store,所以回滚后可能还留着实体文件 —— 报告会明写这一句,
不会假装"回滚 = 什么都没变"。
清单(suite-manifest.json)= 单一真相
安装器读它;将来 dsh-butler-core 的装后自检也读同一份。任何一方想加判据,只能改这份清单
—— 两处真相必然漂移。
字段:
| 字段 | 必填 | 说明 |
|---|---|---|
| name | ✅ | 包名(@scope/name 或裸名) |
| versionRange | ✅ | 期望区间,写法只接受 ^x.y.z / ~x.y.z / x.y.z。不支持 >=1.2、1.x、|| 组合 —— 与其两处语义各猜一半,不如让写法收敛 |
| type | ✅ | plugin(要进组合列表)/ cli(不参与挂载) |
| group | ✅ | 组名,--only 按组选 |
| mount | 插件可选 | auto(默认,装完应自动挂)/ manual(按设计不进组合列表,挂载由它自带的部署命令完成)/ none |
| probe | ✅ | 见下「探针」 |
| aliases | 可选 | --only 用的别名(如 面板、看板),不许与包名重复 |
| note | 可选 | 一句人话:这个包是什么、有什么坑 |
| excluded[] | 顶层可选 | 不可安装项:不是包的目录(如历史聚合仓)。演练档会跳过它并打印原因 |
期望版本一律写区间,不钉精确版本:宿主落进 profile 的本来就是区间, 钉精确版本只会造出"清单与实体永远不一致"的假红。
探针(probe.kind)
| 值 | 干什么 | 什么时候能用 |
|---|---|---|
| existence(默认) | 只看文件在不在:插件看 node_modules/<包>/package.json,命令行看 .bin 或全局前缀下的命令文件。一条命令都不执行 | 任何命令都能用 |
| help | 先静态核该命令源码里确有 --help 分支,再跑 <命令> --help | 仅当这条命令确实实现了 --help。核不到就拒绝执行并判失败 |
| cmd | 跑清单指定的一个只读命令 | 清单里必须写 readonlyBecause:凭什么认定它只读 |
🔴 为什么 help 要加硬闸:这批命令行工具里有相当一部分没有参数解析,未知参数被忽略后
直接执行真动作(导出快照、归档、改状态)。拿 --help 当"只看一眼"是错的。
所以 help 探针在执行之前先读源码,找不到 --help 分支就拒跑 —— 宁可判红,不赌它是只读的。
要取证"哪些命令真的实现了 --help",用随包附带的只读扫描器(一个命令都不跑):
npx -p @kira905/dsh-butler-install dsh-butler-install-probe-scan <某仓根或父目录>只有它标 ✅ 的命令,才允许在清单里写 probe.kind: "help"。
常用参数
| 参数 | 说明 |
|---|---|
| --profile <name> | 必填。宿主会定位 <数据根>/profiles/<name> |
| --apply | 真装(不加就是演练档) |
| --only / --skip | 逗号分隔,认组名 / 包名 / 包名末段 / 别名 |
| --upgrade | 已满足的项也重跑一遍安装("装过但坏了"的情形) |
| --rollback[=step\|batch] | 失败后怎么回滚,默认 step |
| --uninstall | 卸掉选中的包(不是回滚) |
| --npm-prefix <dir> | 全局包落点(默认取 npm 自己的全局前缀)。隔离演练时用它:装到临时目录,不碰你真正的全局包 |
| --home <dir> | DSH 数据根(默认 $DSH_HOME,再默认 ~/.dsh) |
| --timeout / --budget | 单步超时(默认 120s)/ 整批墙钟上限(默认 300s,超了就停下并回滚本批) |
| --snapshot-dir <dir> | 快照目录,默认 <数据根>/butler-install/snapshots/<时间戳> |
| --json | 只输出一份 JSON 汇总(人读的输出改走 stderr) |
| --ui | 起一个本地向导页,能在页面上点着装(点了才装、不点不装):只绑 127.0.0.1、写入口四条都得令牌 + 同源。装完要重启 DSH 才在侧边栏出现。见上文。用它时 --profile 可省(默认 web) |
| --port <n> | 向导页端口(只在 --ui 时有用),默认 45080;被占用会自动换一个并打印实际地址 |
它在 Windows 上怎么跑子进程
dsh / npm 在这台机器上是 .cmd 壳:不带 shell 的 spawn 直接 ENOENT,
显式写 .cmd 又会 EINVAL。所以本工具在 Windows 上过 shell 执行,并且参数做了最小必要引号化
(只有含空格等特殊字符的参数才加引号)——打印出来的命令行与真正执行的命令行由同一个函数生成,
报告里不会出现"说要跑 A、实际跑了 B"。
已知限制(别当 bug)
- 回滚不覆盖
node_modules里的实体文件与包管理器缓存(见上文诚实边界)。 - 用
--upgrade重装一个之前就装着的包、然后失败回滚时,声明层会回到快照值, 但实体层可能变成"声明在、实体更新过"的中间态 —— 再跑一次安装即可对齐。 mount: "manual"的包(按设计不进组合列表、靠自带部署命令挂载)本工具只装不挂, 挂载判据记"跳过"。- 不判"运行时会不会崩":
--dump-config只证明组合树里有这一行。真要验运行时,得自己起一次服务看日志。 - 不替你决定"要不要装某一件",也不做"装完自动起服务"。
- 🔴 向导页能装,但它防不住本机同用户的其它程序(见上文安全边界)。要更严的隔离,就别在本机跑第三方程序。
- 向导页的取消不是干净撤回:已装好的项留着,当前那一项可能只装了一半 —— 页面会直说,并给撤回入口。
- 向导页的**「只恢复配置文件到装前」只还声明层**:
node_modules实体与全局 CLI 包还在;期间有别的程序改过那几份文件就拒动。 - 向导页不落自己的状态:进度只在内存里。关掉窗口 = 这一批断了;重开页面按"现在装着什么"现跑给真实读数 (这比读一份可能过时的状态文件更准)。
夹具
npm test夹具全部落在自己的临时目录里(假 dsh、假 npm、假 profile、假全局前缀),
不读也不写真实数据根。覆盖:清单校验(每条错误都带文件名 + 行号)· 版本区间三态 ·
探针三分支(含"标了 help 但该命令没实现 ⇒ 拒执行且不产生任何执行")· 组合树三条计数判据
(各自独立可触发)· 快照回放与核对 · 演练档零副作用(目录指纹必须一模一样)· 退出码 0/3/4 ·
步级与整批回滚的差别 · 卸载 · 向导页(监听地址只许 127.0.0.1 / 写入口只有那四条、其余 405 /
令牌与同源两道闸 / 两步确认 / 进度轮询 / 撤回两条路与前置核对 / 页面数据与 --dry-run 同源 /
每请求零 spawn / 端口冲突自动换 / 不给 --ui 时输出逐字节不变)·
以及变异测试(4 条):把监听地址改成 0.0.0.0、去掉令牌校验、把 POST 白名单放开到任意路径、
去掉同源校验 —— 每一条都必须让夹具变红在指定判据上(改坏了还绿 ⇒ 这条夹具是假的)。
清单漂移判据(test/range-drift.mjs)
清单是单一真相,但真相会烂:写清单那天按当天的 latest 填区间,之后某个包跨了 0.x 的小版本 (0 阶段里这就是大版本),caret 的上界就把新版挡在门外 —— 照清单装一遍拿到旧一套,而且装后自检 照样绿(它读同一份清单)。所以这条判据对着清单外面的世界核,而不是自己核自己:
node test/range-drift.mjs # npm view,真网络
node test/range-drift.mjs --offline test/fixtures/npm-latest-snapshot.json # 读快照,零网络
node test/range-drift.mjs --json --manifest <别的清单>退出码:0 全部接受 · 3 有漂移(照清单装会拿到旧版)· 4 取数失败/无法判定(绝不当通过)·
2 用法错或清单读不进来。test/run-all.mjs 里那一格走仓内快照、零网络;快照的 capturedAt
是这份数据"什么时候是真的"的凭据,刷新清单区间时记得一起刷。
许可
MIT
