dsh-pocket 2.5.1 → 2.6.1

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/README.en.md CHANGED
@@ -194,7 +194,7 @@ Such tools take over all traffic and often cut cloudflared's tunnel-edge connect
194
194
  ```sh
195
195
  npm install
196
196
  node client/build.mjs # rebuild after editing client/
197
- npm test # proxy / auth / compression / tunnel / service / RPC / settings (105 tests)
197
+ npm test # proxy / auth / compression / tunnel / service / RPC / settings (108 tests)
198
198
  ```
199
199
 
200
200
  **Want to try your changes locally without publishing?** Point the installed plugin at your local checkout with a symlink and restart dsh web. Full steps (including switching back to the npm release) are in [LOCAL-DEV.md](./LOCAL-DEV.md).
package/README.md CHANGED
@@ -195,7 +195,7 @@ npx @deepseek-ai/dsh web
195
195
  ```sh
196
196
  npm install
197
197
  node client/build.mjs # 改 client/ 后重新打包
198
- npm test # 代理 / 认证 / 压缩 / 隧道 / 服务 / RPC / 设置(105 测试)
198
+ npm test # 代理 / 认证 / 压缩 / 隧道 / 服务 / RPC / 设置(108 测试)
199
199
  ```
200
200
 
201
201
  **改完想在本机先试?** 不用发版:把插件换成指向本地仓库的软链,重启 dsh web 就是本地代码。完整步骤(含怎么换回 npm 官方版本)见 [LOCAL-DEV.md](./LOCAL-DEV.md)。
package/client/client.js CHANGED
@@ -1214,6 +1214,51 @@ var MOBILE_CSS = `
1214
1214
  [data-mobile-nav="drawer-actions"] {
1215
1215
  display: none !important;
1216
1216
  }
1217
+ /* \u684C\u9762\u7AEF\u6C38\u8FDC\u4E0D\u9700\u8981\u300C\u26F6 \u653E\u5927\u8F93\u5165\u300D\u6309\u94AE\uFF08composer \u5DF2\u7ECF\u6709\u5B8C\u6574\u5DE5\u5177\u884C\uFF09 */
1218
+ [data-mobile-nav="composer-fullscreen"] {
1219
+ display: none !important;
1220
+ }
1221
+ }
1222
+
1223
+ /* ---------- \u653E\u5927\u8F93\u5165\uFF08issue #23\uFF09 ----------
1224
+ \u7A84\u5C4F\u9ED8\u8BA4\u9690\u85CF\u63D2\u4EF6\u6CE8\u518C\u5230 conversation.input.* slot \u7684 UI\uFF08\u4EC5\u7559\u5B98\u65B9 resident
1225
+ chrome\uFF09\uFF0C\u70B9\u300C\u26F6\u300D\u6309\u94AE\u628A composer \u5361\u7247\u56FA\u5B9A\u5230\u5168\u5C4F\u540E\u6240\u6709\u63D2\u4EF6 UI \u6062\u590D\u663E\u793A\u2014\u2014
1226
+ \u65B0\u63D2\u4EF6\u96F6\u9002\u914D\u3002
1227
+ \u5B9E\u73B0\u8BF4\u660E\uFF1A\u5F53\u524D dsh 0.1.1 \u6CA1\u628A input slot \u6E32\u67D3\u6210 [data-slot] \u5305\u88C5\uFF080.1.2+ \u624D\u6709\uFF09\uFF0C
1228
+ \u6240\u4EE5\u8FD9\u91CC\u7528\u300C\u767D\u540D\u5355\u300D\u53CD\u9009\uFF1AmobileApply \u6CE8\u518C\u7684 mobile \u81EA\u8EAB\u8282\u70B9\u52A0
1229
+ data-mobile-nav="composer-fullscreen" \u4E0D\u88AB\u9690\u85CF\uFF1B\u5176\u5B83\u63D2\u4EF6 UI \u9690\u85CF\u3002 */
1230
+ @media (max-width: 1023px) {
1231
+ /* \u9ED8\u8BA4\uFF08\u975E\u5168\u5C4F\uFF09\uFF1A\u9690\u85CF input slot \u5BB9\u5668\u4E0B\u7684\u6240\u6709\u76F4\u63A5\u5B50\u5143\u7D20\uFF08\u5305\u542B\u63D2\u4EF6\u548C\u5B98\u65B9\uFF09
1232
+ \u2014\u2014\u5B98\u65B9\u81EA\u5DF1\u5982\u679C\u60F3\u5728\u7A84\u5C4F\u4E5F\u9732\u51FA\uFF0C\u6CE8 input slot \u65F6\u52A0 data-mobile-nav-keep="1" */
1233
+ [data-dsh-pocket-composer-fullscreen]:not([data-dsh-pocket-composer-fullscreen="1"])
1234
+ [class$="_card"]:has(textarea) [data-slot^="conversation.input."] > :not([data-mobile-nav-keep]),
1235
+ [data-dsh-pocket-composer-fullscreen]:not([data-dsh-pocket-composer-fullscreen="1"])
1236
+ [class$="_card"]:has(textarea) [data-slot^="conversation.input."]:empty {
1237
+ display: none !important;
1238
+ }
1239
+ /* \u653E\u5927\u6309\u94AE\uFF1A\u5E38\u9A7B\u5728 conversation.input.right slot \u65C1\uFF0C\u684C\u9762\u7AEF\u5DF2\u88AB\u4E0A\u9762\u89C4\u5219\u9690\u85CF */
1240
+ [data-mobile-nav="composer-fullscreen"] {
1241
+ display: inline-flex;
1242
+ }
1243
+ /* \u5168\u5C4F\u6A21\u5F0F\uFF1Acomposer \u5361\u7247\u56FA\u5B9A\u5230\u89C6\u53E3\uFF0Ctextarea \u62C9\u9AD8\uFF0C\u5DE5\u5177\u884C\u53EF\u6362\u884C */
1244
+ [data-dsh-pocket-composer-fullscreen="1"] [class$="_card"]:has(textarea) {
1245
+ position: fixed !important;
1246
+ inset: 0 !important;
1247
+ z-index: 9999 !important;
1248
+ border-radius: 0 !important;
1249
+ margin: 0 !important;
1250
+ height: 100dvh !important;
1251
+ display: flex !important;
1252
+ flex-direction: column !important;
1253
+ }
1254
+ [data-dsh-pocket-composer-fullscreen="1"] [class$="_card"]:has(textarea) textarea {
1255
+ flex: 1 1 auto !important;
1256
+ min-height: 50dvh !important;
1257
+ max-height: none !important;
1258
+ }
1259
+ [data-dsh-pocket-composer-fullscreen="1"] [class$="_card"]:has(textarea) [data-slot^="conversation.input."] {
1260
+ flex-wrap: wrap !important;
1261
+ }
1217
1262
  }
1218
1263
  `;
