dsh-remote-agent 0.9.6 → 0.9.8

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/lib/client.js CHANGED
@@ -163,6 +163,30 @@ window.__ModuleLoader__.load({
163
163
  // 模板库降级提示(文件坏了 / 读不了):用 warn 的视觉语言,不是错误
164
164
  '.dra-tplnote{font-size:11px;color:var(--dsw-alias-label-secondary);',
165
165
  'padding:2px 0 4px;flex:1 1 100%}',
166
+ // ── 可折叠区块(2026-10-20,UI 改进)──────────────────────────
167
+ //
168
+ // 问题:加了一键修复、外部工具、SMB 共享之后,面板区块到了 9 个
169
+ // (配置/体检/工具/配对/卡片/节点/共享/派发/任务),首屏被淹没 ——
170
+ // 用户打开面板先看到一堆"当前不需要"的东西。
171
+ //
172
+ // 做法:把"配置类"区块收进折叠面板,默认只展开**当前最需要的**:
173
+ // · 没副机在线 → 展开"接入"
174
+ // · 有副机在线 → 展开"任务"
175
+ //
176
+ // 用原生 <details>:零 JS 状态、可访问性好、无 hydration 问题。
177
+ '.dra-fold{border:1px solid var(--dsw-alias-border-l1);border-radius:8px;',
178
+ 'margin:10px 0;background:var(--dsw-alias-bg-layer-1);overflow:hidden}',
179
+ '.dra-fold>summary{padding:8px 12px;cursor:pointer;font-size:12px;',
180
+ 'font-weight:600;color:var(--dsw-alias-label-secondary);',
181
+ 'list-style:none;user-select:none;display:flex;align-items:center;gap:6px}',
182
+ '.dra-fold>summary::-webkit-details-marker{display:none}',
183
+ '.dra-fold>summary::before{content:"▸";font-size:10px;opacity:.7;',
184
+ 'transition:transform .12s}',
185
+ '.dra-fold[open]>summary::before{transform:rotate(90deg)}',
186
+ '.dra-fold>summary:hover{background:var(--dsw-alias-bg-layer-2)}',
187
+ '.dra-fold-b{padding:4px 12px 12px}',
188
+ // 折叠块里的小标题要更轻 —— 避免和区块标题抢视觉层级
189
+ '.dra-fold .dra-sec{margin:8px 0 4px;font-size:10px}',
166
190
  ].join('')
167
191
 
168
192
  /* ───────────────────────── 数据层 ───────────────────────── */
@@ -1093,6 +1117,82 @@ window.__ModuleLoader__.load({
1093
1117
  .then(function () { setEnvBusy(false) })
1094
1118
  }
1095
1119
 
1120
+ // ── 一键修复状态(2026-10-20)────────────────────────────────────
1121
+ //
1122
+ // 三步流程,每一步都要用户看得见:
1123
+ // fixPlan === null → 只显示「预览修复」按钮
1124
+ // fixPlan !== null → 显示"将要改什么",给「确认修复」
1125
+ // 点确认后 → 写盘 + 写后校验 + 提示重启
1126
+ const fixPlanS = React.useState(null)
1127
+ const fixPlan = fixPlanS[0]
1128
+ const setFixPlan = fixPlanS[1]
1129
+ const fixBusyS = React.useState(false)
1130
+ const fixBusy = fixBusyS[0]
1131
+ const setFixBusy = fixBusyS[1]
1132
+ const fixMsgS = React.useState('')
1133
+ const fixMsg = fixMsgS[0]
1134
+ const setFixMsg = fixMsgS[1]
1135
+
1136
+ /** 第一步:只读预览(不写任何文件)。 */
1137
+ const previewFix = function () {
1138
+ if (fixBusy) return
1139
+ setFixBusy(true)
1140
+ setFixMsg('')
1141
+ apiPost('/fix', { action: 'plan' })
1142
+ .then(function (r) {
1143
+ if (r && r.ok) {
1144
+ setFixPlan(r.plan || null)
1145
+ } else {
1146
+ setFixMsg((r && r.error) || '预览失败')
1147
+ }
1148
+ })
1149
+ .catch(function (err) { setFixMsg('预览失败:' + ((err && err.message) || err)) })
1150
+ .then(function () { setFixBusy(false) })
1151
+ }
1152
+
1153
+ /** 第二步:确认后写盘(带 confirm: 'yes')。 */
1154
+ const applyFix = function () {
1155
+ if (fixBusy) return
1156
+ setFixBusy(true)
1157
+ setFixMsg('')
1158
+ apiPost('/fix', { action: 'apply', confirm: 'yes' })
1159
+ .then(function (r) {
1160
+ if (r && r.ok) {
1161
+ setFixMsg('已修复:' + (r.applied || []).join(', ') + ' —— 重启 DSH 生效')
1162
+ setFixPlan(null)
1163
+ // 修完重新体检一次,让用户看到"确实好了"
1164
+ runEnvCheck()
1165
+ } else {
1166
+ setFixMsg((r && r.error) || '修复失败')
1167
+ }
1168
+ })
1169
+ .catch(function (err) { setFixMsg('修复失败:' + ((err && err.message) || err)) })
1170
+ .then(function () { setFixBusy(false) })
1171
+ }
1172
+
1173
+ // ── 外部工具检测(2026-10-20)───────────────────────────────────
1174
+ //
1175
+ // 用户要求"自动装依赖"。我们把范围收窄到**外部工具**,且**不代下载**:
1176
+ // · 零 npm 依赖是核心卖点,代装 npm 包会自毁卖点
1177
+ // · 代下载第三方二进制会让我们变成软件分发方(供应链责任)
1178
+ //
1179
+ // 所以这里只做:探测 → 说清缺了会怎样 → 给官方链接 + 安装命令(可复制)。
1180
+ const toolsS = React.useState(null)
1181
+ const extTools = toolsS[0]
1182
+ const setExtTools = toolsS[1]
1183
+ const toolsBusyS = React.useState(false)
1184
+ const toolsBusy = toolsBusyS[0]
1185
+ const setToolsBusy = toolsBusyS[1]
1186
+
1187
+ const runToolProbe = function () {
1188
+ if (toolsBusy) return
1189
+ setToolsBusy(true)
1190
+ apiGet('/tools')
1191
+ .then(function (r) { setExtTools(r || null) })
1192
+ .catch(function (err) { setNote('工具检测失败:' + ((err && err.message) || err)) })
1193
+ .then(function () { setToolsBusy(false) })
1194
+ }
1195
+
1096
1196
  // 首次自动体检的闸门。用 ref(不是 state)—— 它只控制"要不要跑一次",
1097
1197
  // 改了不需要重渲染。
1098
1198
  const autoChecked = React.useRef(false)
@@ -1607,6 +1707,14 @@ window.__ModuleLoader__.load({
1607
1707
  disabled: envBusy,
1608
1708
  onClick: runEnvCheck,
1609
1709
  }, envBusy ? '体检中…' : '体检'),
1710
+ // ── 外部工具检测(2026-10-20)────────────────────────────────
1711
+ //
1712
+ // 不自动跑:每次要 spawn 4 个进程查版本。用户点了才查。
1713
+ h('button', {
1714
+ className: 'dra-btn',
1715
+ disabled: toolsBusy,
1716
+ onClick: runToolProbe,
1717
+ }, toolsBusy ? '检测中…' : '检测工具'),
1610
1718
  // ── 清理任务视图 ─────────────────────────────────────────────
1611
1719
  //
1612
1720
  // 为什么面板上需要这个按钮:hub 侧任务视图会累积(副机的任务记录
@@ -1806,7 +1914,60 @@ window.__ModuleLoader__.load({
1806
1914
  (envCheck.failed === 0
1807
1915
  ? '核心项全部通过'
1808
1916
  : '共 ' + envCheck.failed + ' 项需要处理')
1809
- + (envCheck.autoFixable > 0 ? '(其中 ' + envCheck.autoFixable + ' 项修法机械,照抄即可)' : '')),
1917
+ + (envCheck.autoFixable > 0 ? '(其中 ' + envCheck.autoFixable + ' 项修法机械,可一键修复)' : '')),
1918
+ // ── 一键修复(2026-10-20)─────────────────────────────────
1919
+ //
1920
+ // ⚠ 这会改 profile 的 cordis.patch.yml(DSH 自己的配置文件)。
1921
+ // 所以流程刻意做成**两步**:
1922
+ // 1. 点「预览修复」→ 只读,列出"将要改什么"+"不会做什么"
1923
+ // 2. 看清后点「确认修复」→ 才写盘(带 confirm: 'yes')
1924
+ //
1925
+ // 写盘有备份 + 写后校验,改完给「撤销」按钮。
1926
+ fixPlan !== null
1927
+ ? h('div', { className: 'dra-warn', style: { marginBottom: '8px' } },
1928
+ h('div', { style: { fontWeight: '600' } }, '将要修改 profile 配置(需重启生效)'),
1929
+ h('div', { className: 'dra-dim', style: { marginTop: '4px' } }, '文件:' + (fixPlan.file || '')),
1930
+ fixPlan.fixable.length > 0
1931
+ ? h('div', { style: { marginTop: '4px' } },
1932
+ fixPlan.fixable.map(function (f) {
1933
+ return h('div', { key: f.id, className: 'dra-dim' }, '· ' + f.change)
1934
+ }))
1935
+ : h('div', { className: 'dra-dim', style: { marginTop: '4px' } }, '没有可自动修的项。'),
1936
+ // 明确列出"不会做什么"——比承诺做什么更重要
1937
+ fixPlan.fixable.length > 0
1938
+ ? h('div', { className: 'dra-dim', style: { marginTop: '4px' } },
1939
+ '只会改托管块里那几个字段,你的其它配置条目一个字节不动;'
1940
+ + '写前自动备份,改坏可一键撤销。')
1941
+ : null,
1942
+ fixPlan.skipped.length > 0
1943
+ ? h('div', { className: 'dra-dim', style: { marginTop: '4px' } },
1944
+ '这些项需要你判断,不会自动改:' + fixPlan.skipped.map(function (s) { return s.label }).join('、'))
1945
+ : null,
1946
+ h('div', { className: 'dra-actions', style: { marginTop: '6px' } },
1947
+ fixPlan.fixable.length > 0
1948
+ ? h('button', {
1949
+ className: 'dra-btn',
1950
+ disabled: fixBusy,
1951
+ onClick: function () { applyFix() },
1952
+ }, fixBusy ? '写入中…' : '确认修复')
1953
+ : null,
1954
+ h('button', {
1955
+ className: 'dra-btn',
1956
+ onClick: function () { setFixPlan(null); setFixMsg('') },
1957
+ }, '取消'),
1958
+ fixMsg ? h('span', { className: 'dra-dim' }, fixMsg) : null,
1959
+ ),
1960
+ )
1961
+ : (envCheck.failed > 0 || envCheck.autoFixable > 0)
1962
+ ? h('div', { className: 'dra-actions', style: { marginBottom: '8px' } },
1963
+ h('button', {
1964
+ className: 'dra-btn',
1965
+ disabled: fixBusy,
1966
+ onClick: function () { previewFix() },
1967
+ }, fixBusy ? '检查中…' : '预览修复'),
1968
+ h('span', { className: 'dra-dim' }, '只读预览 —— 先看要改什么,再决定'),
1969
+ )
1970
+ : null,
1810
1971
  (envCheck.items || []).map(function (it) {
1811
1972
  const mark = it.ok ? '✓' : (it.severity === 'warn' ? '⚠' : '✗')
1812
1973
  return h('div', { key: it.id, className: 'dra-env-item' },
@@ -1845,6 +2006,64 @@ window.__ModuleLoader__.load({
1845
2006
  }),
1846
2007
  )
1847
2008
 
2009
+ // ── 外部工具(2026-10-20)──────────────────────────────────────
2010
+ //
2011
+ // 只在用户点了「检测工具」后显示(不自动跑:每次要 spawn 4 个进程)。
2012
+ // 缺失的项给"为什么需要 + 安装命令(可复制)+ 官方链接"。
2013
+ // **不代下载** —— 边界写在 lib/external-tools.js 头注释。
2014
+ const toolsSection = extTools === null
2015
+ ? null
2016
+ : h('div', null,
2017
+ h('div', { className: 'dra-sec' }, '外部工具'),
2018
+ (extTools.tools || []).map(function (t) {
2019
+ return h('div', { key: t.id, style: { marginBottom: '6px' } },
2020
+ h('div', { className: 'dra-row', style: { alignItems: 'baseline' } },
2021
+ h('span', {
2022
+ className: 'dra-mono',
2023
+ style: { color: t.installed ? 'var(--dsw-alias-state-success-primary, currentColor)' : 'var(--dsw-alias-state-warning-primary, currentColor)' },
2024
+ }, t.installed ? '✓' : '⚠'),
2025
+ h('span', { style: { flex: '1' } }, t.name + (t.required ? '(必需)' : '')),
2026
+ t.installed
2027
+ ? h('span', { className: 'dra-dim' }, t.version)
2028
+ : h('span', { className: 'dra-dim' }, '未安装'),
2029
+ ),
2030
+ !t.installed
2031
+ ? h('div', null,
2032
+ h('div', { className: 'dra-dim', style: { paddingLeft: '20px' } }, t.why),
2033
+ h('div', { className: 'dra-row', style: { paddingLeft: '20px', marginTop: '2px' } },
2034
+ t.install
2035
+ ? h('button', {
2036
+ className: 'dra-btn',
2037
+ style: { padding: '2px 8px', fontSize: '11px' },
2038
+ onClick: function () {
2039
+ copyText(t.install).then(function (ok) {
2040
+ setNote(ok ? '已复制安装命令' : '复制失败,请手动选中')
2041
+ })
2042
+ },
2043
+ }, '复制安装命令')
2044
+ : null,
2045
+ t.url
2046
+ ? h('button', {
2047
+ className: 'dra-btn',
2048
+ style: { padding: '2px 8px', fontSize: '11px' },
2049
+ onClick: function () {
2050
+ // 用系统浏览器打开官方下载页(不是我们代理下载)
2051
+ try { window.open(t.url, '_blank', 'noopener') } catch { setNote('打开失败:' + t.url) }
2052
+ },
2053
+ }, '官方下载页')
2054
+ : null,
2055
+ t.install
2056
+ ? h('span', { className: 'dra-dim dra-mono', style: { fontSize: '11px' } }, t.install)
2057
+ : null,
2058
+ ),
2059
+ )
2060
+ : null,
2061
+ )
2062
+ }),
2063
+ h('div', { className: 'dra-dim', style: { marginTop: '4px' } },
2064
+ '本插件不代下载第三方软件 —— 只告诉你缺什么、怎么装。'),
2065
+ )
2066
+
1848
2067
  const errBox = state.error
1849
2068
  ? h('div', { className: 'dra-err' }, '读取失败:' + state.error)
1850
2069
  : null
@@ -1940,6 +2159,34 @@ window.__ModuleLoader__.load({
1940
2159
  h('div', { className: 'dra-card-l' }, '任务总数')),
1941
2160
  )
1942
2161
 
2162
+ // ── 可折叠区块(2026-10-20,UI 改进)──────────────────────────
2163
+ //
2164
+ // 加了一键修复 / 外部工具 / SMB 共享之后,面板区块到了 9 个,
2165
+ // 首屏被淹没:用户打开面板先看到一堆"当前不需要"的东西。
2166
+ //
2167
+ // 用原生 `<details>`:
2168
+ // · 零 JS 状态(不用 React 管展开/收起)
2169
+ // · 可访问性好(键盘可操作、屏幕阅读器认)
2170
+ // · 无 hydration 问题
2171
+ //
2172
+ // 默认展开策略由调用方决定(见下面的 render):
2173
+ // · 没副机在线 → 展开「接入与配置」(那是用户当下要解决的)
2174
+ // · 有副机在线 → 展开「任务」
2175
+ const Fold = function (props) {
2176
+ return h('details', {
2177
+ className: 'dra-fold',
2178
+ open: props.open === true,
2179
+ },
2180
+ h('summary', null,
2181
+ h('span', null, props.title || ''),
2182
+ props.badge
2183
+ ? h('span', { className: 'dra-badge' }, props.badge)
2184
+ : null,
2185
+ ),
2186
+ h('div', { className: 'dra-fold-b' }, props.children),
2187
+ )
2188
+ }
2189
+
1943
2190
  // ── 节点 ──
1944
2191
  //
1945
2192
  // 空态不能只说"还没有节点接入"(2026-10-20,体验提升 ①)。
@@ -2458,18 +2705,41 @@ window.__ModuleLoader__.load({
2458
2705
  head,
2459
2706
  h('div', { className: 'dra-body' },
2460
2707
  errBox,
2708
+ // ── 首屏只放"当前最需要的"(2026-10-20,UI 改进)─────────────
2709
+ //
2710
+ // 改动前:9 个区块全展开(配置/体检/工具/配对/卡片/节点/共享/
2711
+ // 派发/任务),首屏被淹 —— 用户打开面板先看到一堆当前不需要的东西。
2712
+ //
2713
+ // 改动后:
2714
+ // · **错误与可达性横幅**永远在最上(那是必须立刻知道的)
2715
+ // · 有副机在线 → 先看任务;没副机 → 先看接入
2716
+ // · 配置/体检/工具/共享收进折叠块,需要时再展开
2461
2717
  reachBox,
2462
- cfgSection,
2463
- envSection,
2464
- pairSection,
2465
2718
  cards,
2466
2719
  nodeSection,
2467
- // 工作区共享(2026-10-17):**放在派发表单之前** —— 它是"配好之后
2468
- // 想用本地 IDE 打开副机文件"的进阶需求,不该抢首屏位置,但也不该
2469
- // 埋在任务列表下面。二次确认在组件内部。
2470
- h(SharePanel, { nodes: state.nodes }),
2720
+ // 任务区放在节点之后(有任务时它是主角)
2721
+ taskSection,
2471
2722
  dispatchForm,
2472
- taskSection),
2723
+ // ── 以下收进折叠块:配好之后很少再动 ──────────────────────
2724
+ Fold({
2725
+ title: '接入与配置',
2726
+ open: state.nodes.length === 0,
2727
+ children: h('div', null, cfgSection, pairSection),
2728
+ }),
2729
+ Fold({
2730
+ title: '环境体检与工具',
2731
+ // 有失败项时默认展开 —— 那说明确实需要处理
2732
+ open: Boolean(envCheck && envCheck.failed > 0),
2733
+ badge: envCheck && envCheck.failed > 0 ? String(envCheck.failed) : '',
2734
+ children: h('div', null, envSection, toolsSection),
2735
+ }),
2736
+ // 工作区共享(2026-10-17):**在派发表单之前**,但不抢首屏。
2737
+ // 折叠默认收起 —— 它是"配好之后想用本地 IDE 打开副机文件"的进阶需求。
2738
+ Fold({
2739
+ title: '工作区共享(SMB)',
2740
+ children: h('div', null, h(SharePanel, { nodes: state.nodes })),
2741
+ }),
2742
+ ),
2473
2743
  )
2474
2744
  }
2475
2745
 
package/lib/e2e.js CHANGED
@@ -97,8 +97,49 @@ export function normalizeE2EPub(raw) {
97
97
  return raw
98
98
  }
99
99
 
100
+ /**
101
+ * X25519 PKCS#8 私钥的 DER 前缀(16 字节)。
102
+ *
103
+ * 结构:SEQUENCE { INTEGER 0, SEQUENCE { OID 1.3.101.110 (X25519) },
104
+ * OCTET STRING { OCTET STRING(32 字节原始标量) } }
105
+ * 后接 32 字节标量即完整私钥。
106
+ */
107
+ const X25519_PKCS8_PREFIX = Buffer.from('302e020100300506032b656e04220420', 'hex')
108
+
109
+ /**
110
+ * 由 base64url 的 32 字节标量构造 X25519 私钥。
111
+ *
112
+ * ═══════════════════════════════════════════════════════════════════════════
113
+ * ⚠ 为什么用 DER 而不是 JWK(2026-10-20 CI 抓到的真实缺陷)
114
+ * ═══════════════════════════════════════════════════════════════════════════
115
+ *
116
+ * 原实现:`createPrivateKey({ key: { kty:'OKP', crv:'X25519', d: b64, x: '' }, format:'jwk' })`
117
+ *
118
+ * 在本地 Node 24.18 上能过,但在 GitHub Actions 的 `ubuntu-latest / node 24.x`
119
+ * 上抛 **`ERR_CRYPTO_INVALID_JWK`** —— 那个版本对 JWK 的 `x` 校验更严
120
+ * (空串不是合法 base64url;省略 `x` 同样被拒)。
121
+ *
122
+ * 后果很隐蔽:E2E 握手失败 → 测试只看到
123
+ * `timeout waiting for e2e session on test-worker`,**真因被完全掩盖**。
124
+ *
125
+ * ── 修法 ────────────────────────────────────────────────────────────────
126
+ *
127
+ * 直接用 **DER** 构造:X25519 私钥的 PKCS#8 就是固定 16 字节前缀 + 32 字节
128
+ * 标量。这条路径**不经过 JWK 校验**,跨 Node 小版本稳定。
129
+ *
130
+ * 实测验证:DER 构造出的私钥导出的公钥 `x` 与原始 JWK 完全一致,
131
+ * 且 DH 协商结果与正常生成的密钥对一致。
132
+ */
100
133
  function x2PrivateKeyFromB64(b64) {
101
- return createPrivateKey({ key: { kty: 'OKP', crv: 'X25519', d: b64, x: '' }, format: 'jwk' })
134
+ const raw = Buffer.from(String(b64), 'base64url')
135
+ if (raw.length !== X2_PUB_BYTES) {
136
+ throw new Error(`bad x25519 private key length: ${raw.length} (expect ${X2_PUB_BYTES})`)
137
+ }
138
+ return createPrivateKey({
139
+ key: Buffer.concat([X25519_PKCS8_PREFIX, raw]),
140
+ format: 'der',
141
+ type: 'pkcs8',
142
+ })
102
143
  }
103
144
 
104
145
  function x2PublicKeyFromB64(b64) {
@@ -0,0 +1,238 @@
1
+ /**
2
+ * 外部工具检测(2026-10-20,用户要求的"自动装依赖")。
3
+ *
4
+ * ═══════════════════════════════════════════════════════════════════════════
5
+ * 边界:**检测 + 给命令,不代下载**
6
+ * ═══════════════════════════════════════════════════════════════════════════
7
+ *
8
+ * 用户原话是"自动装依赖",但我们把范围收窄到"**外部工具**",并且
9
+ * **不代下载**。理由(用户此前也确认过这条边界):
10
+ *
11
+ * · 本插件的核心卖点是**零 npm 依赖** —— 代装 npm 包会自毁卖点
12
+ * · 代下载第三方二进制会让我们变成软件分发方:
13
+ * 装哪个版本、从哪下、校验和怎么保证、装失败算谁的、供应链攻击面
14
+ * —— 全落在我们头上
15
+ *
16
+ * 所以本模块做三件事:
17
+ * 1. **探测**这些工具在不在(不执行它们做实际工作,只查版本)
18
+ * 2. **说清缺了会怎样**(用户才能判断要不要装)
19
+ * 3. **给官方下载链接 + 安装命令**(用户自己决定、自己执行)
20
+ *
21
+ * ═══════════════════════════════════════════════════════════════════════════
22
+ * 为什么这些工具值得检测
23
+ * ═══════════════════════════════════════════════════════════════════════════
24
+ *
25
+ * 都是"**缺了会静默失败或体验大幅退化**"的那类:
26
+ *
27
+ * · **Tailscale** —— 跨网场景的唯一现实路径。缺了副机连不上,
28
+ * 而错误表现是"连接超时",用户根本想不到是缺个 VPN 工具。
29
+ * · **PowerShell** —— Windows 上 SMB 共享、部分 exec 任务依赖它。
30
+ * 缺了表现为"命令找不到",很难归因。
31
+ * · **git** —— 仓库感知调度(提案 #13)依赖它。缺了只是少个功能,
32
+ * 但用户不知道为什么 repo 路由不生效。
33
+ * · **Node.js** —— 插件本身跑在 DSH 里(DSH 自带),但副机侧某些
34
+ * headless 场景要独立的 node。缺了报错很难懂。
35
+ *
36
+ * ═══════════════════════════════════════════════════════════════════════════
37
+ * 探测方式:只查版本,不做事
38
+ * ═══════════════════════════════════════════════════════════════════════════
39
+ *
40
+ * 每个工具跑 `<tool> --version`(或等价),超时 3 秒。
41
+ * **不传任何用户输入**,命令是写死的常量 —— 没有注入面。
42
+ */
43
+
44
+ import { spawnSync } from 'node:child_process'
45
+
46
+ /**
47
+ * 工具清单。
48
+ *
49
+ * 字段说明:
50
+ * · `required`:缺了核心功能不可用(true)还是只是功能退化(false)
51
+ * · `why`:缺了会怎样 —— 用户据此判断要不要装
52
+ * · `install`:**给用户复制粘贴的命令**(我们不执行它)
53
+ * · `url`:官方下载页
54
+ */
55
+ const TOOLS = Object.freeze([
56
+ {
57
+ id: 'tailscale',
58
+ name: 'Tailscale',
59
+ // 探测命令:`tailscale version` 输出形如 `1.62.0\n go1.21`
60
+ probe: { cmd: 'tailscale', args: ['version'], timeoutMs: 3000 },
61
+ required: false,
62
+ why: '跨网(不在同一局域网)时的推荐路径。缺了副机连不上,'
63
+ + '而错误表现只是"连接超时",很难想到是缺个 VPN 工具。'
64
+ + '同一局域网可以不用它。',
65
+ install: {
66
+ win: 'winget install --id Tailscale.Tailscale -e',
67
+ mac: 'brew install --cask tailscale',
68
+ linux: 'curl -fsSL https://tailscale.com/install.sh | sh',
69
+ },
70
+ url: 'https://tailscale.com/download',
71
+ },
72
+ {
73
+ id: 'powershell',
74
+ name: 'PowerShell',
75
+ // Windows 上叫 powershell / pwsh;非 Windows 上可能没有
76
+ probe: { cmd: process.platform === 'win32' ? 'powershell' : 'pwsh', args: ['-NoProfile', '-Command', '$PSVersionTable.PSVersion.Major'], timeoutMs: 4000 },
77
+ required: process.platform === 'win32',
78
+ why: 'Windows 上的 SMB 共享、部分 exec 任务依赖它。缺了表现为"命令找不到",很难归因。',
79
+ install: {
80
+ win: '(Windows 自带;若缺失说明系统组件被裁剪)',
81
+ mac: 'brew install --cask powershell',
82
+ linux: 'https://learn.microsoft.com/powershell/scripting/install/installing-powershell',
83
+ },
84
+ url: 'https://learn.microsoft.com/powershell/scripting/install/installing-powershell',
85
+ },
86
+ {
87
+ id: 'git',
88
+ name: 'git',
89
+ probe: { cmd: 'git', args: ['--version'], timeoutMs: 3000 },
90
+ required: false,
91
+ why: '仓库感知调度(remote_task 的 repo 参数)依赖它扫描副机上的仓库。'
92
+ + '缺了只是少这个功能,但用户不知道为什么 repo 路由不生效。',
93
+ install: {
94
+ win: 'winget install --id Git.Git -e',
95
+ mac: 'brew install git',
96
+ linux: 'sudo apt install git # 或 dnf / pacman',
97
+ },
98
+ url: 'https://git-scm.com/downloads',
99
+ },
100
+ {
101
+ id: 'node',
102
+ name: 'Node.js',
103
+ probe: { cmd: 'node', args: ['--version'], timeoutMs: 3000 },
104
+ required: false,
105
+ why: '插件本身跑在 DSH 里(DSH 自带 node),但副机的 headless 后端'
106
+ + '要一个独立的 node 才能 spawn 子进程。缺了 agent 模式会退化。',
107
+ install: {
108
+ win: 'winget install --id OpenJS.NodeJS.LTS -e',
109
+ mac: 'brew install node@22',
110
+ linux: 'https://nodejs.org/en/download',
111
+ },
112
+ url: 'https://nodejs.org/en/download',
113
+ },
114
+ ])
115
+
116
+ /** 当前平台的安装命令键。 */
117
+ function platformKey() {
118
+ if (process.platform === 'win32') return 'win'
119
+ if (process.platform === 'darwin') return 'mac'
120
+ return 'linux'
121
+ }
122
+
123
+ /**
124
+ * 探测单个工具。
125
+ *
126
+ * @returns {{id:string, name:string, installed:boolean, version:string, error:string}}
127
+ */
128
+ function probeTool(tool) {
129
+ const base = { id: tool.id, name: tool.name, installed: false, version: '', error: '' }
130
+ try {
131
+ const r = spawnSync(tool.probe.cmd, tool.probe.args, {
132
+ encoding: 'utf8',
133
+ windowsHide: true,
134
+ timeout: tool.probe.timeoutMs,
135
+ // 不继承用户 shell 的 alias / 函数 —— 只跑真实可执行文件
136
+ shell: false,
137
+ })
138
+ if (r.error) {
139
+ // ENOENT = 没装,这是最常见的情形,不算"错误"
140
+ base.error = r.error.code === 'ENOENT' ? '' : String(r.error.message ?? r.error)
141
+ return base
142
+ }
143
+ if (r.status !== 0) {
144
+ base.error = `退出码 ${r.status}`
145
+ return base
146
+ }
147
+ const out = String(r.stdout ?? '').trim()
148
+ if (out === '') {
149
+ base.error = '没有输出'
150
+ return base
151
+ }
152
+ base.installed = true
153
+ // 只取第一行、截断 —— 版本串不需要整段
154
+ base.version = out.split(/\r?\n/)[0].slice(0, 60)
155
+ return base
156
+ } catch (err) {
157
+ base.error = String(err?.message ?? err)
158
+ return base
159
+ }
160
+ }
161
+
162
+ /**
163
+ * 探测所有外部工具。
164
+ *
165
+ * @param {object} [o]
166
+ * @param {boolean} [o.onlyMissing] 只返回缺失的(面板用,省流量)
167
+ * @returns {{platform:string, tools:Array, missing:Array, requiredMissing:Array}}
168
+ */
169
+ export function probeExternalTools({ onlyMissing = false } = {}) {
170
+ const pk = platformKey()
171
+ const tools = []
172
+ for (const t of TOOLS) {
173
+ const p = probeTool(t)
174
+ const entry = {
175
+ id: p.id,
176
+ name: p.name,
177
+ installed: p.installed,
178
+ version: p.version,
179
+ required: t.required,
180
+ // 缺了会怎样 —— 这是用户判断"要不要装"的唯一依据
181
+ why: t.why,
182
+ // 安装命令(**给用户复制,我们不执行**)
183
+ install: t.install[pk] ?? '',
184
+ url: t.url,
185
+ error: p.error,
186
+ }
187
+ if (onlyMissing && entry.installed) continue
188
+ tools.push(entry)
189
+ }
190
+ const missing = tools.filter((t) => !t.installed)
191
+ return {
192
+ platform: process.platform,
193
+ tools,
194
+ missing,
195
+ requiredMissing: missing.filter((t) => t.required),
196
+ }
197
+ }
198
+
199
+ /**
200
+ * 把探测结果渲染成人读文本(命令行用)。
201
+ */
202
+ export function renderExternalTools(result) {
203
+ const lines = ['外部工具检测', '']
204
+ if (!result || !Array.isArray(result.tools) || result.tools.length === 0) {
205
+ lines.push('(未探测到任何工具)')
206
+ return lines.join('\n')
207
+ }
208
+ for (const t of result.tools) {
209
+ const mark = t.installed ? '✓' : (t.required ? '✗' : '⚠')
210
+ const ver = t.installed && t.version !== '' ? ` ${t.version}` : ''
211
+ lines.push(`${mark} ${t.name}${ver}`)
212
+ if (!t.installed) {
213
+ if (t.why) lines.push(` ${t.why}`)
214
+ if (t.install) lines.push(` 安装:${t.install}`)
215
+ if (t.url) lines.push(` 下载:${t.url}`)
216
+ }
217
+ }
218
+ lines.push('')
219
+ if (result.requiredMissing.length === 0) {
220
+ lines.push('必需工具都在。')
221
+ } else {
222
+ lines.push(`缺少 ${result.requiredMissing.length} 个必需工具:${result.requiredMissing.map((t) => t.name).join(', ')}`)
223
+ }
224
+ return lines.join('\n')
225
+ }
226
+
227
+ /** 导出清单(测试与面板共用,避免两处写死)。 */
228
+ export function toolCatalog() {
229
+ const pk = platformKey()
230
+ return TOOLS.map((t) => ({
231
+ id: t.id,
232
+ name: t.name,
233
+ required: t.required,
234
+ why: t.why,
235
+ install: t.install[pk] ?? '',
236
+ url: t.url,
237
+ }))
238
+ }