@kira905/dsh-butler-install 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +174 -2
- package/bin/dsh-butler-install.mjs +44 -0
- package/bin/probe-scan.mjs +103 -0
- package/lib/cli-args.mjs +167 -0
- package/lib/dump.mjs +73 -0
- package/lib/exec.mjs +106 -0
- package/lib/manifest.mjs +274 -0
- package/lib/probe.mjs +119 -0
- package/lib/run.mjs +664 -0
- package/lib/semver.mjs +95 -0
- package/lib/snapshot.mjs +105 -0
- package/lib/state.mjs +149 -0
- package/package.json +27 -4
- package/suite-manifest.json +163 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 kira905
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,175 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-butler-install
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
大管家全家桶的**薄壳安装器**:一条命令按清单装齐插件与命令行工具,装完**立刻告诉你成了没**,红了能整批回滚。
|
|
4
|
+
|
|
5
|
+
> 卖点不是"一键",是"一键之后的可验证性"。
|
|
6
|
+
> 一个包"装完 rc=0 但其实没生效"是这套生态里真实存在过的坑(宿主只会吐一行
|
|
7
|
+
> `declares no dsh.bundle` 警告,然后把它装成普通依赖)。本工具逐包核对**包实体在不在、
|
|
8
|
+
> 版本落不落在期望区间、插件到底有没有进组合列表**,任一条不过就判这一步失败。
|
|
9
|
+
|
|
10
|
+
## 30 秒上手
|
|
11
|
+
|
|
12
|
+
```powershell
|
|
13
|
+
# ① 先看它会做什么(默认就是演练档:一条命令都不跑,零写入)
|
|
14
|
+
npx @kira905/dsh-butler-install --profile web
|
|
15
|
+
|
|
16
|
+
# ② 真装
|
|
17
|
+
npx @kira905/dsh-butler-install --profile web --apply
|
|
18
|
+
|
|
19
|
+
# ③ 只装一部分(组名 / 包名 / 别名都认)
|
|
20
|
+
npx @kira905/dsh-butler-install --profile web --apply --only 面板,看板
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
装完/以后任何时候的自检,是 `dsh-butler-core` 仓里那条装后自检:
|
|
24
|
+
|
|
25
|
+
```powershell
|
|
26
|
+
node <dsh-butler-core 仓根>/selfcheck-replica.mjs --manifest suite-manifest.json
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
> ⚠️ **这条自检命令的现状**:它目前在 `dsh-butler-core` 仓里,**还没有接清单**
|
|
30
|
+
> (只跑它内置的那几项判据)。本仓的 `suite-manifest.json` 的字段是**为它将来接清单准备的**
|
|
31
|
+
> —— 字段含义见下文「清单字段」一节。别把它当成"已经能按清单跑了"。
|
|
32
|
+
|
|
33
|
+
## 它只做两件事,且只用两个原语
|
|
34
|
+
|
|
35
|
+
| 原语 | 用来装什么 |
|
|
36
|
+
|---|---|
|
|
37
|
+
| `dsh plugin --profile <p> add <包>@<期望区间>` / `remove <包>` | 插件类(要进 profile 的组合列表) |
|
|
38
|
+
| `npm i -g <包>@<期望区间>` / `npm rm -g <包>` | 命令行类(不参与挂载) |
|
|
39
|
+
|
|
40
|
+
明确**不做**(这是设计红线,不是懒):
|
|
41
|
+
|
|
42
|
+
- **不写** `dsh.profile.bundles` —— 那是宿主的活;本工具只调 `dsh plugin add`,让宿主自己登记。
|
|
43
|
+
- **不写** `cordis.patch.yml` —— 需要手工 insert 的包,本工具只告诉你"它要手工挂",不代劳。
|
|
44
|
+
- **不碰** `node_modules` —— 不自己下载、不解包、不建链接。代码里没有第二条装包路径。
|
|
45
|
+
- **不重启**你的 DSH,不改别的仓,不帮你决定要不要装。
|
|
46
|
+
|
|
47
|
+
删掉本工具,一切照旧:它没有留下任何自己的运行时状态(只有回滚快照,见下)。
|
|
48
|
+
|
|
49
|
+
## 为什么安装命令要带 `<包>@<期望区间>`
|
|
50
|
+
|
|
51
|
+
因为注册表的 `latest` 标签和"最高版本号"不是一回事。实测踩过:某个包的 `latest`
|
|
52
|
+
指着一个旧版本,而更新的版本确实已发布、却没被 `latest` 指着 —— 不带区间装上来的是
|
|
53
|
+
**旧的那一版**,于是"期望 ^0.1.1、实体 0.1.0",装完看着成功、实际装错了版本。
|
|
54
|
+
|
|
55
|
+
带上区间之后,解析器只能落在期望区间里:落不进去就报错停下(而不是装个别的版本还报成功)。
|
|
56
|
+
|
|
57
|
+
## 退出码
|
|
58
|
+
|
|
59
|
+
| 码 | 含义 |
|
|
60
|
+
|---|---|
|
|
61
|
+
| `0` | 全绿(含"本来就已满足、这次全跳过") |
|
|
62
|
+
| `2` | 用法 / 清单 / 前置条件错误 —— **什么都没改** |
|
|
63
|
+
| `3` | 有步骤失败,但**已回滚干净** |
|
|
64
|
+
| `4` | 有步骤失败,且**回滚不干净**(要人看:报告里会指出哪份文件对不上、快照在哪) |
|
|
65
|
+
|
|
66
|
+
## 回滚:两级,别只做一级
|
|
67
|
+
|
|
68
|
+
失败时默认**只退失败那一步**(前面已装好的几步保留,不用全重来):
|
|
69
|
+
|
|
70
|
+
| 参数 | 干什么 |
|
|
71
|
+
|---|---|
|
|
72
|
+
| `--rollback` / `--rollback=step`(默认) | 回到**失败那一步之前**:先卸掉这一步装进去的包,再回放那一步之前拍的快照 |
|
|
73
|
+
| `--rollback=batch` | 回到**本轮开跑之前**:逐包卸掉本轮装过的,再回放开跑前的快照 |
|
|
74
|
+
| `--uninstall` | **不是回滚**:把选中的包从 profile / 全局前缀卸掉("我不想装这套了") |
|
|
75
|
+
|
|
76
|
+
一句话分清:**回滚看的是"这一轮做了什么",卸载看的是"现在装着什么"。**
|
|
77
|
+
|
|
78
|
+
快照拍的是**声明层文件**:`package.json`、`pnpm-lock.yaml`、`cordis.patch.yml`,
|
|
79
|
+
外加包管理器自己会写的 `pnpm-workspace.yaml`(实测会出现,不拍就漏一份)。开跑前一份,
|
|
80
|
+
之后每成功一步再拍一份 —— 这样"退到上一步之前"才有落点。
|
|
81
|
+
|
|
82
|
+
🔴 **诚实边界**:`node_modules` 里的实体文件**不在快照内**。`remove` 能摘掉依赖链接,
|
|
83
|
+
但清不干净包管理器的缓存与虚拟store,所以回滚后可能还留着实体文件 —— 报告会明写这一句,
|
|
84
|
+
不会假装"回滚 = 什么都没变"。
|
|
85
|
+
|
|
86
|
+
## 清单(`suite-manifest.json`)= 单一真相
|
|
87
|
+
|
|
88
|
+
安装器读它;将来 `dsh-butler-core` 的装后自检也读**同一份**。任何一方想加判据,只能改这份清单
|
|
89
|
+
—— 两处真相必然漂移。
|
|
90
|
+
|
|
91
|
+
字段:
|
|
92
|
+
|
|
93
|
+
| 字段 | 必填 | 说明 |
|
|
94
|
+
|---|---|---|
|
|
95
|
+
| `name` | ✅ | 包名(`@scope/name` 或裸名) |
|
|
96
|
+
| `versionRange` | ✅ | **期望区间**,写法只接受 `^x.y.z` / `~x.y.z` / `x.y.z`。不支持 `>=1.2`、`1.x`、`||` 组合 —— 与其两处语义各猜一半,不如让写法收敛 |
|
|
97
|
+
| `type` | ✅ | `plugin`(要进组合列表)/ `cli`(不参与挂载) |
|
|
98
|
+
| `group` | ✅ | 组名,`--only` 按组选 |
|
|
99
|
+
| `mount` | 插件可选 | `auto`(默认,装完应自动挂)/ `manual`(按设计不进组合列表,挂载由它自带的部署命令完成)/ `none` |
|
|
100
|
+
| `probe` | ✅ | 见下「探针」 |
|
|
101
|
+
| `aliases` | 可选 | `--only` 用的别名(如 `面板`、`看板`),不许与包名重复 |
|
|
102
|
+
| `note` | 可选 | 一句人话:这个包是什么、有什么坑 |
|
|
103
|
+
| `excluded[]` | 顶层可选 | **不可安装项**:不是包的目录(如历史聚合仓)。演练档会跳过它并打印原因 |
|
|
104
|
+
|
|
105
|
+
**期望版本一律写区间,不钉精确版本**:宿主落进 profile 的本来就是区间,
|
|
106
|
+
钉精确版本只会造出"清单与实体永远不一致"的假红。
|
|
107
|
+
|
|
108
|
+
## 探针(`probe.kind`)
|
|
109
|
+
|
|
110
|
+
| 值 | 干什么 | 什么时候能用 |
|
|
111
|
+
|---|---|---|
|
|
112
|
+
| `existence`(**默认**) | 只看文件在不在:插件看 `node_modules/<包>/package.json`,命令行看 `.bin` 或全局前缀下的命令文件。**一条命令都不执行** | 任何命令都能用 |
|
|
113
|
+
| `help` | 先**静态核**该命令源码里确有 `--help` 分支,再跑 `<命令> --help` | **仅当这条命令确实实现了 `--help`**。核不到就**拒绝执行**并判失败 |
|
|
114
|
+
| `cmd` | 跑清单指定的一个**只读**命令 | 清单里必须写 `readonlyBecause`:凭什么认定它只读 |
|
|
115
|
+
|
|
116
|
+
🔴 **为什么 `help` 要加硬闸**:这批命令行工具里有相当一部分没有参数解析,未知参数被忽略后
|
|
117
|
+
**直接执行真动作**(导出快照、归档、改状态)。拿 `--help` 当"只看一眼"是错的。
|
|
118
|
+
所以 `help` 探针在**执行之前**先读源码,找不到 `--help` 分支就拒跑 —— 宁可判红,不赌它是只读的。
|
|
119
|
+
|
|
120
|
+
要取证"哪些命令真的实现了 `--help`",用随包附带的只读扫描器(一个命令都不跑):
|
|
121
|
+
|
|
122
|
+
```powershell
|
|
123
|
+
npx -p @kira905/dsh-butler-install dsh-butler-install-probe-scan <某仓根或父目录>
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
只有它标 ✅ 的命令,才允许在清单里写 `probe.kind: "help"`。
|
|
127
|
+
|
|
128
|
+
## 常用参数
|
|
129
|
+
|
|
130
|
+
| 参数 | 说明 |
|
|
131
|
+
|---|---|
|
|
132
|
+
| `--profile <name>` | 必填。宿主会定位 `<数据根>/profiles/<name>` |
|
|
133
|
+
| `--apply` | 真装(不加就是演练档) |
|
|
134
|
+
| `--only` / `--skip` | 逗号分隔,认组名 / 包名 / 包名末段 / 别名 |
|
|
135
|
+
| `--upgrade` | 已满足的项也重跑一遍安装("装过但坏了"的情形) |
|
|
136
|
+
| `--rollback[=step\|batch]` | 失败后怎么回滚,默认 `step` |
|
|
137
|
+
| `--uninstall` | 卸掉选中的包(不是回滚) |
|
|
138
|
+
| `--npm-prefix <dir>` | 全局包落点(默认取 npm 自己的全局前缀)。**隔离演练时用它**:装到临时目录,不碰你真正的全局包 |
|
|
139
|
+
| `--home <dir>` | DSH 数据根(默认 `$DSH_HOME`,再默认 `~/.dsh`) |
|
|
140
|
+
| `--timeout` / `--budget` | 单步超时(默认 120s)/ 整批墙钟上限(默认 300s,超了就停下并回滚本批) |
|
|
141
|
+
| `--snapshot-dir <dir>` | 快照目录,默认 `<数据根>/butler-install/snapshots/<时间戳>` |
|
|
142
|
+
| `--json` | 只输出一份 JSON 汇总(人读的输出改走 stderr) |
|
|
143
|
+
|
|
144
|
+
## 它在 Windows 上怎么跑子进程
|
|
145
|
+
|
|
146
|
+
`dsh` / `npm` 在这台机器上是 `.cmd` 壳:不带 shell 的 spawn 直接 `ENOENT`,
|
|
147
|
+
显式写 `.cmd` 又会 `EINVAL`。所以本工具在 Windows 上过 shell 执行,并且参数做了最小必要引号化
|
|
148
|
+
(只有含空格等特殊字符的参数才加引号)——打印出来的命令行与真正执行的命令行由同一个函数生成,
|
|
149
|
+
报告里不会出现"说要跑 A、实际跑了 B"。
|
|
150
|
+
|
|
151
|
+
## 已知限制(别当 bug)
|
|
152
|
+
|
|
153
|
+
- 回滚**不覆盖** `node_modules` 里的实体文件与包管理器缓存(见上文诚实边界)。
|
|
154
|
+
- 用 `--upgrade` 重装一个**之前就装着**的包、然后失败回滚时,声明层会回到快照值,
|
|
155
|
+
但实体层可能变成"声明在、实体更新过"的中间态 —— 再跑一次安装即可对齐。
|
|
156
|
+
- `mount: "manual"` 的包(按设计不进组合列表、靠自带部署命令挂载)本工具只装不挂,
|
|
157
|
+
挂载判据记"跳过"。
|
|
158
|
+
- 不判"运行时会不会崩":`--dump-config` 只证明组合树里有这一行。真要验运行时,得自己起一次服务看日志。
|
|
159
|
+
- 不替你决定"要不要装某一件",也不做"装完自动起服务"。
|
|
160
|
+
|
|
161
|
+
## 夹具
|
|
162
|
+
|
|
163
|
+
```powershell
|
|
164
|
+
npm test
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
夹具全部落在自己的临时目录里(假 `dsh`、假 `npm`、假 profile、假全局前缀),
|
|
168
|
+
不读也不写真实数据根。覆盖:清单校验(每条错误都带文件名 + 行号)· 版本区间三态 ·
|
|
169
|
+
探针三分支(含"标了 `help` 但该命令没实现 ⇒ 拒执行且不产生任何执行")· 组合树三条计数判据
|
|
170
|
+
(各自独立可触发)· 快照回放与核对 · 演练档零副作用(目录指纹必须一模一样)· 退出码 0/3/4 ·
|
|
171
|
+
步级与整批回滚的差别 · 卸载。
|
|
172
|
+
|
|
173
|
+
## 许可
|
|
174
|
+
|
|
175
|
+
MIT
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 入口:解析参数 → 交给 runSuite。
|
|
4
|
+
* 这里只做三件事:--help / --version 的早退、参数错误转 rc 2、--json 的输出整形。
|
|
5
|
+
* 真正的编排一律在 lib/run.mjs,别把逻辑塞回入口。
|
|
6
|
+
*/
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { parseArgs, HELP_TEXT, EXIT } from '../lib/cli-args.mjs';
|
|
11
|
+
import { runSuite } from '../lib/run.mjs';
|
|
12
|
+
|
|
13
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
|
|
15
|
+
function pkgVersion() {
|
|
16
|
+
try {
|
|
17
|
+
const json = JSON.parse(fs.readFileSync(path.join(here, '..', 'package.json'), 'utf8'));
|
|
18
|
+
return json.version;
|
|
19
|
+
} catch {
|
|
20
|
+
return 'unknown';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const { options, errors } = parseArgs(process.argv.slice(2));
|
|
25
|
+
|
|
26
|
+
if (options.help) {
|
|
27
|
+
process.stdout.write(HELP_TEXT);
|
|
28
|
+
process.exitCode = EXIT.OK;
|
|
29
|
+
} else if (options.version) {
|
|
30
|
+
process.stdout.write(`${pkgVersion()}\n`);
|
|
31
|
+
process.exitCode = EXIT.OK;
|
|
32
|
+
} else if (errors.length) {
|
|
33
|
+
for (const e of errors) process.stderr.write(`✖ ${e}\n`);
|
|
34
|
+
process.stderr.write('用 --help 看可用参数。\n');
|
|
35
|
+
process.exitCode = EXIT.USAGE;
|
|
36
|
+
} else {
|
|
37
|
+
const { exitCode, summary } = await runSuite(options);
|
|
38
|
+
if (options.json) {
|
|
39
|
+
process.stdout.write(`${JSON.stringify({ exitCode, ...summary }, null, 2)}\n`);
|
|
40
|
+
} else {
|
|
41
|
+
process.stdout.write(`\n退出码 ${exitCode}:${exitCode === 0 ? '全绿' : exitCode === 3 ? '有步骤失败,已回滚干净' : exitCode === 4 ? '有步骤失败,回滚不干净(要人看)' : '用法/前置条件错误,什么都没改'}\n`);
|
|
42
|
+
}
|
|
43
|
+
process.exitCode = exitCode;
|
|
44
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 探针取证工具:扫一批包的 `bin` 源文件,逐个命令回答"它到底有没有实现 --help"。
|
|
4
|
+
*
|
|
5
|
+
* 为什么单独做成一条命令:清单里把某一项升级成 `probeKind: "help"` 之前,必须先
|
|
6
|
+
* **按命令**核过。这个判断很容易想当然("同一个仓里别的命令都有,这个也一定有"),
|
|
7
|
+
* 而错了的后果是探针把 `--help` 当普通参数丢给脚本,脚本照常导出/归档/改状态 ——
|
|
8
|
+
* 那不是"看一眼",是真干活。所以取证动作要能一条命令重跑、结论能贴进清单。
|
|
9
|
+
*
|
|
10
|
+
* **零执行**:只读文件、只做字符串判断,一个 bin 都不跑。
|
|
11
|
+
*
|
|
12
|
+
* 用法:
|
|
13
|
+
* dsh-butler-install-probe-scan <目录> [--json] [--depth N]
|
|
14
|
+
* 目录可以是某个包根,也可以是装着多个包根的父目录。
|
|
15
|
+
*/
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { hasHelpBranch } from '../lib/probe.mjs';
|
|
19
|
+
|
|
20
|
+
const argv = process.argv.slice(2);
|
|
21
|
+
if (argv.includes('-h') || argv.includes('--help') || argv.length === 0) {
|
|
22
|
+
process.stdout.write(`dsh-butler-install-probe-scan —— 只读核对哪些命令真的实现了 --help
|
|
23
|
+
|
|
24
|
+
用法:
|
|
25
|
+
dsh-butler-install-probe-scan <目录> [--json] [--depth N]
|
|
26
|
+
|
|
27
|
+
<目录> 某个包的根目录,或装着多个包根的父目录
|
|
28
|
+
--json 输出一份 JSON(贴进清单/报告用)
|
|
29
|
+
--depth N 往下找 package.json 的层数,默认 3
|
|
30
|
+
`);
|
|
31
|
+
process.exitCode = argv.length === 0 ? 2 : 0;
|
|
32
|
+
} else {
|
|
33
|
+
const json = argv.includes('--json');
|
|
34
|
+
const depthIndex = argv.indexOf('--depth');
|
|
35
|
+
const maxDepth = depthIndex >= 0 ? Number(argv[depthIndex + 1]) || 3 : 3;
|
|
36
|
+
const root = argv.find((a) => !a.startsWith('--') && a !== String(maxDepth) && a !== argv[depthIndex + 1]);
|
|
37
|
+
|
|
38
|
+
if (!root) {
|
|
39
|
+
process.stderr.write('✖ 缺目录参数\n');
|
|
40
|
+
process.exitCode = 2;
|
|
41
|
+
} else {
|
|
42
|
+
const results = scan(path.resolve(root), maxDepth);
|
|
43
|
+
if (json) {
|
|
44
|
+
process.stdout.write(`${JSON.stringify({ root: path.resolve(root), results }, null, 2)}\n`);
|
|
45
|
+
} else {
|
|
46
|
+
process.stdout.write(`扫描根:${path.resolve(root)}\n`);
|
|
47
|
+
process.stdout.write('(只读:一个命令都没执行;结论来自源码静态判断)\n\n');
|
|
48
|
+
for (const r of results) {
|
|
49
|
+
process.stdout.write(`${r.helpBranch ? '✅' : '❌'} ${r.pkg} ${r.cmd} → ${r.file}\n`);
|
|
50
|
+
}
|
|
51
|
+
const yes = results.filter((r) => r.helpBranch).length;
|
|
52
|
+
process.stdout.write(`\n共 ${results.length} 个命令:有 --help 分支 ${yes} 个、没有 ${results.length - yes} 个。\n`);
|
|
53
|
+
process.stdout.write('只有 ✅ 那些才允许在清单里标 probeKind=help;其余一律用 existence(只查文件在不在,零执行)。\n');
|
|
54
|
+
}
|
|
55
|
+
process.exitCode = 0;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function scan(dir, maxDepth) {
|
|
60
|
+
const out = [];
|
|
61
|
+
for (const pkgDir of findPackageDirs(dir, maxDepth, 0)) {
|
|
62
|
+
const pkgJson = path.join(pkgDir, 'package.json');
|
|
63
|
+
let json;
|
|
64
|
+
try {
|
|
65
|
+
json = JSON.parse(fs.readFileSync(pkgJson, 'utf8'));
|
|
66
|
+
} catch {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const bin = json.bin;
|
|
70
|
+
if (!bin) continue;
|
|
71
|
+
const pairs = typeof bin === 'string' ? [[json.name || path.basename(pkgDir), bin]] : Object.entries(bin);
|
|
72
|
+
for (const [cmd, rel] of pairs) {
|
|
73
|
+
const file = path.resolve(pkgDir, rel);
|
|
74
|
+
let source = '';
|
|
75
|
+
try {
|
|
76
|
+
source = fs.readFileSync(file, 'utf8');
|
|
77
|
+
} catch {
|
|
78
|
+
out.push({ pkg: json.name, cmd, file, helpBranch: false, reason: '读不到源文件' });
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
out.push({ pkg: json.name, cmd, file, helpBranch: hasHelpBranch(source) });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function findPackageDirs(dir, maxDepth, depth) {
|
|
88
|
+
if (depth > maxDepth) return [];
|
|
89
|
+
const out = [];
|
|
90
|
+
if (fs.existsSync(path.join(dir, 'package.json'))) out.push(dir);
|
|
91
|
+
let entries = [];
|
|
92
|
+
try {
|
|
93
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
94
|
+
} catch {
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
for (const entry of entries) {
|
|
98
|
+
if (!entry.isDirectory()) continue;
|
|
99
|
+
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
|
|
100
|
+
out.push(...findPackageDirs(path.join(dir, entry.name), maxDepth, depth + 1));
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
package/lib/cli-args.mjs
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 命令行参数解析。
|
|
3
|
+
*
|
|
4
|
+
* 纪律:**未知参数一律报错退出**。这批命令行工具里有不少"未知参数被忽略、然后照常干活"
|
|
5
|
+
* 的先例 —— 安装器自己绝不能成为同一类东西(写错一个字母就静默跑成别的事,比报错坏得多)。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const EXIT = {
|
|
9
|
+
OK: 0,
|
|
10
|
+
USAGE: 2,
|
|
11
|
+
ROLLED_BACK: 3,
|
|
12
|
+
UNCLEAN: 4,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const HELP_TEXT = `dsh-butler-install —— 大管家全家桶薄壳安装器
|
|
16
|
+
|
|
17
|
+
用法:
|
|
18
|
+
dsh-butler-install --profile <profile> [选项]
|
|
19
|
+
|
|
20
|
+
--profile <name> 要操作的 profile(必填)。宿主会用它定位 <数据根>/profiles/<name>
|
|
21
|
+
--manifest <path> 清单文件,默认用包内自带的 suite-manifest.json
|
|
22
|
+
--apply 真装。**不加就是演练档**:只打印将要执行的命令与每步判据,一条命令都不跑
|
|
23
|
+
--only <列表> 只装这些:组名 / 包名 / 别名,逗号分隔,可重复给
|
|
24
|
+
--skip <列表> 跳过这些(同 --only 的写法)
|
|
25
|
+
--upgrade 已满足的项也重跑一遍安装(用于"装过但坏了"的情形)
|
|
26
|
+
--rollback[=step|batch] 失败后怎么回滚:默认 step(只回滚失败那一步),batch 退整批
|
|
27
|
+
--uninstall 把选中的包卸掉(**不是回滚**:卸载看"现在装着什么",回滚看"这轮做了什么")
|
|
28
|
+
--npm-prefix <dir> 全局包的落点(默认取 npm 自己的全局前缀;隔离演练时用它)
|
|
29
|
+
--home <dir> DSH 数据根(默认 $DSH_HOME,再默认 ~/.dsh)
|
|
30
|
+
--timeout <秒> 单步超时,默认 120
|
|
31
|
+
--budget <秒> 整批墙钟上限,默认 300;超了停下并回滚本批
|
|
32
|
+
--snapshot-dir <dir> 快照目录,默认 <数据根>/butler-install/snapshots/<时间戳>
|
|
33
|
+
--json 只输出一份 JSON 汇总(人读的输出改走 stderr)
|
|
34
|
+
--dry-run 显式写"演练"(与默认行为相同,便于脚本表达意图)
|
|
35
|
+
-h, --help 看这份说明
|
|
36
|
+
-V, --version 看版本号
|
|
37
|
+
|
|
38
|
+
退出码:
|
|
39
|
+
0 全绿
|
|
40
|
+
2 用法/清单/前置条件错误(什么都没改)
|
|
41
|
+
3 有步骤失败,但已回滚干净
|
|
42
|
+
4 有步骤失败,且回滚不干净(要人看:报告里会指出哪份文件对不上)
|
|
43
|
+
|
|
44
|
+
只用两个原语:\`dsh plugin --profile <p> add|remove …\` 与 \`npm i -g|npm rm -g …\`。
|
|
45
|
+
本工具**不写** dsh.profile.bundles、**不写** cordis.patch.yml、**不碰** node_modules —— 那些是宿主的事。
|
|
46
|
+
`;
|
|
47
|
+
|
|
48
|
+
const VALUE_FLAGS = new Set([
|
|
49
|
+
'--profile',
|
|
50
|
+
'--manifest',
|
|
51
|
+
'--only',
|
|
52
|
+
'--skip',
|
|
53
|
+
'--npm-prefix',
|
|
54
|
+
'--home',
|
|
55
|
+
'--timeout',
|
|
56
|
+
'--budget',
|
|
57
|
+
'--snapshot-dir',
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
const BOOL_FLAGS = new Set(['--apply', '--upgrade', '--uninstall', '--json', '--dry-run', '--help', '-h', '--version', '-V', '--rollback']);
|
|
61
|
+
|
|
62
|
+
export function parseArgs(argv) {
|
|
63
|
+
const options = {
|
|
64
|
+
profile: null,
|
|
65
|
+
manifest: null,
|
|
66
|
+
apply: false,
|
|
67
|
+
only: [],
|
|
68
|
+
skip: [],
|
|
69
|
+
upgrade: false,
|
|
70
|
+
rollbackMode: 'step',
|
|
71
|
+
uninstall: false,
|
|
72
|
+
npmPrefix: null,
|
|
73
|
+
home: null,
|
|
74
|
+
timeoutMs: 120000,
|
|
75
|
+
budgetMs: 300000,
|
|
76
|
+
snapshotDir: null,
|
|
77
|
+
json: false,
|
|
78
|
+
help: false,
|
|
79
|
+
version: false,
|
|
80
|
+
};
|
|
81
|
+
const errors = [];
|
|
82
|
+
|
|
83
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
84
|
+
const token = argv[i];
|
|
85
|
+
if (token === '--rollback') {
|
|
86
|
+
const next = argv[i + 1];
|
|
87
|
+
if (next === 'step' || next === 'batch') {
|
|
88
|
+
options.rollbackMode = next;
|
|
89
|
+
i += 1;
|
|
90
|
+
}
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (token.startsWith('--rollback=')) {
|
|
94
|
+
const value = token.slice('--rollback='.length);
|
|
95
|
+
if (value !== 'step' && value !== 'batch') errors.push(`--rollback 只接受 step 或 batch(现在读到 ${JSON.stringify(value)})`);
|
|
96
|
+
else options.rollbackMode = value;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (token.startsWith('--')) {
|
|
100
|
+
const eq = token.indexOf('=');
|
|
101
|
+
const flag = eq >= 0 ? token.slice(0, eq) : token;
|
|
102
|
+
const inlineValue = eq >= 0 ? token.slice(eq + 1) : null;
|
|
103
|
+
if (VALUE_FLAGS.has(flag)) {
|
|
104
|
+
const value = inlineValue !== null ? inlineValue : argv[i + 1];
|
|
105
|
+
if (value === undefined || value.startsWith('--')) {
|
|
106
|
+
errors.push(`${flag} 需要一个值`);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (inlineValue === null) i += 1;
|
|
110
|
+
applyValue(options, flag, value, errors);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (BOOL_FLAGS.has(flag)) {
|
|
114
|
+
if (inlineValue !== null) errors.push(`${flag} 是开关,不接受值`);
|
|
115
|
+
if (flag === '--apply') options.apply = true;
|
|
116
|
+
if (flag === '--upgrade') options.upgrade = true;
|
|
117
|
+
if (flag === '--uninstall') options.uninstall = true;
|
|
118
|
+
if (flag === '--json') options.json = true;
|
|
119
|
+
if (flag === '--help' || flag === '-h') options.help = true;
|
|
120
|
+
if (flag === '--version' || flag === '-V') options.version = true;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
errors.push(`未知参数:${token}(用 --help 看可用参数)`);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (token === '-h') { options.help = true; continue; }
|
|
127
|
+
if (token === '-V') { options.version = true; continue; }
|
|
128
|
+
errors.push(`多余的位置参数:${token}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (!options.help && !options.version && !options.profile) errors.push('缺 --profile;用法:dsh-butler-install --profile <name> [--apply]');
|
|
132
|
+
return { options, errors };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function applyValue(options, flag, value, errors) {
|
|
136
|
+
if (flag === '--profile') {
|
|
137
|
+
if (!/^[A-Za-z0-9._-]+$/.test(value)) errors.push(`--profile 只接受字母数字与 . _ -(现在读到 ${JSON.stringify(value)})`);
|
|
138
|
+
options.profile = value;
|
|
139
|
+
} else if (flag === '--manifest') {
|
|
140
|
+
options.manifest = value;
|
|
141
|
+
} else if (flag === '--only') {
|
|
142
|
+
options.only.push(...splitList(value));
|
|
143
|
+
} else if (flag === '--skip') {
|
|
144
|
+
options.skip.push(...splitList(value));
|
|
145
|
+
} else if (flag === '--npm-prefix') {
|
|
146
|
+
options.npmPrefix = value;
|
|
147
|
+
} else if (flag === '--home') {
|
|
148
|
+
options.home = value;
|
|
149
|
+
} else if (flag === '--snapshot-dir') {
|
|
150
|
+
options.snapshotDir = value;
|
|
151
|
+
} else if (flag === '--timeout') {
|
|
152
|
+
const n = Number(value);
|
|
153
|
+
if (!Number.isFinite(n) || n <= 0) errors.push(`--timeout 要正数秒(现在读到 ${JSON.stringify(value)})`);
|
|
154
|
+
else options.timeoutMs = Math.round(n * 1000);
|
|
155
|
+
} else if (flag === '--budget') {
|
|
156
|
+
const n = Number(value);
|
|
157
|
+
if (!Number.isFinite(n) || n <= 0) errors.push(`--budget 要正数秒(现在读到 ${JSON.stringify(value)})`);
|
|
158
|
+
else options.budgetMs = Math.round(n * 1000);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function splitList(value) {
|
|
163
|
+
return String(value)
|
|
164
|
+
.split(/[,,]/)
|
|
165
|
+
.map((s) => s.trim())
|
|
166
|
+
.filter(Boolean);
|
|
167
|
+
}
|
package/lib/dump.mjs
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 解析 `dsh --profile <p> --dump-config` 的输出。
|
|
3
|
+
*
|
|
4
|
+
* 为什么非要数次数、不满足于"抓到那一行就不管了":组合树里同一份实现被挂两次时,
|
|
5
|
+
* 宿主**不报错**、只是多打一行 —— 用户装完一路绿灯,实际用的是哪一份实现谁也说不清。
|
|
6
|
+
* 所以判据是"出现恰好 1 次",不是"出现过"。
|
|
7
|
+
*
|
|
8
|
+
* 输出形态(实测):
|
|
9
|
+
* # == <bundle 名> ← 组合(bundle)级
|
|
10
|
+
* # == <bundle 名>, patched by <包名> ← 被 patch 的 bundle,别把这行算成"包里出现了"
|
|
11
|
+
* - id: <entry id>
|
|
12
|
+
* name: '<包名 或 内部模块名>'
|
|
13
|
+
*/
|
|
14
|
+
export function analyzeDump(text) {
|
|
15
|
+
const bundles = [];
|
|
16
|
+
const entries = [];
|
|
17
|
+
const idCounts = new Map();
|
|
18
|
+
const nameCounts = new Map();
|
|
19
|
+
let current = null;
|
|
20
|
+
|
|
21
|
+
for (const rawLine of String(text || '').split(/\r?\n/)) {
|
|
22
|
+
const line = rawLine.replace(/\s+$/, '');
|
|
23
|
+
const head = /^# == (.+)$/.exec(line);
|
|
24
|
+
if (head) {
|
|
25
|
+
const first = head[1].split(',')[0].trim();
|
|
26
|
+
if (first) bundles.push(first);
|
|
27
|
+
current = null;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const idLine = /^- id:\s*(.+)$/.exec(line);
|
|
31
|
+
if (idLine) {
|
|
32
|
+
const id = idLine[1].trim();
|
|
33
|
+
current = { id, name: null };
|
|
34
|
+
entries.push(current);
|
|
35
|
+
idCounts.set(id, (idCounts.get(id) || 0) + 1);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const nameLine = /^ {2}name:\s*(.+)$/.exec(line);
|
|
39
|
+
if (nameLine && current && current.name === null) {
|
|
40
|
+
const name = nameLine[1].trim().replace(/^['"]|['"]$/g, '');
|
|
41
|
+
current.name = name;
|
|
42
|
+
nameCounts.set(name, (nameCounts.get(name) || 0) + 1);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
// 别的顶级/子级行不改变"当前 entry",但 `- id:` 之外的新 entry 起点已经处理
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return { bundles, entries, idCounts, nameCounts };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 某个包在组合树里的三条计数判据 */
|
|
52
|
+
export function checkMounted(analysis, pkgName) {
|
|
53
|
+
const bundleLines = analysis.bundles.filter((b) => b === pkgName).length;
|
|
54
|
+
const matching = analysis.entries.filter((e) => e.name === pkgName);
|
|
55
|
+
const ids = matching.map((e) => e.id);
|
|
56
|
+
const idCounts = ids.map((id) => analysis.idCounts.get(id) || 0);
|
|
57
|
+
const problems = [];
|
|
58
|
+
if (bundleLines !== 1) problems.push(`组合头 "# == ${pkgName}" 出现 ${bundleLines} 次(应为 1)(packages: ${analysis.bundles.filter((b)=>b.includes(pkgName)).join(' | ') || '无'})`);
|
|
59
|
+
if (matching.length !== 1) problems.push(`条目 name=${pkgName} 出现 ${matching.length} 次(应为 1)`);
|
|
60
|
+
for (let i = 0; i < ids.length; i += 1) {
|
|
61
|
+
if (idCounts[i] !== 1) problems.push(`条目 id=${ids[i]} 在整棵树里出现 ${idCounts[i]} 次(应为 1)—— 撞 id 时宿主不报错,只静默保留一份`);
|
|
62
|
+
}
|
|
63
|
+
return { ok: problems.length === 0, bundleLines, nameCount: matching.length, ids, idCounts, problems };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** dump 的 stderr 里"某个 bundle 被跳过"的行(能归因时为安装器所用) */
|
|
67
|
+
export function skippedBundlesFromStderr(stderr) {
|
|
68
|
+
const out = [];
|
|
69
|
+
for (const line of String(stderr || '').split(/\r?\n/)) {
|
|
70
|
+
if (/skipping profile bundle|declares no dsh\.bundle/.test(line)) out.push(line.trim());
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|