1219
1264
 
@@ -1439,6 +1484,12 @@ function mobileApply(ctx) {
1439
1484
  toggleSidebar: () => ctx.layout.toggleSidebar()
1440
1485
  })
1441
1486
  }, MobileNavToggle));
1487
+ ctx.slots.inject("conversation.input.right", () => ctx.slots.register({
1488
+ name: "conversation.input.right",
1489
+ id: "mobile-composer-fullscreen",
1490
+ order: 100,
1491
+ locale: NS
1492
+ }, MobileComposerFullscreen));
1442
1493
  ctx.slots.inject("shell.overlay", () => ctx.slots.register({
1443
1494
  name: "shell.overlay",
1444
1495
  id: "mobile-nav-overlay",
@@ -0,0 +1,77 @@
1
+ // 手机端「⛶ 放大输入」开关(issue #23)
2
+ //
3
+ // 行为:
4
+ // - 点击切换 body 属性 `data-dsh-pocket-composer-fullscreen`;
5
+ // - mobile.css.ts 监听这个属性,把 composer 卡片固定到全屏(textarea 拉高、工具行
6
+ // 全部展开),插件 UI 注册到 conversation.input.* slot 的按钮全部恢复显示;
7
+ // - 默认(无 fullscreen 属性)下,CSS 把这些 slot 全部隐藏,只保留官方 resident
8
+ // chrome(权限/plan/模型/发送)。新插件只要注册到 input slot 就自动遵守此规则,
9
+ // 无需逐个适配。
10
+ //
11
+ // 实现细节:
12
+ // - 不依赖 dsh web 把 data-slot 包装暴露给选择器(0.1.1 没,0.1.2+ 才有)。我们用
13
+ // `:scope > :not([data-mobile-nav-keep])` 这样的「保留」标记:所有插件如果
14
+ // 想在手机未放大时也露出,加 data-mobile-nav-keep="1" 即可。默认隐藏的是
15
+ // slot 容器下的所有直接子元素,**包含 dsh web 官方自己注入的**(这些是
16
+ // 我们想隐藏的"插件 UI"——只要不冲突)。所以需要 dsh web 在官方自己的 UI 上
17
+ // 加 `data-mobile-nav-keep` 标记——等 dsh 0.1.2+ data-slot 上线后改用那个
18
+ // 更优雅(CSS 选择器变成 `[data-slot=...] > :not([data-mobile-nav-keep])`)。
19
+ import { createElement as h, useEffect, useState } from 'react';
20
+
21
+ const FULLSCREEN_ATTR = 'data-dsh-pocket-composer-fullscreen';
22
+
23
+ function isFullscreen() {
24
+ return typeof document !== 'undefined' && document.body?.getAttribute(FULLSCREEN_ATTR) === '1';
25
+ }
26
+
27
+ function setFullscreen(on) {
28
+ if (typeof document === 'undefined') return;
29
+ if (on) document.body.setAttribute(FULLSCREEN_ATTR, '1');
30
+ else document.body.removeAttribute(FULLSCREEN_ATTR);
31
+ }
32
+
33
+ /**
34
+ * 放大输入按钮:注册到 `conversation.input.right`,id `mobile-composer-fullscreen`。
35
+ * 仅在窄屏(max-width:1023px)显示,桌面端由 CSS 隐藏。
36
+ */
37
+ export function MobileComposerFullscreen() {
38
+ const [on, setOn] = useState(isFullscreen);
39
+ useEffect(() => {
40
+ const update = () => setOn(isFullscreen());
41
+ // 与其它 effect 协调:监听任何对 body 属性的修改即可(设置项自己改也算)
42
+ const observer = new MutationObserver(update);
43
+ observer.observe(document.body, { attributes: true, attributeFilter: [FULLSCREEN_ATTR] });
44
+ return () => observer.disconnect();
45
+ }, []);
46
+ return h('button', {
47
+ type: 'button',
48
+ 'aria-label': on ? '收起输入' : '放大输入',
49
+ 'aria-pressed': on,
50
+ title: on ? '收起输入' : '放大输入',
51
+ 'data-mobile-nav': 'composer-fullscreen',
52
+ 'data-mobile-nav-keep': '1', // 自己的按钮不该被 #23 的 slot 通用隐藏规则误伤
53
+ onClick: () => {
54
+ const next = !isFullscreen();
55
+ setFullscreen(next);
56
+ setOn(next);
57
+ },
58
+ style: {
59
+ appearance: 'none',
60
+ WebkitAppearance: 'none',
61
+ border: 'none',
62
+ background: 'transparent',
63
+ color: 'inherit',
64
+ cursor: 'pointer',
65
+ padding: '0 8px',
66
+ height: 32,
67
+ minWidth: 32,
68
+ borderRadius: 6,
69
+ display: 'inline-flex',
70
+ alignItems: 'center',
71
+ justifyContent: 'center',
72
+ font: 'inherit',
73
+ fontSize: 16,
74
+ lineHeight: 1,
75
+ },
76
+ }, on ? '⤡' : '⤢');
77
+ }
@@ -272,6 +272,16 @@ export function mobileApply(ctx): void {
272
272
  }),
273
273
  }, MobileNavToggle))
