@dptech-corp/bohr-cli 2.6.91 → 2.6.92

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/CHANGELOG.md CHANGED
@@ -16,6 +16,28 @@
16
16
 
17
17
  ## [Unreleased]
18
18
 
19
+ ## [2.6.92] - 2026-09-10
20
+
21
+ ### Fixed
22
+
23
+ - **安装失败的原因现在真的看得见**:npm 默认把 postinstall 的输出整个收走,此前拒装/失败的理由一个字都到不了用户眼前;改走终端后正常 `npm install` 下就能看到。
24
+ - **两个安装同时跑不再互相踩**:临时文件名此前固定,并发安装会让一个装好的工具被报成失败。
25
+ - **服务端不出这个平台的包时会说清楚**:此前报的是「取哈希失败 HTTP 400」,读起来像服务端故障。
26
+ - **postinstall 下载不再拖垮 `npm install`**:写盘失败、跨协议跳转、重定向环此前会让安装崩掉或卡死,现在都变成一次可见的失败并跳过该工具。
27
+ - **伴生工具的哈希校验改为必须**:发布方给不出可用 SHA256(空响应、缺字段、格式非法)时,此前会静默跳过校验照装 trisol / wenyon-cli,现在改为不装并说清原因。
28
+ - **npmmirror 显式同步真的会发出去了**:发版流水线里那一步此前用的是 `wget --method=PUT`,而镜像内是 BusyBox 的 wget、不认这个选项(v2.6.91 日志逐字为 `wget: unrecognized option: method=PUT`),失败又被 `|| true` 咽掉——「显式同步」看起来一直在做、实际一次都没做成,镜像站只能靠自己的机制慢慢追。现改用 node 发 PUT 并拆成独立 job,失败会显示为流水线 warning 而不是埋在日志里。
29
+
30
+ ### Added
31
+
32
+ - **装完之后会告诉你 `bohr` 是不是刚装的那个**:`npm install -g` 结束时,若 PATH 上解析到的 `bohr` 不是本次安装的那一个(已知两个来源:早期版本留在 `~/.bohrium` 的可执行文件,以及 `scripts/install.sh` 装在 `~/.bohr/bin` 的那一个,两者都会把自己的目录前置进 PATH),会打印一段提示,并按**实际挡路的那个路径**给出改名命令。判不准时保持沉默:Windows 上不判(npm 生成的是 `.cmd` 垫片,比对必然不等)、PATH 上没有 `bohr` 时不判。它不改变安装的退出码。
33
+ - **npm 包 README 补了「升级后 `bohr version` 还是旧版本号」的排障步骤**:含两个来源的对照表,以及一条容易踩的——改名旧二进制之后必须 `hash -r`(bash)或 `rehash`(zsh),否则下一条命令会报 `No such file or directory` 指着刚改名的文件。
34
+
35
+ ### Changed
36
+
37
+ - **拒绝 https→http 的降级跳转**:清单与哈希旁挂文件是整条校验链的信任根,被降级到明文等于校验一起失效。
38
+ - **伴生工具的复用判定只看哈希**:不再先跑一次 `<tool> version`——那等于在校验之前执行一个还没验证的文件;发布方没给版本号时现在也能复用。
39
+ - **`--space` 的弃用告警改写**:直接给出改法(`--space personal` 加 `<path>` 写成 `personal/<path>`),不再只说「将来会移除」。
40
+
19
41
  ## [2.6.91] - 2026-09-09
20
42
 
21
43
  ### Added
package/README.md CHANGED
@@ -99,6 +99,58 @@ npm install -g @dptech-corp/bohr-cli@latest
99
99
  npm install -g @dptech-corp/bohr-cli@latest --registry https://registry.npmjs.org
