dsh-agentone 0.5.22 → 0.5.23

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/index.js CHANGED
@@ -34,7 +34,7 @@ import { applyPlatformSkillhub, removePlatformSkillhub } from './skillhub.js';
34
34
  import { beginCliAuth, cliAuthQrcode, completeCliAuth, parseCliAuthBeginOutput } from './cliauth.mjs';
35
35
  import { platformFetch } from './http.js';
36
36
  import { donePageHtml, statusPageHtml } from './page.js';
37
- import { compareSemver, dshPluginCli, firstErrorLines, installCliTool, isUnexpectedStoreError, probeCliDetail, pnpmViewLatest, runCliTool, unexpectedStoreGuidance, upgradeCliTool } from './proc.js';
37
+ import { compareSemver, dshPluginCli, firstErrorLines, installCliTool, isUnexpectedStoreError, probeCliDetail, probePythonEnv, pnpmViewLatest, runCliTool, unexpectedStoreGuidance, upgradeCliTool } from './proc.js';
38
38
 
39
39
  import { clearCredentials, loadCredentials, resolveDshHome, saveCredentials, updateCredentials } from './store.js';
40
40
 
@@ -370,6 +370,21 @@ function specLabel(spec) {
370
370
 
371
371
  /** dsh 插件与 CLI 操作白名单:用户输入不得进入 execFile 的 tool 位。 */
372
372
  const CLI_TOOLS = new Set(['lark-cli', 'ccpg-cli']);
373
+ /** 除 CLI 外可通过 /agentone/api/cli/install 自助安装的工具(python 无
374
+ * 授权/升级概念,仅安装)。 */
375
+ const CLI_INSTALL_TOOLS = new Set(['python']);
376
+
377
+ /** CLI 详情防降级:latest 不高于已装版本时置 null,前端不出「升级」
378
+ * 按钮(ccpg 媒体通道可能比 update 通道发得更新,实测装到 1.2.16 时
379
+ * update --check 的 latest 还是 1.2.15;与插件列表 1123/1200 行同口径)。 */
380
+ function withCliUpgradeGuard(detail) {
381
+ if (!detail || typeof detail !== 'object') return detail;
382
+ const { version, latest } = detail;
383
+ return {
384
+ ...detail,
385
+ latest: latest && version && compareSemver(latest, version) > 0 ? latest : null,
386
+ };
387
+ }
373
388
 
374
389
  /**
375
390
  * 插件自身是否由平台托管镜像管理:镜像 Dockerfile 固有 ENV
@@ -1211,26 +1226,12 @@ export function apply(ctx, config) {
1211
1226
  // 真实探测本机 CLI:三态与平台 cli-deployd 判定口径一致,附带版本
1212
1227
  // 与最新版(查询失败降级 null,不阻塞三态)。并发探测,单个失败不影
1213
1228
  // 响另一个。附带 Python 环境探测(技能脚本标准依赖,Windows GUI 启动
1214
- // 时 PATH 常缺 python,缺失时前端给安装指引)。
1215
- const pythonCandidates = process.platform === 'win32'
1216
- ? ['python', 'python3', 'py']
1217
- : ['python3', 'python'];
1218
- const probePython = async () => {
1219
- for (const name of pythonCandidates) {
1220
- try {
1221
- const result = await runCliTool(name, ['--version'], { timeoutMs: 10_000 });
1222
- const match = /(\d+\.\d+(?:\.\d+)?)/.exec(`${result.stdout || ''}${result.stderr || ''}`);
1223
- if (match) return { available: true, command: name, version: match[1] };
1224
- } catch {
1225
- // 未安装或 Store 假别名:试下一个候选
1226
- }
1227
- }
1228
- return { available: false, command: null, version: null };
1229
- };
1229
+ // 时 PATH 常缺 python 或前段是 Store 假别名,probePythonEnv 逐个候选
1230
+ // 穿透重试),缺失时前端给安装按钮/指引。
1230
1231
  const [lark, ccpg, python] = await Promise.all([
1231
- probeCliDetail('lark-cli').catch(() => ({ state: 'missing', version: null, latest: null })),
1232
- probeCliDetail('ccpg-cli').catch(() => ({ state: 'missing', version: null, latest: null })),
1233
- probePython().catch(() => ({ available: false, command: null, version: null })),
1232
+ probeCliDetail('lark-cli').then(withCliUpgradeGuard).catch(() => ({ state: 'missing', version: null, latest: null })),
1233
+ probeCliDetail('ccpg-cli').then(withCliUpgradeGuard).catch(() => ({ state: 'missing', version: null, latest: null })),
1234
+ probePythonEnv().catch(() => ({ available: false, command: null, version: null })),
1234
1235
  ]);
1235
1236
  sendJson(response, 200, { tools: { 'lark-cli': lark, 'ccpg-cli': ccpg }, python });
1236
1237
  });
@@ -1244,15 +1245,18 @@ export function apply(ctx, config) {
1244
1245
  try {
1245
1246
  const body = await readJsonBody(request);
1246
1247
  const tool = String(body.tool || '');
1247
- if (!CLI_TOOLS.has(tool)) {
1248
+ if (!CLI_TOOLS.has(tool) && !CLI_INSTALL_TOOLS.has(tool)) {
1248
1249
  sendJson(response, 400, { error: '不支持的 CLI 工具' });
1249
1250
  return;
1250
1251
  }
1251
- // 自助安装(lark 走 npm、ccpg 走官方 auto-install.sh),与升级一样
1252
- // 分钟级耗时:输出尾部透传给前端展示失败原因
1252
+ // 自助安装(lark 走 npm、ccpg 走官方安装器、python 走 python.org
1253
+ // 官方安装器静默装),与升级一样分钟级耗时:输出尾部透传给前端展示
1253
1254
  const output = await installCliTool(tool);
1254
- const detail = await probeCliDetail(tool).catch(() => null);
1255
- sendJson(response, 200, { ok: true, output, tool: detail });
1255
+ // python 无三态概念,回传探测详情供前端就地刷新
1256
+ const detail = tool === 'python'
1257
+ ? await probePythonEnv().catch(() => null)
1258
+ : await probeCliDetail(tool).then(withCliUpgradeGuard).catch(() => null);
1259
+ sendJson(response, 200, { ok: true, output, tool: detail, python: tool === 'python' ? detail : undefined });
1256
1260
  } catch (error) {
1257
1261
  sendJson(response, 502, { error: error?.message || 'CLI 安装失败,请稍后重试' });
1258
1262
  }
@@ -1274,7 +1278,7 @@ export function apply(ctx, config) {
1274
1278
  // 官方自升级(ccpg 下载二进制可达分钟级),输出尾部透传给前端
1275
1279
  // (lark-cli 手动安装时含 GitHub 下载地址)
1276
1280
  const output = await upgradeCliTool(tool);
1277
- const detail = await probeCliDetail(tool).catch(() => null);
1281
+ const detail = await probeCliDetail(tool).then(withCliUpgradeGuard).catch(() => null);
1278
1282
  sendJson(response, 200, { ok: true, output, tool: detail });
1279
1283
  } catch (error) {
1280
1284
  sendJson(response, 502, { error: error?.message || 'CLI 升级失败,请稍后重试' });
@@ -1298,7 +1302,7 @@ export function apply(ctx, config) {
1298
1302
  sendJson(response, 400, { error: '不支持的 CLI 工具' });
1299
1303
  return;
1300
1304
  }
1301
- const detail = await probeCliDetail(tool).catch(() => null);
1305
+ const detail = await probeCliDetail(tool).then(withCliUpgradeGuard).catch(() => null);
1302
1306
  if (detail?.state === 'missing') {
1303
1307
  sendJson(response, 400, { error: 'CLI 未安装,请先安装或联系管理员' });
1304
1308
  return;
@@ -1348,7 +1352,7 @@ export function apply(ctx, config) {
1348
1352
  // lark-cli 同时轮同一码。这里 25s 一掐,前端串行重试(见 page.js)。
1349
1353
  await completeCliAuth(tool, deviceCode, { timeoutMs: 25_000 });
1350
1354
  pendingCliAuth.delete(tool);
1351
- const detail = await probeCliDetail(tool).catch(() => null);
1355
+ const detail = await probeCliDetail(tool).then(withCliUpgradeGuard).catch(() => null);
1352
1356
  sendJson(response, 200, { ok: true, tool: detail });
1353
1357
  } catch (error) {
1354
1358
  // 超时=我们主动掐断轮询(用户还没在授权页同意):保留会话,202
package/lib/page.js CHANGED
@@ -526,7 +526,8 @@ const renderCliData = (s, tools, python) => {
526
526
  + esc(name) + status + '</div><div class="skill-sub">'
527
527
  + versionLine + '</div></div><div style="display:flex;gap:6px">' + actions + '</div></div>';
528
528
  });
529
- // Python 环境行:技能脚本的标准依赖,缺失时按平台给安装指引
529
+ // Python 环境行:技能脚本的标准依赖。Windows 未装时可一键安装
530
+ // (python.org 官方安装器静默装用户目录),其他平台给包管理器指引。
530
531
  const py = renderCli._python;
531
532
  const isWin = navigator.platform && /win/i.test(navigator.platform);
532
533
  const pythonRow = py
@@ -538,13 +539,17 @@ const renderCliData = (s, tools, python) => {
538
539
  + (py.available
539
540
  ? '技能脚本运行环境 · ' + esc(py.command) + ' · v' + esc(py.version)
540
541
  : isWin
541
- ? '技能脚本运行环境 · 未检测到 Python,从 python.org 下载安装(勾选 Add to PATH)后重启 DSH Desktop'
542
+ ? '技能脚本运行环境 · 未检测到 Python(技能脚本需要 Python 3),点右侧「安装」自动装官方版本(python.org)'
542
543
  : '技能脚本运行环境 · 未检测到 Python,可用系统包管理器安装(如 brew install python)后重启 dsh')
543
- + '</div></div></div>'
544
+ + '</div></div>'
545
+ + (!py.available && isWin
546
+ ? '<div style="display:flex;gap:6px"><button class="btn small primary" data-cli-install="python">安装</button></div>'
547
+ : '')
548
+ + '</div>'
544
549
  : '';
545
550
  body.innerHTML = rows.join('') + pythonRow
546
551
  + '<div class="hint">在对话中直接使用(如 /lark 帮助),由 dsh 自动调用。</div>'
547
- + '<div class="hint">未安装时点「安装」自助安装;已安装未授权时点「去授权」用飞书扫码完成即可。</div>';
552
+ + '<div class="hint">未安装时点「安装」自助安装(Python 同样支持);已安装未授权时点「去授权」用飞书扫码完成即可。</div>';
548
553
  body.querySelectorAll('[data-cli-upgrade]').forEach((btn) => {
549
554
  btn.onclick = () => cliUpgrade(btn.dataset.cliUpgrade, btn);
550
555
  });
@@ -667,8 +672,8 @@ const cliAuthDialog = async (tool) => {
667
672
  }
668
673
  };
669
674
 
670
- /** CLI 一键安装:lark 走 npm 全局安装、ccpg 走官方安装脚本,耗时可达
671
- * 分钟级。响应自带安装后立即探测的单工具详情,直接合进缓存刷新界面。 */
675
+ /** CLI 一键安装:lark 走 npm 全局安装、ccpg/python 走官方安装器,耗时
676
+ * 可达分钟级。响应自带安装后立即探测的详情,直接合进缓存刷新界面。 */
672
677
  const cliInstall = async (tool, btn) => {
673
678
  btn.disabled = true;
674
679
  btn.textContent = '安装中,可能需要 1–3 分钟…';
@@ -681,7 +686,11 @@ const cliInstall = async (tool, btn) => {
681
686
  const data = await resp.json().catch(() => ({}));
682
687
  if (!resp.ok) throw new Error(data.error || 'CLI 安装失败');
683
688
  renderCli._probedAt = 0;
684
- if (data.tool) {
689
+ if (tool === 'python') {
690
+ // python 无三态:回传 {available,command,version},重绘 Python 行
691
+ if (data.python) renderCliData(lastStatus, {}, data.python);
692
+ else renderCli(lastStatus);
693
+ } else if (data.tool) {
685
694
  renderCliData(lastStatus, { [tool]: data.tool });
686
695
  } else {
687
696
  renderCli(lastStatus);
package/lib/proc.js CHANGED
@@ -2,12 +2,13 @@
2
2
  //
3
3
  // 官方 `dsh plugin --profile web <args>` 内部就是 pnpm 转发 + profile
4
4
  // 初始化 + dsh.profile.bundles reconcile,全部复用。这里只封装进程执行
5
- // (execFile 参数数组、绝不经过 shell)与错误翻译;包名/版本白名单在
6
- // index.js 的调用侧执行。
5
+ // (execFile 参数数组,不经 shell 拼接;win32 的 .cmd/.bat shim 是唯一
6
+ // 例外——按 CVE-2024-27980 后的 Node 约定经 cmd.exe /d /s /c 执行,
7
+ // 参数仍是数组形态)与错误翻译;包名/版本白名单在 index.js 的调用侧执行。
7
8
  import { execFile } from 'node:child_process';
8
- import { existsSync, readdirSync } from 'node:fs';
9
- import { homedir } from 'node:os';
10
- import { join, dirname, delimiter as pathDelimiter } from 'node:path';
9
+ import { existsSync, mkdirSync, readdirSync, renameSync } from 'node:fs';
10
+ import { homedir, tmpdir } from 'node:os';
11
+ import { join, isAbsolute, delimiter as pathDelimiter } from 'node:path';
11
12
  import { promisify } from 'node:util';
12
13
 
13
14
  const execFileAsync = promisify(execFile);
@@ -26,9 +27,52 @@ function extraBinDirs() {
26
27
  if (process.platform === 'win32') {
27
28
  const appData = process.env.APPDATA || join(home, 'AppData', 'Roaming');
28
29
  const localAppData = process.env.LOCALAPPDATA || join(home, 'AppData', 'Local');
29
- // npm -g 默认 prefix 与 pnpm 默认 PNPM_HOME;nodejs.org 安装器的
30
- // 系统级安装不在这,但那种环境 PATH 里有 node,不需要兑底。
31
- dirs.push(join(appData, 'npm'), join(localAppData, 'pnpm'));
30
+ const systemRoot = process.env.SystemRoot || 'C:\\Windows';
31
+ const programFiles = process.env.ProgramFiles || 'C:\\Program Files';
32
+ const programFilesX86 = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)';
33
+ // Windows 系统目录:cmd.exe / curl.exe / powershell 等系统自带命令的
34
+ // 落地位——GUI 宿主 PATH 贫瘠时连 cmd.exe 都 resolve 不到,.cmd shim
35
+ // 的路由与 curl 下载全部瘫痪,必须兑底。
36
+ dirs.push(
37
+ join(systemRoot, 'System32'),
38
+ systemRoot,
39
+ join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0'),
40
+ );
41
+ // nodejs.org 官方安装器默认系统级安装位(机器级 Program Files;
42
+ // 机器上没有 node.exe 时不会注入 PATH,但兑底目录里如果找到了,
43
+ // 就能解析出 node/npm,不用再要求用户装 Node)。
44
+ dirs.push(join(programFiles, 'nodejs'), join(programFilesX86, 'nodejs'));
45
+ // npm -g 默认 prefix、pnpm 默认 PNPM_HOME,以及 ccpg-cli Windows 官方
46
+ // 安装器的落点 %USERPROFILE%\bin(本机实测 C:\Users\<user>\bin\
47
+ // ccpg-cli.exe;装完不重启也能立即探测/自升级)。nodejs.org 安装器的
48
+ // 系统级安装不在这些目录,但那种环境 PATH 里有 node,不需要兑底。
49
+ dirs.push(join(appData, 'npm'), join(localAppData, 'pnpm'), join(home, 'bin'));
50
+ // Python 常见安装位(python 探测兑底):python.org 每用户安装位
51
+ // %LOCALAPPDATA%\Programs\Python\Python3xx、机器级 Program Files\
52
+ // Python3xx、Miniconda/Anaconda 默认位。自定义安装位(如
53
+ // E:\VScode\miniconda3)在 PATH 里,不受影响。
54
+ try {
55
+ const pyRoot = join(localAppData, 'Programs', 'Python');
56
+ for (const entry of readdirSync(pyRoot)) {
57
+ if (existsSync(join(pyRoot, entry, 'python.exe'))) dirs.push(join(pyRoot, entry));
58
+ }
59
+ } catch {
60
+ // 未装 python.org 版:跳过
61
+ }
62
+ try {
63
+ for (const entry of readdirSync(programFiles)) {
64
+ if (/^Python\d+/.test(entry) && existsSync(join(programFiles, entry, 'python.exe'))) {
65
+ dirs.push(join(programFiles, entry));
66
+ }
67
+ }
68
+ } catch {
69
+ // 未装机器级 python.org 版:跳过
70
+ }
71
+ // Miniconda/Anaconda 常见默认位:用户目录与 ProgramData(机器级共享)
72
+ dirs.push(
73
+ join(home, 'miniconda3'), join(home, 'anaconda3'),
74
+ join('C:\\', 'ProgramData', 'miniconda3'), join('C:\\', 'ProgramData', 'Anaconda3'),
75
+ );
32
76
  } else {
33
77
  try {
34
78
  // nvm 目录名形如 v24.18.0,用 compareSemver 取最高版(compareSemver
@@ -71,17 +115,76 @@ function extraBinDirs() {
71
115
  return dirs;
72
116
  }
73
117
 
118
+ /** 安装完成后清空兑底目录缓存:一键安装 python/ccpg 后新目录要立即可
119
+ * 探测(python.org 装到 %LOCALAPPDATA%\Programs\Python\Python3xx),不
120
+ * 能等 DSH Desktop 重启。 */
121
+ export function resetExtraBinDirsCache() {
122
+ extraBinDirsCache = null;
123
+ }
124
+
125
+ /** 候选扩展名顺序(纯函数,供测试):win32 下 CreateProcess 只认可执行
126
+ * 映像,npm/pnpm/lark-cli 的安装目录同时存在同名的无扩展名 sh 脚本
127
+ * (给 Git Bash 用)与 .cmd shim——无扩展名必须排最后,否则解析命中 sh
128
+ * 脚本,execFile 直接失败。 */
129
+ export function candidateSuffixes(platform) {
130
+ return platform === 'win32' ? ['.exe', '.cmd', ''] : [''];
131
+ }
132
+
133
+ /** win32 下 .cmd/.bat shim 的 execFile 等价调用形态(纯函数,供测试):
134
+ * Node ≥ 18.20/20.12(CVE-2024-27980 修复,DSH Desktop 内置 Node 同样
135
+ * 受影响)起 execFile 直接执行 .cmd/.bat 会同步抛 spawn EINVAL,必须经
136
+ * cmd.exe /d /s /c 解释。参数仍走数组、由 Node 按 Windows 规则引号转义,
137
+ * 不拼接用户输入。非 win32 或非 shim 返回 null。 */
138
+ export function cmdShimInvocation(platform, resolved, args) {
139
+ if (platform !== 'win32') return null;
140
+ if (!/\.(cmd|bat)$/i.test(resolved)) return null;
141
+ return { file: 'cmd.exe', args: ['/d', '/s', '/c', resolved, ...args] };
142
+ }
143
+
144
+ /** PATH 环境变量键名(纯函数,供测试):win32 下实际键通常是 Path,注入
145
+ * 时必须复用已有键,否则 env 里出现 Path+PATH 两个重复键,子进程读到
146
+ * 哪个取决于实现。 */
147
+ export function pathEnvKey(env, platform) {
148
+ if (platform !== 'win32') return 'PATH';
149
+ return Object.keys(env).find((key) => /^path$/i.test(key)) || 'PATH';
150
+ }
151
+
74
152
  /** 命令解析:PATH 里找不到时从兑底目录解析绝对路径(找不到返回 null)。
75
153
  * 所有 execFile 调用统一过这里;返回 null 时调用方走「未找到命令」的
76
- * 中文指引分支。 */
154
+ * 中文指引分支。绝对路径原样返回(ccpg 安装器等)。 */
77
155
  export function resolveCommandOrNull(tool) {
156
+ if (isAbsolute(tool)) return existsSync(tool) ? tool : null;
78
157
  const pathDirs = String(process.env.PATH || '').split(pathDelimiter).filter(Boolean);
79
- const candidates = [];
158
+ const suffixes = candidateSuffixes(process.platform);
80
159
  for (const dir of [...pathDirs, ...extraBinDirs()]) {
81
- candidates.push(join(dir, tool));
82
- if (process.platform === 'win32') candidates.push(join(dir, `${tool}.exe`), join(dir, `${tool}.cmd`));
160
+ for (const suffix of suffixes) {
161
+ const candidate = join(dir, tool + suffix);
162
+ if (existsSync(candidate)) return candidate;
163
+ }
83
164
  }
84
- return candidates.find((p) => existsSync(p)) || null;
165
+ return null;
166
+ }
167
+
168
+ /** 与 resolveCommandOrNull 同口径,但返回全部按序去重候选。python 这类
169
+ * 探测必须逐个候选试:Windows PATH 前段常是 Microsoft Store 的假别名
170
+ * (0 字节重解析点,执行退出码 9009),真正的解释器(miniconda /
171
+ * python.org)排在更后面——只试首个命中会误报未安装。 */
172
+ export function resolveCommandCandidates(tool) {
173
+ if (isAbsolute(tool)) return existsSync(tool) ? [tool] : [];
174
+ const seen = new Set();
175
+ const out = [];
176
+ const pathDirs = String(process.env.PATH || '').split(pathDelimiter).filter(Boolean);
177
+ const suffixes = candidateSuffixes(process.platform);
178
+ for (const dir of [...pathDirs, ...extraBinDirs()]) {
179
+ for (const suffix of suffixes) {
180
+ const candidate = join(dir, tool + suffix);
181
+ if (existsSync(candidate) && !seen.has(candidate)) {
182
+ seen.add(candidate);
183
+ out.push(candidate);
184
+ }
185
+ }
186
+ }
187
+ return out;
85
188
  }
86
189
 
87
190
  /** 兼容旧签名:找不到时返回原名(后续 ENOENT 走既有 missing/降级语义)。 */
@@ -89,16 +192,22 @@ export function resolveCommand(tool) {
89
192
  return resolveCommandOrNull(tool) || tool;
90
193
  }
91
194
 
92
- /** 统一的子进程执行:解析命令路径,并把解析到的目录前置进子进程 PATH
93
- * (pnpm/lark-cli 等是 `#!/usr/bin/env node` 脚本,宿主 PATH 贫瘠时
94
- * 找得到脚本也找不到 node,必须连带注入)。opts.env 只传增量变量。 */
195
+ /** 统一的子进程执行:解析命令路径,并把**所有兑底目录**前置进子进程
196
+ * PATH——win32 下 npm/lark-cli 是 .cmd shim(`%~dp0\node.exe` 找 node),
197
+ * node 可能在另一个兑底目录(nodejs.org 系统安装位);.cmd 又经
198
+ * cmd.exe /d /s /c 路由,cmd.exe 在第三个兑底目录(System32)。只前置
199
+ * 解析到的那个目录会让 shim 内部找不到依赖,必须整组前置。opts.env 只
200
+ * 传增量变量。 */
95
201
  function execResolved(tool, args, { timeoutMs, cwd, env } = {}) {
96
202
  const resolved = resolveCommand(tool);
97
203
  const childEnv = { ...process.env, ...(env || {}) };
98
- if (resolved !== tool) {
99
- childEnv.PATH = [dirname(resolved), childEnv.PATH || ''].filter(Boolean).join(pathDelimiter);
204
+ const key = pathEnvKey(childEnv, process.platform);
205
+ const extras = extraBinDirs().filter((dir) => existsSync(dir));
206
+ if (extras.length) {
207
+ childEnv[key] = [...extras, childEnv[key] || ''].filter(Boolean).join(pathDelimiter);
100
208
  }
101
- return execFileAsync(resolved, args, {
209
+ const shim = cmdShimInvocation(process.platform, resolved, args);
210
+ return execFileAsync(shim ? shim.file : resolved, shim ? shim.args : args, {
102
211
  timeout: timeoutMs,
103
212
  killSignal: 'SIGKILL',
104
213
  cwd,
@@ -174,6 +283,11 @@ function missingCommandGuidance(tool) {
174
283
  if (tool === 'dsh') {
175
284
  return '未找到 dsh 命令,请确认已安装 dsh 并在终端可用';
176
285
  }
286
+ if (tool === 'python' || tool === 'python3' || tool === 'py') {
287
+ return process.platform === 'win32'
288
+ ? '未检测到 Python:技能脚本需要 Python 3 运行环境,点「安装」自动装官方版本(python.org),或自行从 https://www.python.org 下载安装(勾选 Add to PATH)后重启 DSH Desktop'
289
+ : '未检测到 Python:技能脚本需要 Python 3 运行环境,请用系统包管理器安装(如 brew install python / apt install python3)后重启 dsh';
290
+ }
177
291
  return `未找到 ${tool} 命令,请确认已安装`;
178
292
  }
179
293
 
@@ -326,6 +440,31 @@ async function cliLatestVersion(tool, timeoutMs) {
326
440
  return null;
327
441
  }
328
442
 
443
+ /**
444
+ * Python 环境探测(技能脚本的标准依赖)。win32 优先官方 py 启动器,再
445
+ * python / python3;每个名字按 resolveCommandCandidates 逐个候选试
446
+ * `--version`(穿透 Store 假别名)。Python 2.x 不算可用——技能脚本需要
447
+ * 3.x,继续找下一个候选。与平台 instance_service 的口径一致:探测失败
448
+ * 不抛错,返回 available:false 由前端给安装指引/按钮。
449
+ */
450
+ export async function probePythonEnv({ timeoutMs = 15_000 } = {}) {
451
+ const names = process.platform === 'win32' ? ['py', 'python', 'python3'] : ['python3', 'python'];
452
+ for (const name of names) {
453
+ for (const candidate of resolveCommandCandidates(name)) {
454
+ try {
455
+ const { stdout, stderr } = await runCliTool(candidate, ['--version'], { timeoutMs });
456
+ const match = /(\d+)\.(\d+)(?:\.(\d+))?/.exec(`${stdout || ''}${stderr || ''}`);
457
+ if (match && Number(match[1]) >= 3) {
458
+ return { available: true, command: name, version: match[0] };
459
+ }
460
+ } catch {
461
+ // Store 假别名(9009)/ 不可执行 / 超时:试下一个候选
462
+ }
463
+ }
464
+ }
465
+ return { available: false, command: null, version: null };
466
+ }
467
+
329
468
  /** ccpg-cli 官方安装脚本地址(与平台侧 cli-deployd-go InstallCCPGCLI 同源,
330
469
  * 保持两端口径一致;auto-install.sh 自带二进制下载与 PATH 写入)。 */
331
470
  const CCPG_CLI_INSTALL_URL_PREFIX = 'http://bms.einwin.com/media/ccpg-cli/202605091513';
@@ -360,7 +499,7 @@ async function installLarkSkills({ timeoutMs = 180_000 } = {}) {
360
499
  * cli-deployd-go 的 install 口径一致。
361
500
  * 返回输出尾部给前端展示。只接受 CLI_TOOLS 白名单内的 tool。
362
501
  */
363
- export async function installCliTool(tool, { timeoutMs = 300_000 } = {}) {
502
+ export async function installCliTool(tool, { timeoutMs = 600_000 } = {}) {
364
503
  let stdout;
365
504
  if (tool === 'lark-cli') {
366
505
  // 预检+降级链:优先独立 npm;没有 npm 但有 pnpm(含 DSH Desktop 自带
@@ -399,6 +538,12 @@ export async function installCliTool(tool, { timeoutMs = 300_000 } = {}) {
399
538
  env: { CCPG_CLI_URL_PREFIX: CCPG_CLI_INSTALL_URL_PREFIX },
400
539
  }));
401
540
  }
541
+ resetExtraBinDirsCache();
542
+ } else if (tool === 'python') {
543
+ if (process.platform !== 'win32') {
544
+ throw new Error('请用系统包管理器安装 Python(如 brew install python / apt install python3)后重启 dsh');
545
+ }
546
+ ({ stdout } = await installPythonWindows(timeoutMs));
402
547
  } else {
403
548
  throw new Error('不支持的 CLI 工具');
404
549
  }
@@ -406,44 +551,154 @@ export async function installCliTool(tool, { timeoutMs = 300_000 } = {}) {
406
551
  return lines.slice(-6).join('\n');
407
552
  }
408
553
 
409
- /** ccpg-cli Windows 安装(官方教程口径,202607151817):
410
- * 1) Windows 10 1803+ 自带 curl.exe 下载官方自安装 exe
411
- * (https://bms.einwin.com/media/ccpg-cli/202607151817/ccpg-cli-windows-amd64.exe)
412
- * 2) 静默运行安装(/S 安静模式,装到用户目录并注册 PATH)
413
- * 3) 教程要求装完必须 ccpg-cli update(拉最新功能)——自动补上。
554
+ /** ccpg-cli Windows 安装(与 mac/Linux auto-install.sh 同构:裸二进制落地):
555
+ * 1) 动态查询官方更新接口拿当前二进制地址——媒体目录按日期滚动,pin 死
556
+ * 的旧地址会被服务器清理(实测教程里的 202607151817 已 404);接口与
557
+ * ccpg-cli 自身 updater 同源,永远指向当前有效版本。接口返回的 URL
558
+ * 必须落在 bms.einwin.com/media/ccpg-cli/ 前缀下才接受(防接口被篡
559
+ * 改指向恶意地址),接口异常时兜底最近一次验证过的 pin 值。
560
+ * 注意:接口给的是**裸二进制本体**(实测与 update 落地文件逐字节一
561
+ * 致),不是安装器——落地即用,无 /S 静默安装一说。
562
+ * 2) curl 下载到 %USERPROFILE%\bin\ccpg-cli.exe(先下暂存再改名,原子)
563
+ * 3) 校验 --version 可执行(PE 完整、架构匹配)
564
+ * 4) %USERPROFILE%\bin 写入用户 PATH(注册表 User 作用域,免管理员)
565
+ * 5) 教程必做步骤 ccpg-cli update(刚下的就是 latest,通常 no-op,软失败)
414
566
  * 全程 execFile 参数数组,不经 shell。 */
415
- const CCPG_CLI_WINDOWS_INSTALLER_URL = 'https://bms.einwin.com/media/ccpg-cli/202607151817/ccpg-cli-windows-amd64.exe';
567
+ const CCPG_CLI_UPDATE_ENDPOINT = 'https://bms.einwin.com/mdm-third-party/ccpg-cli/latest?os=windows&arch=amd64';
568
+ const CCPG_CLI_WINDOWS_URL_PREFIX = 'https://bms.einwin.com/media/ccpg-cli/';
569
+ const CCPG_CLI_WINDOWS_FALLBACK_URL = `${CCPG_CLI_WINDOWS_URL_PREFIX}202608271055/ccpg-cli-windows-amd64.exe`;
570
+
571
+ async function resolveCcpgWindowsDownloadUrl() {
572
+ try {
573
+ const { stdout } = await runCliTool('curl', [
574
+ '-fsSL', '--max-time', '30', CCPG_CLI_UPDATE_ENDPOINT,
575
+ ], { timeoutMs: 40_000 });
576
+ const url = JSON.parse(String(stdout).trim())?.value?.url;
577
+ if (typeof url === 'string' && url.startsWith(CCPG_CLI_WINDOWS_URL_PREFIX)) return url;
578
+ } catch {
579
+ // 更新接口异常:走兜底 pin 值
580
+ }
581
+ return CCPG_CLI_WINDOWS_FALLBACK_URL;
582
+ }
583
+
584
+ /** 把目录追加进用户 PATH(注册表 User 作用域,新开终端生效)。已存在则
585
+ * 跳过。用 powershell [Environment] API——setx 会静默截断超过 1024 字符
586
+ * 的 PATH,不用。 */
587
+ async function ensureUserPathContains(dir) {
588
+ const safeDir = dir.replace(/'/g, "''");
589
+ const script = [
590
+ "$p=[Environment]::GetEnvironmentVariable('Path','User')",
591
+ `if(($p -split ';') -notcontains '${safeDir}'){[Environment]::SetEnvironmentVariable('Path',($p.TrimEnd(';')+';${safeDir}'),'User');'added'}else{'exists'}`,
592
+ ].join(';');
593
+ const { stdout } = await runCliTool('powershell', ['-NoProfile', '-Command', script], { timeoutMs: 30_000 });
594
+ return String(stdout).includes('added')
595
+ ? '已把 %USERPROFILE%\\bin 写入用户 PATH(新开的终端生效)'
596
+ : '%USERPROFILE%\\bin 已在用户 PATH 中';
597
+ }
416
598
 
417
599
  async function installCcpgCliWindows(timeoutMs) {
418
- const tempDir = join(homedir(), 'AppData', 'Local', 'Temp');
419
- const installer = join(tempDir, 'ccpg-cli-setup.exe');
600
+ const binDir = join(homedir(), 'bin');
601
+ const target = join(binDir, 'ccpg-cli.exe');
602
+ mkdirSync(binDir, { recursive: true });
603
+ const url = await resolveCcpgWindowsDownloadUrl();
604
+ const staging = join(binDir, 'ccpg-cli.exe.download');
420
605
  await runCliTool('curl', [
421
- '-fsSL', '--retry', '3', '--retry-delay', '5', '--max-time', '300',
422
- '-o', installer, CCPG_CLI_WINDOWS_INSTALLER_URL,
606
+ '-fsSL', '--retry', '3', '--retry-delay', '5', '--max-time', '600',
607
+ '--speed-limit', '65536', '--speed-time', '30',
608
+ '-o', staging, url,
423
609
  ], { timeoutMs });
424
- // NSIS 风格安装器:/S 静默安装;退出码非 0 视为失败
425
- await runCliTool(installer, ['/S'], { timeoutMs });
426
- // 官方教程的必做步骤:安装完成后立即自升级到最新版(软失败不阻断)
427
- const updateNote = await runCliTool('ccpg-cli', ['update'], { timeoutMs: 120_000 })
610
+ renameSync(staging, target);
611
+ // 校验二进制可执行(PE 完整、架构匹配)
612
+ const { stdout: versionOut } = await runCliTool(target, ['--version'], { timeoutMs: 30_000 });
613
+ resetExtraBinDirsCache();
614
+ const pathNote = await ensureUserPathContains(binDir)
615
+ .catch(() => 'PATH 写入失败,请手动把 %USERPROFILE%\\bin 加入用户 PATH');
616
+ // 官方教程的必做步骤:装完立即自升级(刚下的就是 latest,通常 no-op;
617
+ // 软失败不阻断)
618
+ const updateNote = await runCliTool('ccpg-cli', ['update'], {
619
+ timeoutMs: 120_000,
620
+ env: { CCPG_FULL_CLI: '1' },
621
+ })
428
622
  .then((r) => String(r.stdout || '').trim().split('\n').slice(-2).join('\n'))
429
623
  .catch(() => 'ccpg-cli update 未完成,安装后请手动执行 ccpg-cli update');
430
624
  return {
431
- stdout: `ccpg-cli 安装完成\n${updateNote}\n如命令不可用,请重启 DSH Desktop 后点「重新检测」`,
625
+ stdout: `ccpg-cli 安装完成(${String(versionOut).trim()})\n${updateNote}\n${pathNote}\n如状态页仍显示未安装,请重启 DSH Desktop 后点「重新检测」`,
626
+ };
627
+ }
628
+
629
+ /** Python Windows 一键安装(python.org 官方安装器口径):
630
+ * 1) Windows 10 1803+ 自带 curl.exe 下载官方安装器——**国内镜像优先**
631
+ * (python.org 官网源在国内动辄超时,实测华为云镜像 2MB/s、官网源
632
+ * 300s 拉不完 45MB),镜像目录结构与官网一致、按版本号直链;
633
+ * 镜像不可用时退化官网源(海外用户/镜像异常)
634
+ * 2) 静默每用户安装:InstallAllUsers=0(无需 UAC/管理员)、
635
+ * PrependPath=1(安装器直接写入用户 PATH——不勾选的话 GUI 宿主
636
+ * 要等重启才能看到 python.exe)
637
+ * 3) 装完清兑底目录缓存,本进程内即可探测到新解释器
638
+ * 全程 execFile 参数数组,不经 shell。 */
639
+ const PYTHON_WINDOWS_INSTALLER_VERSION = '3.13.6';
640
+ const PYTHON_WINDOWS_INSTALLER_URLS = [
641
+ `https://mirrors.huaweicloud.com/python/${PYTHON_WINDOWS_INSTALLER_VERSION}/python-${PYTHON_WINDOWS_INSTALLER_VERSION}-amd64.exe`,
642
+ `https://www.python.org/ftp/python/${PYTHON_WINDOWS_INSTALLER_VERSION}/python-${PYTHON_WINDOWS_INSTALLER_VERSION}-amd64.exe`,
643
+ ];
644
+
645
+ async function installPythonWindows(timeoutMs) {
646
+ const installer = join(tmpdir(), 'python-setup.exe');
647
+ // 逐个源下载:curl 失败(非 2xx/超时/低速假死)换下一个源。
648
+ // --speed-limit/--speed-time:30 秒内速度低于 64KB/s 视为假死换源
649
+ // (官网源在国内常见形态不是断连而是 kb 级慢速拖死)。
650
+ let downloaded = false;
651
+ let lastError = null;
652
+ for (const url of PYTHON_WINDOWS_INSTALLER_URLS) {
653
+ try {
654
+ await runCliTool('curl', [
655
+ '-fsSL', '--retry', '2', '--retry-delay', '3', '--max-time', '600',
656
+ '--speed-limit', '65536', '--speed-time', '30',
657
+ '-o', installer, url,
658
+ ], { timeoutMs });
659
+ downloaded = true;
660
+ break;
661
+ } catch (error) {
662
+ lastError = error;
663
+ }
664
+ }
665
+ if (!downloaded) throw lastError || new Error('安装包下载失败,请检查网络后重试');
666
+ // python.org 安装器静默模式:/quiet + 每用户安装 + 写入用户 PATH
667
+ await runCliTool(installer, [
668
+ '/quiet', 'InstallAllUsers=0', 'PrependPath=1', 'Include_test=0',
669
+ ], { timeoutMs });
670
+ resetExtraBinDirsCache();
671
+ // 立即复探(本进程内兑底目录已含 python.org 每用户安装位)
672
+ const probe = await probePythonEnv().catch(() => ({ available: false }));
673
+ return {
674
+ stdout: probe.available
675
+ ? `Python ${probe.version} 安装完成(${probe.command})。如技能页仍提示未安装,请重启 DSH Desktop 后点「重新检测」`
676
+ : `Python ${PYTHON_WINDOWS_INSTALLER_VERSION} 安装完成。安装器已写入用户 PATH,请重启 DSH Desktop 后点「重新检测」`,
432
677
  };
433
678
  }
434
679
 
435
680
  /**
436
681
  * 触发 CLI 官方自升级(`tool update`):ccpg-cli 从更新服务器下载二进制
437
- * (~17MB),lark-cli 自动探测 npm/pnpm/手动安装方式(手动时只打印
682
+ * (~17MB),新版 lark-cli 自动探测 npm/pnpm/手动安装方式(手动时只打印
438
683
  * GitHub 下载地址)。返回输出尾部给前端展示(含手动安装的下载链接)。
684
+ * 兜底:老版本 lark-cli 没有 update 子命令(实测 1.0.0 报 unknown
685
+ * command),此时「重装即升级」——走 installCliTool 的 npm/pnpm 全局装
686
+ * 最新版链路,覆盖旧版。
439
687
  */
440
688
  export async function upgradeCliTool(tool, { timeoutMs = 300_000 } = {}) {
441
- const { stdout } = await runCliTool(tool, ['update'], {
442
- timeoutMs,
443
- env: tool === 'ccpg-cli' ? { CCPG_FULL_CLI: '1' } : undefined,
444
- });
445
- const lines = String(stdout).split('\n').map((l) => l.trim()).filter(Boolean);
446
- return lines.slice(-6).join('\n');
689
+ try {
690
+ const { stdout } = await runCliTool(tool, ['update'], {
691
+ timeoutMs,
692
+ env: tool === 'ccpg-cli' ? { CCPG_FULL_CLI: '1' } : undefined,
693
+ });
694
+ const lines = String(stdout).split('\n').map((l) => l.trim()).filter(Boolean);
695
+ return lines.slice(-6).join('\n');
696
+ } catch (error) {
697
+ if (tool === 'lark-cli' && /unknown command/i.test(String(error?.message || ''))) {
698
+ return installCliTool('lark-cli', { timeoutMs });
699
+ }
700
+ throw error;
701
+ }
447
702
  }
448
703
 
449
704
  /** lark-cli 形态:identities 下的 tokenStatus 或新版 status/available 组合。 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-agentone",
3
- "version": "0.5.22",
3
+ "version": "0.5.23",
4
4
  "description": "AgentOne 平台集成插件:飞书登录、平台模型(同步失败自愈重试)、SkillHub 技能(升级检测一键升级、装后即时刷新、套餐技能自动同步)、套餐申请、插件管理与自助升级、lark-cli / ccpg-cli 探测与一键安装(请求超时与错误码透传、令牌轮换 single-flight、凭据损坏容错与并发安全写入、状态页 UX 增强)",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",