274
274
 
275
+ // 「⛶ 放大输入」按钮(issue #23):注册到 conversation.input.right(发送键旁)
276
+ // ——桌面端由 mobile.css.ts 隐藏(min-width:1024px)。点按切换 body 上的
277
+ // data-dsh-pocket-composer-fullscreen 标记,由 CSS 把 composer 卡片全屏化。
278
+ ctx.slots.inject('conversation.input.right', () => ctx.slots.register({
279
+ name: 'conversation.input.right',
280
+ id: 'mobile-composer-fullscreen',
281
+ order: 100,
282
+ locale: NS,
283
+ }, MobileComposerFullscreen))
284
+
275
285
  ctx.slots.inject('shell.overlay', () => ctx.slots.register({
276
286
  name: 'shell.overlay',
277
287
  id: 'mobile-nav-overlay',
@@ -886,5 +886,50 @@ export const MOBILE_CSS = `
886
886
  [data-mobile-nav="drawer-actions"] {
887
887
  display: none !important;
888
888
  }
889
+ /* 桌面端永远不需要「⛶ 放大输入」按钮(composer 已经有完整工具行) */
890
+ [data-mobile-nav="composer-fullscreen"] {
891
+ display: none !important;
892
+ }
893
+ }
894
+
895
+ /* ---------- 放大输入(issue #23) ----------
896
+ 窄屏默认隐藏插件注册到 conversation.input.* slot 的 UI(仅留官方 resident
897
+ chrome),点「⛶」按钮把 composer 卡片固定到全屏后所有插件 UI 恢复显示——
898
+ 新插件零适配。
899
+ 实现说明:当前 dsh 0.1.1 没把 input slot 渲染成 [data-slot] 包装(0.1.2+ 才有),
900
+ 所以这里用「白名单」反选:mobileApply 注册的 mobile 自身节点加
901
+ data-mobile-nav="composer-fullscreen" 不被隐藏;其它插件 UI 隐藏。 */
902
+ @media (max-width: 1023px) {
903
+ /* 默认(非全屏):隐藏 input slot 容器下的所有直接子元素(包含插件和官方)
904
+ ——官方自己如果想在窄屏也露出,注 input slot 时加 data-mobile-nav-keep="1" */
905
+ [data-dsh-pocket-composer-fullscreen]:not([data-dsh-pocket-composer-fullscreen="1"])
906
+ [class$="_card"]:has(textarea) [data-slot^="conversation.input."] > :not([data-mobile-nav-keep]),
907
+ [data-dsh-pocket-composer-fullscreen]:not([data-dsh-pocket-composer-fullscreen="1"])
908
+ [class$="_card"]:has(textarea) [data-slot^="conversation.input."]:empty {
909
+ display: none !important;
910
+ }
911
+ /* 放大按钮:常驻在 conversation.input.right slot 旁,桌面端已被上面规则隐藏 */
912
+ [data-mobile-nav="composer-fullscreen"] {
913
+ display: inline-flex;
914
+ }
915
+ /* 全屏模式:composer 卡片固定到视口,textarea 拉高,工具行可换行 */
916
+ [data-dsh-pocket-composer-fullscreen="1"] [class$="_card"]:has(textarea) {
917
+ position: fixed !important;
918
+ inset: 0 !important;
919
+ z-index: 9999 !important;
920
+ border-radius: 0 !important;
921
+ margin: 0 !important;
922
+ height: 100dvh !important;
923
+ display: flex !important;
924
+ flex-direction: column !important;
925
+ }
926
+ [data-dsh-pocket-composer-fullscreen="1"] [class$="_card"]:has(textarea) textarea {
927
+ flex: 1 1 auto !important;
928
+ min-height: 50dvh !important;
929
+ max-height: none !important;
930
+ }
931
+ [data-dsh-pocket-composer-fullscreen="1"] [class$="_card"]:has(textarea) [data-slot^="conversation.input."] {
932
+ flex-wrap: wrap !important;
933
+ }
889
934
  }
890
935
  `
package/lib/tunnel.mjs CHANGED
@@ -23,12 +23,37 @@ import { createWriteStream } from 'node:fs';
23
23
  export const QUICK_TUNNEL_URL_RE = /https:\/\/(?!api\.)[a-z0-9-]+\.trycloudflare\.com/i;
24
24
 
25
25
  function platformBinary() {
26
- const archMap = { x64: 'amd64', arm64: 'arm64' };
26
+ const archMap = { x64: 'amd64', arm64: 'arm64', ia32: '386', arm: 'arm' };
27
27
  const a = archMap[process.arch] ?? process.arch;
28
28
  const os = process.platform === 'darwin' ? 'darwin' : process.platform === 'win32' ? 'windows' : 'linux';
29
29
  return { os, a, ext: os === 'windows' ? '.exe' : '' };
30
30
  }
31
31
 
32
+ /**
33
+ * 候选发布资产名,按优先级排列(issue #45)。
34
+ *
35
+ * cloudflared 现在的发布布局是**按平台分**:
36
+ * - linux:只有裸二进制 `cloudflared-linux-amd64`(没有 .tgz)
37
+ * - darwin:只有 `cloudflared-darwin-<arch>.tgz`
38
+ * - windows:只有 `cloudflared-windows-<arch>.exe`
39
+ *
40
+ * 我们以前给 linux 拼的是 `cloudflared-linux-amd64.tgz`,而它**根本不存在**
41
+ * (GitHub 返回 404),五个镜像全指向同一个 404 地址,于是必然"所有源都不通"
42
+ * —— 表现就是 Linux 服务器上开了公网访问一直报"无法安装",而 macOS/Windows
43
+ * 一切正常。Linux 下裸二进制还有个额外好处:不用解压,也就不再依赖系统装了
44
+ * `tar`(Alpine / slim 镜像 / 容器里常常没有)。
45
+ *
46
+ * linux 仍把 .tgz 留在候选里作为回退,万一上游改回打包方式也不会直接躺平。
47
+ *
48
+ * @returns {string[]} 资产名列表,越靠前越优先。
49
+ */
50
+ export function platformAssets() {
51
+ const { os, a } = platformBinary();
52
+ if (os === 'windows') return [`cloudflared-windows-${a}.exe`];
53
+ if (os === 'darwin') return [`cloudflared-darwin-${a}.tgz`];
54
+ return [`cloudflared-linux-${a}`, `cloudflared-linux-${a}.tgz`];
55
+ }
56
+
32
57
  /**
33
58
  * cloudflared 下载源。
34
59
  * 优先:清华 TUNA 镜像的 Homebrew bottle(国内 CDN,实测 ~3MB/s)——仅 macOS/Linux
@@ -189,51 +214,59 @@ async function downloadCloudflared(binPath, signal) {
189
214
  const dir = dirname(binPath);
190
215
  const tmpFile = join(dir, `cloudflared.download`);
191
216
  const isWindows = os === 'windows';
192
- // 发布资产:Windows 是 .exe(下载即二进制),macOS/Linux 是 .tgz(需解压)
193
- const asset = isWindows ? `cloudflared-windows-${a}.exe` : `cloudflared-${os}-${a}.tgz`;
194
217
  const fetchSignal = signal
195
218
  ? AbortSignal.any([signal, AbortSignal.timeout(120_000)])
196
219
  : AbortSignal.timeout(120_000);
197
220
 
198
- // 构建有序源列表:[{url, host}];清华(如有,仅 macOS/Linux)排第一,再官方 + 加速源
199
- const sources = [];
200
- if (!isWindows) {
201
- const tua = await tsinghuaBottleUrl({ os, a }).catch(() => null);
202
- if (tua) sources.push({ url: tua, host: 'mirrors.tuna.tsinghua.edu.cn' });
203
- }
204
- for (const m of CLOUDFLARED_MIRRORS) sources.push({ url: m(asset), host: hostOf(m(asset)) });
221
+ // 清华 bottle(仅 macOS/Linux 且有对应 bottle);与资产名无关,只算一次
222
+ const bottle = isWindows ? null : await tsinghuaBottleUrl({ os, a }).catch(() => null);
205
223
 
224
+ // 逐个资产试(linux 首选裸二进制,.tgz 只是回退),每个资产再逐个源试
225
+ const assets = platformAssets();
206
226
  let lastErr = null;
207
- for (let i = 0; i < sources.length; i++) {
208
- const { url, host } = sources[i];
209
- console.log(`⬇️ 下载 cloudflared(${i + 1}/${sources.length}:${host})…`);
210
- try {
211
- // 多线程分块(官方 GitHub 支持 Range,Windows 50MB 从几分钟降到几十秒);
212
- // 不支持 Range 的源自动回退单线程
213
- await downloadFile(url, tmpFile, { signal: fetchSignal });
214
- // 简单校验:空文件/极小文件视为下载失败(可能是镜像返回了错误页)
215
- const st = await stat(tmpFile);
216
- if (st.size < 1024 * 1024) throw new Error(`文件异常小(${st.size} 字节),疑似镜像错误页`);
217
- lastErr = null;
218
- break; // 下载成功
219
- } catch (err) {
220
- lastErr = err;
221
- await rm(tmpFile, { force: true }).catch(() => {}); // 清掉半截文件
222
- console.warn(` ⚠️ ${i + 1} 失败:${err?.message ?? err},尝试下一个…`);
227
+ let usedAsset = null;
228
+
229
+ for (let ai = 0; ai < assets.length && usedAsset === null; ai++) {
230
+ const asset = assets[ai];
231
+ const sources = [];
232
+ if (bottle && asset.endsWith('.tgz')) {
233
+ sources.push({ url: bottle, host: 'mirrors.tuna.tsinghua.edu.cn' });
234
+ }
235
+ for (const m of CLOUDFLARED_MIRRORS) sources.push({ url: m(asset), host: hostOf(m(asset)) });
236
+
237
+ for (let i = 0; i < sources.length; i++) {
238
+ const { url, host } = sources[i];
239
+ console.log(`⬇️ 下载 cloudflared(${asset},源 ${i + 1}/${sources.length}:${host})…`);
240
+ try {
241
+ // 多线程分块(官方 GitHub 支持 Range,Windows 50MB 从几分钟降到几十秒);
242
+ // 不支持 Range 的源自动回退单线程
243
+ await downloadFile(url, tmpFile, { signal: fetchSignal });
244
+ // 简单校验:空文件/极小文件视为下载失败(可能是镜像返回了错误页)
245
+ const st = await stat(tmpFile);
246
+ if (st.size < 1024 * 1024) throw new Error(`文件异常小(${st.size} 字节),疑似镜像错误页`);
247
+ usedAsset = asset;
248
+ lastErr = null;
249
+ break; // 下载成功
250
+ } catch (err) {
251
+ lastErr = err;
252
+ await rm(tmpFile, { force: true }).catch(() => {}); // 清掉半截文件
253
+ console.warn(` ⚠️ 源 ${i + 1} 失败:${err?.message ?? err},尝试下一个…`);
254
+ }
223
255
  }
224
256
  }
225
- if (lastErr) {
257
+ if (usedAsset === null) {
226
258
  throw new Error(
227
259
  `cloudflared 下载失败:所有源都不通(最后错误:${lastErr?.message ?? lastErr})。`
228
260
  + (isWindows
229
- ? `Windows 可手动安装后重试:winget install cloudflared;或下载 ${asset} 放到 ${dir} 目录 | download failed — try: winget install cloudflared, or put the exe into ${dir}`
230
- : `可手动安装后重试:npm i -g cloudflared(装好命令行 cloudflared 即可,无需下载);或开启代理/换网络后重试 | all mirrors failed — install cloudflared manually: npm i -g cloudflared, then retry`),
261
+ ? `Windows 可手动安装后重试:winget install cloudflared;或下载 ${assets[0]} 放到 ${dir} 目录 | download failed — try: winget install cloudflared, or put the exe into ${dir}`
262
+ : `也可以自己装好后在 settings.json "cloudflaredPath": "/path/to/cloudflared" 跳过下载(或用环境变量 DSH_POCKET_CLOUDFLARED);或用包管理器安装:apt/dnf install cloudflared | all mirrors failed — install cloudflared manually and set "cloudflaredPath" in settings.json, or: apt/dnf install cloudflared`),
231
263
  );
232
264
  }
233
265
 
234
266
  let extracted = join(dir, `cloudflared${ext}`);
235
- if (isWindows) {
236
- // Windows:exe 直接就是二进制,无需解压
267
+ // 只有 .tgz 才需要解压:Windows 的 .exe 和 linux 的裸二进制本身就可直接执行
268
+ // (linux 用裸二进制还有个好处——不依赖系统装了 tar,见 platformAssets 的说明)
269
+ if (!usedAsset.endsWith('.tgz')) {
237
270
  await rename(tmpFile, extracted).catch(async () => {
238
271
  await cp(tmpFile, extracted).catch(() => {});
239
272
  });
@@ -246,7 +279,13 @@ async function downloadCloudflared(binPath, signal) {
246
279
  await new Promise((resolve, reject) => {
247
280
  const child = spawn('tar', ['-xzf', tmpFile, '-C', extractDir], { stdio: 'ignore' });
248
281
  child.once('exit', (code) => code === 0 ? resolve() : reject(new Error(`cloudflared 解压失败(code=${code})`)));
249
- child.once('error', reject);
282
+ // spawn 失败(Alpine / slim 镜像 / 容器里常见:根本没有 tar)时 err.code
283
+ // ENOENT,直接抛出来只有一句 "spawn tar ENOENT",看不出该怎么办
284
+ child.once('error', (err) => reject(
285
+ err?.code === 'ENOENT'
286
+ ? new Error(`系统里没有 tar 命令,无法解压 ${usedAsset} —— 可改用 linux 裸二进制(默认已如此)或手动安装后设置 cloudflaredPath | no tar on this system`)
287
+ : err,
288
+ ));
250
289
  });
251
290
  // 找真实的二进制**文件**(排除目录):
252
291
  // - GitHub tgz:extractDir/cloudflared
package/package.json CHANGED
@@ -80,5 +80,5 @@
80
80
  "access": "public",
81
81
  "registry": "https://registry.npmjs.org/"
82
82
  },
83
- "version": "2.5.1"
83
+ "version": "2.6.1"
84
84
  }