100
100
  ```
101
101
 
102
+ ### 升级后 `bohr version` 显示的还是旧版本号
103
+
104
+ 多半是早先的安装留下了一个可执行文件,并把它所在的目录前置进了 PATH。这种情况下 npm 装的新版拿不到控制权,敲 `bohr` 跑起来的仍然是旧的那个。`npm install -g` 现在会在装完时直接提示这件事,并且会把**实际挡路的那个路径**打出来。
105
+
106
+ 已知有两个来源,处置只差在第 3 步动哪个文件:
107
+
108
+ | 来源 | 留下的可执行文件 | 写进 PATH 的位置 |
109
+ |-|-|-|
110
+ | 早期版本 | `~/.bohrium/bohr` | rc 文件里的 `~/.bohrium` |
111
+ | `scripts/install.sh` | `~/.bohr/bin/bohr` | rc 文件里的 `export PATH="$HOME/.bohr/bin:$PATH"` |
112
+
113
+ 手动确认与处理如下。
114
+
115
+ 1. 看清实际命中的是哪一个、它是什么版本:
116
+
117
+ ```bash
118
+ command -v bohr
119
+ type -a bohr # 按优先级列出 PATH 上全部同名可执行文件
120
+ bohr version
121
+ ```
122
+
123
+ 2. 找出把那个目录前置进 PATH 的那几行:
124
+
125
+ ```bash
126
+ grep -nE '\.bohrium|\.bohr/bin' ~/.zshrc ~/.bash_profile ~/.bashrc ~/.profile 2>/dev/null
127
+ ```
128
+
129
+ 3. 把第 1 步里 `command -v bohr` 报出来的**那个文件**改名——不是照抄下面的路径,是改你自己那一个:
130
+
131
+ ```bash
132
+ mv "$(command -v bohr)" "$(command -v bohr)-legacy"
133
+ ```
134
+
135
+ 如果它在 `~/.bohrium` 里,**不要删掉 `~/.bohrium` 整个目录**——登录凭据还存在那里,新版是从它迁移过来的。`~/.bohr/bin` 里没有凭据,只有那个可执行文件。
136
+
137
+ 4. 刷新 shell 的命令缓存,或者直接新开一个终端:
138
+
139
+ ```bash
140
+ hash -r # bash
141
+ rehash # zsh
142
+ ```
143
+
144
+ **这一步不能省。** bash 会记住上一次解析到的路径;改完名不刷新,下一条 `bohr` 命令会报
145
+ `bash: /Users/you/.bohrium/bohr: No such file or directory`(或 `~/.bohr/bin` 里的那一个)——指着你刚刚改名的那个文件,
146
+ 看上去像是把环境弄坏了。
147
+
148
+ 5. 复核:
149
+
150
+ ```bash
151
+ command -v bohr && bohr version
152
+ ```
153
+
102
154
  ## License
103
155
 
104
156
  MIT © DP Technology
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dptech-corp/bohr-cli",
3
- "version": "2.6.91",
3
+ "version": "2.6.92",
4
4
  "description": "CLI tool for Bohrium scientific computing platform",
5
5
  "bin": {
6
6
  "bohr": "run.js"
@@ -32,11 +32,11 @@
32
32
  "CHANGELOG.md"
33
33
  ],
34
34
  "optionalDependencies": {
35
- "@dptech-corp/bohr-cli-darwin-arm64": "2.6.91",
36
- "@dptech-corp/bohr-cli-darwin-amd64": "2.6.91",
37
- "@dptech-corp/bohr-cli-linux-amd64": "2.6.91",
38
- "@dptech-corp/bohr-cli-linux-arm64": "2.6.91",
39
- "@dptech-corp/bohr-cli-windows-amd64": "2.6.91",
40
- "@dptech-corp/bohr-cli-windows-arm64": "2.6.91"
35
+ "@dptech-corp/bohr-cli-darwin-arm64": "2.6.92",
36
+ "@dptech-corp/bohr-cli-darwin-amd64": "2.6.92",
37
+ "@dptech-corp/bohr-cli-linux-amd64": "2.6.92",
38
+ "@dptech-corp/bohr-cli-linux-arm64": "2.6.92",
39
+ "@dptech-corp/bohr-cli-windows-amd64": "2.6.92",
40
+ "@dptech-corp/bohr-cli-windows-arm64": "2.6.92"
41
41
  }
42
42
  }
package/postinstall.js CHANGED
@@ -93,62 +93,309 @@ function getPlatform() {
93
93
  return `${mp}-${ma}`;
94
94
  }
95
95
 
96
- function get(url) {
97
- return new Promise((resolve, reject) => {
98
- const mod = url.startsWith("https") ? https : http;
99
- mod.get(url, { headers: { "User-Agent": "bohr-cli-postinstall" } }, (res) => {
100
- if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
101
- return resolve(get(res.headers.location));
102
- }
103
- if (res.statusCode !== 200) {
104
- res.resume();
105
- return reject(new Error(`HTTP ${res.statusCode} from ${url}`));
106
- }
107
- const chunks = [];
108
- res.on("data", (c) => chunks.push(c));
109
- res.on("end", () => resolve(Buffer.concat(chunks)));
110
- res.on("error", reject);
111
- }).on("error", reject);
112
- });
96
+ // 重定向处理是这个文件里踩过三次的地方,三条都以「`npm install -g` 崩掉或者卡死」收场,
97
+ // 而本文件开头写着 postinstall 失败必须是非致命的。三条都实测复现过(2026-09-09):
98
+ //
99
+ // 跳数没有上限 服务端把 302 指回自己 6 秒跳了 148037 次,不会自己停
100
+ // 跨协议跳转 downloadToFile 的 follow() 闭包捕获的是**第一个 URL 的** mod,
101
+ // http.get("https://…") 同步抛 ERR_INVALID_PROTOCOL,抛在响应回调里、
102
+ // 不进 promise → 未捕获异常,退出码 1
103
+ // 写盘失败 WriteStream 'error' 没人听 → 未捕获异常,退出码 1
104
+ //
105
+ // 前两条 get() 与 downloadToFile() 表现还不一样:get() 每跳重算 mod,所以同一次安装里
106
+ // 「取哈希」那步能过、「取二进制」那步崩。两条下载路径对同一件事给出不同答案,本身就是
107
+ // 缺陷——所以下面把跳转决策收敛成一个 nextRedirectURL(),两边共用。
108
+ const MAX_REDIRECTS = 10;
109
+ const GET_TIMEOUT_MS = 30000;
110
+
111
+ function requestModule(url) {
112
+ return url.startsWith("https:") ? https : http;
113
113
  }
114
114
 
115
- function downloadToFile(url, dest, timeoutMs = 300000) {
115
+ function isRedirect(res) {
116
+ return res.statusCode >= 300 && res.statusCode < 400 && res.headers.location;
117
+ }
118
+
119
+ // 一跳的全部决策:解析(Location 允许是相对路径)+ 拒绝降级。
120
+ //
121
+ // **https → http 的跳转一律拒绝。** 哈希必填之后,二进制那条路即使被降级也换不掉内容
122
+ // ——换了就对不上哈希。但清单与 sha256 旁挂文件走的是同一套跳转,它们**就是信任的根**,
123
+ // 没有任何东西再去校验它们;把它们降级到明文,等于让中间人同时给出「期望哈希」和
124
+ // 「匹配那个哈希的负载」,整条校验链一起失效。反向的 http → https 是升级,放行。
125
+ function nextRedirectURL(currentURL, location) {
126
+ const next = new URL(location, currentURL);
127
+ if (currentURL.startsWith("https:") && next.protocol !== "https:") {
128
+ throw new Error(`refusing to follow redirect from https to ${next.protocol}// (${next.host})`);
129
+ }
130
+ return next.toString();
131
+ }
132
+
133
+ function get(url, options) {
134
+ const opts = options || {};
135
+ const redirectsLeft = opts.redirectsLeft === undefined ? MAX_REDIRECTS : opts.redirectsLeft;
136
+ const timeoutMs = opts.timeoutMs === undefined ? GET_TIMEOUT_MS : opts.timeoutMs;
116
137
  return new Promise((resolve, reject) => {
117
- const mod = url.startsWith("https") ? https : http;
118
- const follow = (u) => {
119
- const req = mod.get(u, { headers: { "User-Agent": "bohr-cli-postinstall" }, timeout: timeoutMs }, (res) => {
120
- if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
138
+ const req = requestModule(url).get(
139
+ url,
140
+ { headers: { "User-Agent": "bohr-cli-postinstall" }, timeout: timeoutMs },
141
+ (res) => {
142
+ if (isRedirect(res)) {
121
143
  res.resume();
122
- return follow(res.headers.location);
144
+ if (redirectsLeft <= 0) {
145
+ return reject(new Error(`too many redirects (>${MAX_REDIRECTS}) starting at ${url}`));
146
+ }
147
+ let next;
148
+ try {
149
+ next = nextRedirectURL(url, res.headers.location);
150
+ } catch (e) {
151
+ return reject(e);
152
+ }
153
+ return resolve(get(next, { redirectsLeft: redirectsLeft - 1, timeoutMs }));
123
154
  }
124
155
  if (res.statusCode !== 200) {
125
156
  res.resume();
126
- return reject(new Error(`HTTP ${res.statusCode}`));
157
+ return reject(new Error(`HTTP ${res.statusCode} from ${url}`));
127
158
  }
128
- const file = fs.createWriteStream(dest);
129
- const hash = crypto.createHash("sha256");
130
- res.on("data", (chunk) => {
131
- file.write(chunk);
132
- hash.update(chunk);
133
- });
134
- res.on("end", () => {
135
- file.end(() => resolve(hash.digest("hex")));
136
- });
137
- res.on("error", (e) => { file.close(); reject(e); });
159
+ const chunks = [];
160
+ res.on("data", (c) => chunks.push(c));
161
+ res.on("end", () => resolve(Buffer.concat(chunks)));
162
+ res.on("error", reject);
163
+ }
164
+ );
165
+ req.on("error", reject);
166
+ // 原先 get() 完全没有超时:服务端接了连接不回话,postinstall 就一直等下去。
167
+ //
168
+ // 文案写「配置的上限是多少」,不写「实际等了多久」——'timeout' 事件不告诉你是哪一个
169
+ // 超时开的枪。不设这个选项时 Node 自己也会在 5 秒上触发同一个 handler,写成
170
+ // 「after 200ms」就是在谎报一个没量过的数(实测:不设选项时 5003ms 才回,文案照样
171
+ // 说 200ms)。
172
+ req.on("timeout", () => {
173
+ req.destroy();
174
+ reject(new Error(`request to ${url} timed out (limit ${timeoutMs}ms)`));
175
+ });
176
+ });
177
+ }
178
+
179
+ function downloadToFile(url, dest, timeoutMs = 300000) {
180
+ return new Promise((resolve, reject) => {
181
+ // 下面几路(请求出错、超时、写盘出错、正常结束)都可能先后到达,只认第一个。
182
+ let settled = false;
183
+ const fail = (e) => {
184
+ if (settled) return;
185
+ settled = true;
186
+ reject(e);
187
+ };
188
+ const done = (value) => {
189
+ if (settled) return;
190
+ settled = true;
191
+ resolve(value);
192
+ };
193
+
194
+ const follow = (u, redirectsLeft) => {
195
+ // mod 每跳重算。原先它是在函数外按第一个 URL 算一次的闭包变量。
196
+ const req = requestModule(u).get(
197
+ u,
198
+ { headers: { "User-Agent": "bohr-cli-postinstall" }, timeout: timeoutMs },
199
+ (res) => {
200
+ if (isRedirect(res)) {
201
+ res.resume();
202
+ if (redirectsLeft <= 0) {
203
+ return fail(new Error(`too many redirects (>${MAX_REDIRECTS}) starting at ${url}`));
204
+ }
205
+ let next;
206
+ try {
207
+ next = nextRedirectURL(u, res.headers.location);
208
+ } catch (e) {
209
+ return fail(e);
210
+ }
211
+ return follow(next, redirectsLeft - 1);
212
+ }
213
+ if (res.statusCode !== 200) {
214
+ res.resume();
215
+ return fail(new Error(`HTTP ${res.statusCode}`));
216
+ }
217
+ const file = fs.createWriteStream(dest);
218
+ // 这里原先顺手把网络流的 sha256 也算了、并把它当作校验依据返回。现在校验改成
219
+ // 读落盘的文件(见 installManagedTool),这一份就没有用处了——留着只会让人以为
220
+ // 「下载函数已经验过了」。下载就只管下载。
221
+ //
222
+ // 磁盘满、EIO、这个名字已经被别的东西占住——原先都没人接,Node 于是把
223
+ // WriteStream 的 'error' 变成未捕获异常,整个 postinstall 以退出码 1 结束。
224
+ file.on("error", (e) => {
225
+ res.destroy();
226
+ fail(e);
227
+ });
228
+ res.on("error", (e) => {
229
+ file.destroy();
230
+ fail(e);
231
+ });
232
+ res.on("data", (chunk) => file.write(chunk));
233
+ res.on("end", () => {
234
+ // end 的回调带 error 参数:写盘失败时它先于(或代替)'error' 事件到达。
235
+ // 忽略它的后果实测过:EISDIR 之下照样报 installed,那个目录被 rename 成了
236
+ // 目标路径。
237
+ file.end((err) => {
238
+ if (err) return fail(err);
239
+ done();
240
+ });
241
+ });
242
+ }
243
+ );
244
+ req.on("error", fail);
245
+ req.on("timeout", () => {
246
+ req.destroy();
247
+ fail(new Error("download timed out"));
138
248
  });
139
- req.on("error", reject);
140
- req.on("timeout", () => { req.destroy(); reject(new Error("download timed out")); });
141
249
  };
142
- follow(url);
250
+ follow(url, MAX_REDIRECTS);
143
251
  });
144
252
  }
145
253
 
146
- // installTrisol downloads the trisol external edition (variant=bohrium) via
147
- // the trisol manifest protocol: fetch manifest → pick channel → download
148
- // binary + sha256 sidecar → verify → install to ~/.bohr/tools/trisol/current.
149
- async function installTrisol() {
254
+ // 「按发布方给的哈希装一个受管伴生 CLI」的完整流程,trisol wenyon 共用一份。
255
+ //
256
+ // 抽出来之前这段算法在本文件里抄了两遍,而且已经漂开了——复用判据与校验判据各写各的:
257
+ //
258
+ // 复用 trisol: out.includes(version) && localHash === expectedHash
259
+ // wenyon: out.includes(meta.version) && (!meta.sha256 || localHash === meta.sha256)
260
+ // 校验 trisol: if (expectedHash && actualHash !== expectedHash)
261
+ // wenyon: if (meta.sha256 && actualHash !== meta.sha256)
262
+ //
263
+ // 两处写法不同,说的是同一件事:**哈希缺席就放行**。这条从今天起不成立了。
264
+ //
265
+ // 哈希是必填的,拿不到就不装。原先那个写法是可以走通的:sha256 端点回一个空的 200,
266
+ // `"".trim().split(/\s+/)[0]` 得到空串,`if (expectedHash && …)` 于是整条校验被跳过,
267
+ // 然后照样 chmod 755 装上去——而那一行日志跟校验通过的那次一字不差。这不是读码推出来的,
268
+ // 是拿真实 postinstall.js 对着本地 http 服务跑出来的(负对照:哈希不符时会正确拦下)。
269
+ //
270
+ // 代价认下来:发布方哪天不给哈希了,伴生 CLI 就装不上。postinstall 失败本来就是非致命的
271
+ // (bohr-cli 不依赖它们,`bohr extension install <tool>` 可以重试),而静默装一个没校验过的
272
+ // 可执行文件,代价没有上限。
273
+ //
274
+ // 形状也要判,不只是判空:一页 HTML 错误正文、一句 "Not Found"、一个被截断的哈希,都不是
275
+ // 哈希,判空只挡得住其中一种。
276
+ const SHA256_HEX = /^[0-9a-f]{64}$/i;
277
+
278
+ // 拒绝的时候要说清「发布方给的到底是什么」,否则这行日志跟网络故障分不开。截断是必要的:
279
+ // 这个位置最可能拿到的就是一整页 HTML。
280
+ function describeHash(raw) {
281
+ const text = String(raw == null ? "" : raw).trim();
282
+ if (!text) return "empty response";
283
+ return text.length > 32 ? `${JSON.stringify(text.slice(0, 32))}…` : JSON.stringify(text);
284
+ }
285
+
286
+ // 返回值说明这一趟走了哪条路。「装上了」「复用了」「拒了」在副作用上要费劲才分得开,
287
+ // 在返回值上一目了然——测试断言的是这个,不是日志文案。
288
+ // notify 是「这条话必须让用户看见」的出口,默认走 emitToUser(写 /dev/tty)。
289
+ //
290
+ // npm **默认把生命周期脚本的 stdout/stderr 整个收走**,只在脚本非零退出时才回放——而
291
+ // postinstall 的失败是刻意做成非致命的,于是 console.error 打出来的原因一个字都到不了
292
+ // 用户眼前。实测(npm 10.9.4,同一个 postinstall):`npm install` 一行都不显示,
293
+ // 加 --foreground-scripts 才显示。
294
+ //
295
+ // 也就是说,「拿不到哈希就出声拒绝」这件事里的**出声**,此前是不成立的。文件末尾的影子
296
+ // 告警早就绕开了这层收集,安装这条路当时没跟上。
297
+ //
298
+ // 日常噪音(Installing… / already installed)不走 notify:它们没有「用户需要据此做点
299
+ // 什么」的性质,往终端插话只是干扰。判据就是这一条——**这句话是否要求用户做点什么**。
300
+ //
301
+ // 可注入只为一件事:emitToUser 在有 tty 时写终端、没有时退回 stderr,两条路在用例里都
302
+ // 断言不到;notify 收敛成参数之后,用例拿一个收集器就能判「哪几句进了这个出口」。
303
+ async function installManagedTool({
304
+ name,
305
+ label,
306
+ root,
307
+ installDir,
308
+ executable,
309
+ version,
310
+ url,
311
+ expectedHash,
312
+ detail,
313
+ notify = emitToUser,
314
+ }) {
315
+ const wanted = String(expectedHash == null ? "" : expectedHash).trim().toLowerCase();
316
+ if (!SHA256_HEX.test(wanted)) {
317
+ notify(
318
+ `[postinstall] ${label}: publisher gave no usable SHA256 (${describeHash(expectedHash)}), refusing to install`
319
+ );
320
+ return "rejected-hash";
321
+ }
322
+
323
+ const destPath = path.join(installDir, executable);
324
+
325
+ // 已装的那个,**只按哈希判**。哈希取自发布方,不取自本地文件——本地文件不能自证。
326
+ // 顺带把老版本 postinstall 漏写的 manifest 补上。
327
+ //
328
+ // 这里原先还先跑一次 `<tool> version` 比对版本号。哈希改成必填之后那一步就是冗余的:
329
+ // localHash === wanted 已经意味着这个文件逐字就是发布方那一版的产物,版本号不可能不对
330
+ // (expectedHash 本来就是按 version + 平台取的)。而它的代价是**在校验之前先执行了这个
331
+ // 文件**——本仓「已装的内容不是发布方那一份」那条用例跑的时候,被改过的文件真的被执行过
332
+ // 一次,然后才因为哈希不符被拒。
333
+ //
334
+ // 去掉它还顺带解开一个约束:原先复用要求 version 非空,而 wenyon 的 meta.version 可能缺,
335
+ // 于是那种情况下每次安装都要重下一遍。
336
+ if (fs.existsSync(destPath)) {
337
+ try {
338
+ const localHash = (await hashFile(destPath)).toLowerCase();
339
+ if (localHash === wanted) {
340
+ writeManagedManifest(root, {
341
+ name,
342
+ version,
343
+ executable,
344
+ sha256: localHash,
345
+ pinnedHash: readPinnedHash(root),
346
+ });
347
+ console.log(`[postinstall] ${label} ${version || ""} already installed`);
348
+ return "reused";
349
+ }
350
+ } catch (_) {
351
+ // 读不出哈希(文件损坏、权限不够)只意味着「没装好」,往下重装
352
+ }
353
+ }
354
+
355
+ console.log(`[postinstall] Installing ${label} ${version || ""} (${detail})...`);
356
+ // 临时文件名带上进程号与随机数。原先是固定的 `<dest>.tmp`,两个并发的 postinstall
357
+ // (两个终端各跑一次 npm install -g,或者 monorepo 同时装)抢同一个名字:实测一个
358
+ // installed、另一个报 `download failed (ENOENT … chmod trisol.tmp)`——一个装得好好的
359
+ // 工具被报成失败,而那句错误里没有一个字提到并发。
360
+ //
361
+ // 同一个文件里的 writeManagedManifest 早就用了 `${manifestPath}.${process.pid}.tmp`,
362
+ // 这个教训学过一次,二进制这条路当时没跟上。
363
+ const tmpPath = `${destPath}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`;
364
+ try {
365
+ // mkdir 放在 try 里面。它原先在外面,于是安装目录建不出来时(父路径是个文件、
366
+ // 权限不够)异常直接抛出这个函数——调用方拿到的是一个 rejected promise,而不是
367
+ // 跟其他失败一样的一个状态字符串。
368
+ fs.mkdirSync(installDir, { recursive: true });
369
+ await downloadToFile(url, tmpPath);
370
+ // 哈希算的是**落到盘上的那个文件**,不是收到的网络流。两者在正常情况下相同,但
371
+ // 「校验通过」这句话的意思应该是「待会儿要 chmod +x 并搬到 PATH 上的那个东西是对的」
372
+ // ——那就得读它。算网络流的话,任何在流与盘之间发生的事(并发写、部分写、坏块)都
373
+ // 恰好落在校验的视野之外。多读一遍 11–14 MB,相对一次下载可以忽略。
374
+ const actualHash = (await hashFile(tmpPath)).toLowerCase();
375
+ if (actualHash !== wanted) {
376
+ fs.unlinkSync(tmpPath);
377
+ notify(
378
+ `[postinstall] ${label}: SHA256 mismatch (expected ${wanted}, got ${actualHash}), skipping`
379
+ );
380
+ return "mismatch";
381
+ }
382
+ fs.chmodSync(tmpPath, 0o755);
383
+ fs.renameSync(tmpPath, destPath);
384
+ writeManagedManifest(root, { name, version, executable, sha256: actualHash });
385
+ console.log(`[postinstall] ${label} ${version || ""} installed to ${destPath}`);
386
+ return "installed";
387
+ } catch (e) {
388
+ try { fs.unlinkSync(tmpPath); } catch (_) {}
389
+ notify(`[postinstall] ${label}: download failed (${e.message}), skipping`);
390
+ return "failed";
391
+ }
392
+ }
393
+
394
+ // installTrisol 走 trisol 的清单协议:取清单 → 选通道 → 取二进制与 sha256 旁挂文件 →
395
+ // 交给 installManagedTool 校验并装到 ~/.bohr/tools/trisol/current。
396
+ async function installTrisol({ notify = emitToUser } = {}) {
150
397
  const platform = getPlatform();
151
- if (!platform) return;
398
+ if (!platform) return "unsupported-platform";
152
399
  const [osName, archName] = platform.split("-");
153
400
 
154
401
  let manifest;
@@ -156,162 +403,277 @@ async function installTrisol() {
156
403
  const buf = await get(`${TRISOL_SERVER}/api/v1/cli/manifest`);
157
404
  manifest = JSON.parse(buf.toString());
158
405
  } catch (e) {
159
- console.error(`[postinstall] trisol: could not fetch manifest (${e.message}), skipping`);
160
- return;
406
+ notify(`[postinstall] trisol: could not fetch manifest (${e.message}), skipping`);
407
+ return "no-manifest";
408
+ }
409
+
410
+ // 清单自己就写着服务端出哪些平台的包。不看它的后果实测过(2026-09-09):清单里
411
+ // platforms 不含 windows/arm64,而 bohr-cli 自己是发 windows-arm64 的;那台机器上
412
+ // 两个下载 URL 都回 HTTP 400,于是用户看到的是「could not fetch sha256 (HTTP 400)」
413
+ // ——读起来像服务端坏了,而实际是这个平台根本没有包。判断依据在清单里现成的。
414
+ //
415
+ // 老服务端不带 platforms 时 supported 为空,此时不判——用一个查不到的事实去拒绝安装,
416
+ // 比不判更糟。
417
+ const supported = (manifest.download && manifest.download.platforms) || [];
418
+ if (supported.length && !supported.includes(`${osName}/${archName}`)) {
419
+ notify(
420
+ `[postinstall] trisol: no build for ${osName}/${archName} (server offers ${supported.join(", ")}), skipping`
421
+ );
422
+ return "platform-not-offered";
161
423
  }
424
+
162
425
  const channel = process.env.BOHR_TRISOL_CHANNEL || manifest.default_channel || "stable";
163
426
  const ch = (manifest.channels || {})[channel];
164
427
  const version = ch && ch.version;
165
428
  if (!version) {
166
- console.error(`[postinstall] trisol: channel "${channel}" has no version, skipping`);
167
- return;
429
+ notify(`[postinstall] trisol: channel "${channel}" has no version, skipping`);
430
+ return "no-version";
168
431
  }
169
432
 
170
433
  const base = `${TRISOL_SERVER}/api/v1/cli/download/${version}/${osName}/${archName}`;
171
- const binaryURL = `${base}?variant=bohrium`;
172
- const shaURL = `${base}.sha256?variant=bohrium`;
173
434
  const ext = osName === "windows" ? ".exe" : "";
174
- const destPath = path.join(TRISOL_INSTALL_DIR, `trisol${ext}`);
175
435
 
176
436
  let expectedHash;
177
437
  try {
178
- const buf = await get(shaURL);
438
+ const buf = await get(`${base}.sha256?variant=bohrium`);
179
439
  expectedHash = buf.toString().trim().split(/\s+/)[0];
180
440
  } catch (e) {
181
- console.error(`[postinstall] trisol: could not fetch sha256 (${e.message}), skipping`);
182
- return;
183
- }
184
-
185
- // Retain an existing binary only when both its reported version and its
186
- // publisher-provided hash match. This also repairs metadata omitted by older
187
- // postinstall versions without treating the local file as its own authority.
188
- if (fs.existsSync(destPath)) {
189
- try {
190
- const { execFileSync } = require("child_process");
191
- const out = execFileSync(destPath, ["version"], { encoding: "utf8", timeout: 5000 });
192
- const localHash = await hashFile(destPath);
193
- if (out.includes(version) && localHash === expectedHash) {
194
- writeManagedManifest(TRISOL_ROOT, {
195
- name: "trisol",
196
- version,
197
- executable: `trisol${ext}`,
198
- sha256: localHash,
199
- pinnedHash: readPinnedHash(TRISOL_ROOT),
200
- });
201
- console.log(`[postinstall] trisol ${version} already installed`);
202
- return;
203
- }
204
- } catch (_) {
205
- // version/hash check failed, proceed with install
206
- }
441
+ notify(`[postinstall] trisol: could not fetch sha256 (${e.message}), skipping`);
442
+ return "no-hash";
207
443
  }
208
444
 
209
- console.log(`[postinstall] Installing trisol ${version} (${platform}, variant=bohrium)...`);
210
- fs.mkdirSync(TRISOL_INSTALL_DIR, { recursive: true });
211
- const tmpPath = destPath + ".tmp";
212
- try {
213
- const actualHash = await downloadToFile(binaryURL, tmpPath);
214
- if (expectedHash && actualHash !== expectedHash) {
215
- fs.unlinkSync(tmpPath);
216
- console.error(`[postinstall] trisol: SHA256 mismatch (expected ${expectedHash}, got ${actualHash}), skipping`);
217
- return;
218
- }
219
- fs.chmodSync(tmpPath, 0o755);
220
- fs.renameSync(tmpPath, destPath);
221
- writeManagedManifest(TRISOL_ROOT, {
222
- name: "trisol",
223
- version,
224
- executable: `trisol${ext}`,
225
- sha256: actualHash,
226
- });
227
- console.log(`[postinstall] trisol ${version} installed to ${destPath}`);
228
- } catch (e) {
229
- try { fs.unlinkSync(tmpPath); } catch (_) {}
230
- console.error(`[postinstall] trisol: download failed (${e.message}), skipping`);
231
- }
445
+ return installManagedTool({
446
+ name: "trisol",
447
+ label: "trisol",
448
+ root: TRISOL_ROOT,
449
+ installDir: TRISOL_INSTALL_DIR,
450
+ executable: `trisol${ext}`,
451
+ version,
452
+ url: `${base}?variant=bohrium`,
453
+ expectedHash,
454
+ detail: `${platform}, variant=bohrium`,
455
+ notify,
456
+ });
232
457
  }
233
458
 
234
- async function main() {
235
- const platform = getPlatform();
236
- if (platform) {
237
- await installTrisol();
459
+ // platform 可注入只为一件事:下面那条「平台不认识就整个不做」的分支,在任何一台跑得起
460
+ // 这份测试的机器上都走不到(darwin/linux + amd64/arm64 全都认得),没有注入口就只能靠
461
+ // 肉眼。本文件里 emitToUser 的 ttyPath、detectShadowedBohr 的 opts 已经是这个办法。
462
+ async function main({ platform = getPlatform(), notify = emitToUser } = {}) {
463
+ // 原先这里只是「不装 trisol」,然后照样带着 platform=null 去问 wenyon 的发布接口——
464
+ // 一个必然问不出东西的请求,而且报出来的错长得像服务端故障。
465
+ if (!platform) {
466
+ notify(
467
+ `[postinstall] unsupported platform ${os.platform()}-${os.arch()}, skipping companion tools`
468
+ );
469
+ return "unsupported-platform";
238
470
  }
239
471
 
240
- const metaUrl = `${WENYON_SERVER}/cli/releases/latest/download?platform=${platform}&variant=external`;
472
+ await installTrisol({ notify });
241
473
 
242
474
  let meta;
243
475
  try {
244
- const buf = await get(metaUrl);
476
+ const buf = await get(
477
+ `${WENYON_SERVER}/cli/releases/latest/download?platform=${platform}&variant=external`
478
+ );
245
479
  meta = JSON.parse(buf.toString());
246
480
  } catch (e) {
247
- console.error(`[postinstall] wenyon-cli: could not fetch release info (${e.message}), skipping`);
248
- return;
481
+ notify(`[postinstall] wenyon-cli: could not fetch release info (${e.message}), skipping`);
482
+ return "no-release-info";
249
483
  }
250
484
 
251
485
  if (!meta.url) {
252
- console.error("[postinstall] wenyon-cli: no download URL in release response, skipping");
253
- return;
486
+ notify("[postinstall] wenyon-cli: no download URL in release response, skipping");
487
+ return "no-download-url";
254
488
  }
255
489
 
256
- // Check if already installed and up-to-date
257
490
  const ext = platform.startsWith("windows") ? ".exe" : "";
258
- const destPath = path.join(INSTALL_DIR, `wenyon-cli${ext}`);
491
+ return installManagedTool({
492
+ name: "wenyon",
493
+ label: "wenyon-cli",
494
+ root: WENYON_ROOT,
495
+ installDir: INSTALL_DIR,
496
+ executable: `wenyon-cli${ext}`,
497
+ version: meta.version,
498
+ url: meta.url,
499
+ expectedHash: meta.sha256,
500
+ detail: platform,
501
+ notify,
502
+ });
503
+ }
504
+
505
+ // ---------------------------------------------------------------------------
506
+ // 装完之后,PATH 上的 `bohr` 是不是刚装的这一个。
507
+ //
508
+ // 这个检查只能长在 postinstall 里。被旧二进制遮蔽时,新 CLI 里写什么检查都不会执行
509
+ // ——用户敲 bohr 跑起来的是旧的那个。npm 的生命周期脚本是安装那一刻唯一还能说话的地方。
510
+ //
511
+ // 实测过两台机器上的形态(登记行 recvqLGpdH3RXI):早期版本装在 ~/.bohrium 并把该目录
512
+ // 前置进 PATH,之后 npm 装的新版永远拿不到控制权。官网安装页已经在教人装完跑
513
+ // `bohr version`,但从没说过"你应该看到刚装的那个版本号",于是看到 1.1.0 的人不知道那是错的。
514
+ function realpathOrNull(p) {
515
+ try {
516
+ return fs.realpathSync(p);
517
+ } catch (_) {
518
+ return null;
519
+ }
520
+ }
259
521
 
260
- if (fs.existsSync(destPath) && meta.version) {
522
+ // PATH 上第一个可执行的同名文件,就是 shell 会挑中的那个。
523
+ function resolveOnPath(name, env) {
524
+ const raw = (env && env.PATH) || "";
525
+ if (!raw) return null;
526
+ for (const dir of raw.split(path.delimiter)) {
527
+ if (!dir) continue;
528
+ const candidate = path.join(dir, name);
261
529
  try {
262
- const { execFileSync } = require("child_process");
263
- const out = execFileSync(destPath, ["version"], { encoding: "utf8", timeout: 5000 });
264
- const localHash = await hashFile(destPath);
265
- if (out.includes(meta.version) && (!meta.sha256 || localHash === meta.sha256)) {
266
- writeManagedManifest(WENYON_ROOT, {
267
- name: "wenyon",
268
- version: meta.version,
269
- executable: `wenyon-cli${ext}`,
270
- sha256: localHash,
271
- pinnedHash: readPinnedHash(WENYON_ROOT),
272
- });
273
- console.log(`[postinstall] wenyon-cli ${meta.version} already installed`);
274
- return;
275
- }
530
+ if (!fs.statSync(candidate).isFile()) continue;
531
+ fs.accessSync(candidate, fs.constants.X_OK);
532
+ return candidate;
276
533
  } catch (_) {
277
- // version check failed, proceed with install
534
+ // 不可读/不可执行/不存在都只意味着继续往后找,不是错误
278
535
  }
279
536
  }
537
+ return null;
538
+ }
280
539
 
281
- console.log(`[postinstall] Installing wenyon-cli ${meta.version || ""} (${platform})...`);
540
+ // 判不出来就返回 null(沉默),只有**确定**不是我们才报。
541
+ // 全局安装目录的形态在 nvm / pnpm / Volta 下差别很大,误报一次的代价比漏报大得多:
542
+ // 它会让每个装得好好的人都看见一段"你装错了",几轮之后就没人读这段字了。
543
+ function detectShadowedBohr(opts) {
544
+ const o = opts || {};
545
+ const platform = o.platform || process.platform;
546
+ // Windows 上 npm 生成的是 bohr.cmd / bohr.ps1 垫片,不是指向 run.js 的符号链接,
547
+ // realpath 解出来必然不等于 run.js —— 判据在这个平台上会 100% 误报,所以不判。
548
+ if (platform === "win32") return null;
282
549
 
283
- fs.mkdirSync(INSTALL_DIR, { recursive: true });
550
+ const env = o.env || process.env;
551
+ const packageRoot = o.packageRoot || __dirname;
284
552
 
285
- const tmpPath = destPath + ".tmp";
286
- try {
287
- const actualHash = await downloadToFile(meta.url, tmpPath);
553
+ const ours = realpathOrNull(path.join(packageRoot, "run.js"));
554
+ if (!ours) return null;
288
555
 
289
- if (meta.sha256 && actualHash !== meta.sha256) {
290
- fs.unlinkSync(tmpPath);
291
- console.error(`[postinstall] wenyon-cli: SHA256 mismatch (expected ${meta.sha256}, got ${actualHash}), skipping`);
292
- return;
293
- }
556
+ const found = resolveOnPath("bohr", env);
557
+ // PATH 上还没有 bohr:可能 bin 还没链好,也可能 npm 全局目录不在 PATH。
558
+ // 后者官网安装页已经讲了,这里不重复说。
559
+ if (!found) return null;
294
560
 
295
- fs.chmodSync(tmpPath, 0o755);
296
- fs.renameSync(tmpPath, destPath);
297
- writeManagedManifest(WENYON_ROOT, {
298
- name: "wenyon",
299
- version: meta.version,
300
- executable: `wenyon-cli${ext}`,
301
- sha256: actualHash,
302
- });
303
- console.log(`[postinstall] wenyon-cli ${meta.version || ""} installed to ${destPath}`);
304
- } catch (e) {
305
- try { fs.unlinkSync(tmpPath); } catch (_) {}
306
- console.error(`[postinstall] wenyon-cli: download failed (${e.message}), skipping`);
561
+ const foundReal = realpathOrNull(found);
562
+ if (!foundReal) return null;
563
+ if (foundReal === ours) return null;
564
+
565
+ return { found, foundReal, ours };
566
+ }
567
+
568
+ // 遮蔽源已知有两个,都是本仓自己造出来的:
569
+ //
570
+ // ~/.bohrium/bohr 早期版本的安装位置(登记行 recvqLGpdH3RXI 实测两台机器)
571
+ // ~/.bohr/bin/bohr scripts/install.sh 装的位置,它还往 rc 文件写
572
+ // `export PATH="$HOME/.bohr/bin:$PATH"`——前置,所以必赢
573
+ //
574
+ // 所以补救命令**必须从实际挡路的那个路径生成**,不能写死其中一个。写死 ~/.bohrium 的
575
+ // 后果不是少说了一句:被 ~/.bohr/bin 挡住的人照着敲会得到 "No such file or directory",
576
+ // 而上面三行刚刚把真实路径打给他看过——话术自相矛盾比不给话术更糟。
577
+ //
578
+ // 要改名的是 **PATH 上那一个**(found),不是它指向的目标(foundReal):found 是符号
579
+ // 链接时,改名目标会留下一条断链,而 PATH 解析仍然命中那个链接名。
580
+ function shadowRemedyLines(detected, homeDir) {
581
+ const home = homeDir || os.homedir();
582
+ const legacyRoot = path.join(home, ".bohrium");
583
+ const underLegacyRoot = (p) =>
584
+ typeof p === "string" && (p === legacyRoot || p.startsWith(legacyRoot + path.sep));
585
+
586
+ const lines = [`[postinstall] mv ${detected.found} ${detected.found}-legacy`];
587
+ // 「别删 ~/.bohrium」这句只在真的动到 ~/.bohrium 时才说。凭据在那儿,别处没有;
588
+ // 对着一个 ~/.bohr/bin 的遮蔽讲 ~/.bohrium 的凭据,只会让人以为自己找错了东西。
589
+ if (underLegacyRoot(detected.found) || underLegacyRoot(detected.foundReal)) {
590
+ return [
591
+ "[postinstall] 2. rename the shadowing executable — do NOT delete ~/.bohrium,",
592
+ "[postinstall] your saved credentials still live there and are migrated from it:",
593
+ ].concat(lines);
594
+ }
595
+ return ["[postinstall] 2. rename the shadowing executable:"].concat(lines);
596
+ }
597
+
598
+ function formatShadowWarning(detected, homeDir) {
599
+ if (!detected) return null;
600
+ const lines = [
601
+ "",
602
+ "[postinstall] Installed, but `bohr` on your PATH is NOT the one just installed.",
603
+ `[postinstall] PATH resolves to: ${detected.found}`,
604
+ ];
605
+ if (detected.foundReal !== detected.found) {
606
+ lines.push(`[postinstall] which points at: ${detected.foundReal}`);
607
+ }
608
+ lines.push(
609
+ `[postinstall] just installed: ${detected.ours}`,
610
+ "[postinstall] Usually an earlier install left a `bohr` in ~/.bohrium or ~/.bohr/bin",
611
+ "[postinstall] and put that directory first on PATH.",
612
+ "[postinstall] To fix:",
613
+ "[postinstall] 1. see which one wins: command -v bohr && bohr version"
614
+ );
615
+ lines.push.apply(lines, shadowRemedyLines(detected, homeDir));
616
+ lines.push(
617
+ "[postinstall] 3. refresh the shell command cache: `hash -r` (bash) or `rehash` (zsh),",
618
+ "[postinstall] or just open a new terminal. Without this bash keeps the old path",
619
+ "[postinstall] cached and the next `bohr` reports 'No such file or directory'.",
620
+ "[postinstall] 4. check: bohr version",
621
+ ""
622
+ );
623
+ return lines.join("\n");
624
+ }
625
+
626
+ // npm **默认把生命周期脚本的 stdout/stderr 收走**,只在脚本非零退出时才回放——而这条
627
+ // 提示恰恰必须在安装成功那一次出现。实测(npm 10.9.4,`npm install -g`):不加
628
+ // --foreground-scripts 时 console.error 的内容一个字都不显示,连现有那几行
629
+ // "[postinstall] trisol … installed" 也一样,一直在打给空气。
630
+ //
631
+ // 所以直接写 /dev/tty:它是用户的终端本身,绕过 npm 这层收集。没有 tty 的场合
632
+ // (CI、容器、输出被管道接走)写入会抛,退回 stderr——那些场合本来也没人在看。
633
+ //
634
+ // ttyPath 可注入只为一件事:**回退分支是 CI、容器、输出被管道接走时唯一会走的那条**,
635
+ // 也就是 postinstall 无人值守跑的绝大多数场合。写死 "/dev/tty" 的话这条分支没有任何
636
+ // 办法被测到,只能靠肉眼看一次安装输出。返回值说明走了哪条路,供测试断言。
637
+ function emitToUser(text, ttyPath) {
638
+ try {
639
+ fs.writeFileSync(ttyPath || "/dev/tty", text + "\n");
640
+ return true;
641
+ } catch (_) {
642
+ console.error(text);
643
+ return false;
307
644
  }
308
645
  }
309
646
 
310
647
  if (require.main === module) {
311
- main().catch((e) => {
312
- // postinstall failures must not break npm install
313
- console.error(`[postinstall] wenyon-cli: ${e.message}`);
314
- });
648
+ main()
649
+ .catch((e) => {
650
+ // postinstall failures must not break npm install。同样走 emitToUser:
651
+ // 这是逃出 main() 的异常,属于「用户该知道有东西没装上」那一类。
652
+ emitToUser(`[postinstall] companion tools: ${e.message}`);
653
+ })
654
+ .finally(() => {
655
+ // 这段无论上面成没成都要跑:遮蔽与扩展装没装上无关。
656
+ // 整个包在 try 里,因为 postinstall 抛异常等于 `npm install -g` 装不上,
657
+ // 而这个提示的价值远够不上承担那个后果。
658
+ try {
659
+ const text = formatShadowWarning(detectShadowedBohr());
660
+ if (text) emitToUser(text);
661
+ } catch (_) {
662
+ /* 提示不出来就算了,绝不影响安装 */
663
+ }
664
+ });
315
665
  }
316
666
 
317
- module.exports = { readPinnedHash, writeManagedManifest };
667
+ module.exports = {
668
+ readPinnedHash,
669
+ writeManagedManifest,
670
+ installManagedTool,
671
+ installTrisol,
672
+ main,
673
+ get,
674
+ nextRedirectURL,
675
+ resolveOnPath,
676
+ detectShadowedBohr,
677
+ formatShadowWarning,
678
+ emitToUser,
679
+ };