dsh-agentone 0.5.27 → 0.5.29

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, probePythonEnv, pnpmViewLatest, runCliTool, unexpectedStoreGuidance, upgradeCliTool } from './proc.js';
37
+ import { compareSemver, dshPluginCli, firstErrorLines, hasChineseText, installCliTool, isTerminalCliAuthFailure, 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
 
@@ -1302,9 +1302,17 @@ export function apply(ctx, config) {
1302
1302
  sendJson(response, 400, { error: '不支持的 CLI 工具' });
1303
1303
  return;
1304
1304
  }
1305
- const detail = await probeCliDetail(tool).then(withCliUpgradeGuard).catch(() => null);
1305
+ // 只需要三态:跳过 latest 查询(registry 两轮 curl 最坏 ~40s)
1306
+ const detail = await probeCliDetail(tool, { withLatest: false })
1307
+ .then(withCliUpgradeGuard).catch(() => null);
1306
1308
  if (detail?.state === 'missing') {
1307
- sendJson(response, 400, { error: 'CLI 未安装,请先安装或联系管理员' });
1309
+ sendJson(response, 400, {
1310
+ error: detail.reason === 'broken_install'
1311
+ ? 'CLI 安装不完整,请先点击「修复安装」后再授权'
1312
+ : detail.reason === 'unavailable'
1313
+ ? 'CLI 暂时无法访问(可能被安全软件占用或响应超时),请稍后重试;若持续失败请重新安装'
1314
+ : 'CLI 未安装,请先安装或联系管理员',
1315
+ });
1308
1316
  return;
1309
1317
  }
1310
1318
  const raw = await beginCliAuth(tool);
@@ -1322,7 +1330,10 @@ export function apply(ctx, config) {
1322
1330
  expires_in: parsed.expiresIn,
1323
1331
  });
1324
1332
  } catch (error) {
1325
- sendJson(response, 502, { error: error?.message || '发起授权失败,请稍后重试' });
1333
+ const message = String(error?.message || '');
1334
+ sendJson(response, 502, {
1335
+ error: hasChineseText(message) ? message : '发起授权失败,请稍后重试',
1336
+ });
1326
1337
  }
1327
1338
  });
1328
1339
 
@@ -1354,20 +1365,23 @@ export function apply(ctx, config) {
1354
1365
  await completeCliAuth(tool, deviceCode, { timeoutMs: 25_000 });
1355
1366
  log.info(`cli auth complete ok: tool=${tool} elapsed=${Date.now() - startedAt}ms`);
1356
1367
  pendingCliAuth.delete(tool);
1357
- const detail = await probeCliDetail(tool).then(withCliUpgradeGuard).catch(() => null);
1368
+ const detail = await probeCliDetail(tool, { withLatest: false })
1369
+ .then(withCliUpgradeGuard).catch(() => null);
1358
1370
  sendJson(response, 200, { ok: true, tool: detail });
1359
1371
  } catch (error) {
1360
1372
  // 超时=我们主动掐断轮询(用户还没在授权页同意):保留会话,202
1361
- // 让前端继续等;只有终态(设备码失效/被拒)才删会话,否则用户
1362
- // 慢一点授权就会因会话被删而 400「重新发起」。
1373
+ // 让前端继续等;终态(设备码失效/被拒、以及安装损坏/命令缺失
1374
+ // 这类不会因重试自愈的本地故障)才删会话——否则每 3s 重发
1375
+ // complete、每轮再起一个子进程,直到设备码过期。
1363
1376
  const message = String(error?.message || '');
1364
- const terminal = /expired|invalid|denied|拒绝|失效|过期/i.test(message);
1377
+ const terminal = isTerminalCliAuthFailure(error);
1365
1378
  // 快速失败 ≠ 还在等:真实用户机实测,lark-cli 在部分宿主环境拒绝
1366
1379
  // 阻塞轮询(打印 [AI agent] 提示后退出),且设备码被同意消费后
1367
1380
  // 重放会快速报 JSON 错——这两种情况用户其实已完成授权,纯 202
1368
1381
  // 会让弹窗永远停在「正在等待」。用 auth status 实测兜底:已授权
1369
1382
  // 直接按成功返回,弹窗关闭、状态刷新。
1370
- const detail = await probeCliDetail(tool).then(withCliUpgradeGuard).catch(() => null);
1383
+ const detail = await probeCliDetail(tool, { withLatest: false })
1384
+ .then(withCliUpgradeGuard).catch(() => null);
1371
1385
  if (detail?.state === 'authorized') {
1372
1386
  pendingCliAuth.delete(tool);
1373
1387
  log.info(`cli auth complete ok (via status probe): tool=${tool}`);
@@ -1378,7 +1392,7 @@ export function apply(ctx, config) {
1378
1392
  log.warn(`cli auth complete ${terminal ? 'terminal' : 'timeout'}: tool=${tool} message=${message.slice(0, 160)}`);
1379
1393
  sendJson(response, terminal ? 502 : 202, {
1380
1394
  pending: !terminal,
1381
- error: message,
1395
+ error: hasChineseText(message) ? message : '授权确认失败,请稍后重试',
1382
1396
  });
1383
1397
  }
1384
1398
  } catch (error) {
package/lib/page.js CHANGED
@@ -484,6 +484,10 @@ const renderCli = async (s) => {
484
484
  body.innerHTML = '<div class="skill-sub">平台未开启 CLI 功能。</div>';
485
485
  return;
486
486
  }
487
+ // 请求代际:安装/授权成功会直接写缓存重绘,慢的旧 status 响应回来时
488
+ // 若已有更新的一轮在途(或已完成),丢弃——否则旧响应会把新状态覆盖回
489
+ // 未安装/未授权
490
+ const seq = (renderCli._seq = (renderCli._seq || 0) + 1);
487
491
  // 真实探测本机安装与授权态(数秒)。有旧数据(renderCli._tools)时先用
488
492
  // 缓存渲染再后台刷新——不清屏闪「检测中…」,内容回来后整块替换一次。
489
493
  if (renderCli._tools && Object.keys(renderCli._tools).length) {
@@ -497,11 +501,12 @@ const renderCli = async (s) => {
497
501
  const resp = await fetch('api/cli/status');
498
502
  const data = await resp.json().catch(() => ({}));
499
503
  if (!resp.ok) throw new Error(data.error || '检测失败');
504
+ if (seq !== renderCli._seq) return;
500
505
  tools = data.tools || {};
501
506
  python = data.python || null;
502
507
  } catch {
503
508
  // 探测失败:已有内容保持不动,首载才显示错误
504
- if (!renderCli._tools || !Object.keys(renderCli._tools).length) {
509
+ if (seq === renderCli._seq && (!renderCli._tools || !Object.keys(renderCli._tools).length)) {
505
510
  body.innerHTML = '<div class="skill-sub" style="color:var(--err)">CLI 状态检测失败,请稍后重试。</div>';
506
511
  }
507
512
  return;
@@ -533,20 +538,24 @@ const renderCliData = (s, tools, python) => {
533
538
  ? '<span class="pill ok" style="font-size:11px">已授权</span>'
534
539
  : state === 'unauthorized'
535
540
  ? '<span class="pill warn" style="font-size:11px">未授权</span>'
536
- : '<span class="pill warn" style="font-size:11px">未安装</span>';
541
+ : info.reason === 'broken_install'
542
+ ? '<span class="pill warn" style="font-size:11px">安装损坏</span>'
543
+ : '<span class="pill warn" style="font-size:11px">未安装</span>';
537
544
  const versionLine = meta.desc + ' · ' + meta.label
545
+ + (info.reason === 'broken_install' ? ' · 检测到文件不完整,请修复安装' : '')
538
546
  + (info.version ? ' · v' + esc(info.version) : '')
539
547
  + (info.latest && info.latest !== info.version
540
548
  ? ' → <b>v' + esc(info.latest) + '</b>'
541
549
  : '');
542
550
  const upgradable = info.latest && info.latest !== info.version;
543
- // 已装未授权 → 「去授权」:发起官方 Device Flow(扫码/链接 + 轮询确认)
551
+ // 已装未授权 → 「去授权」;残缺安装 → 「修复安装」
544
552
  const actions = [
545
553
  state === 'unauthorized'
546
554
  ? '<button class="btn small primary" data-cli-auth="' + esc(name) + '">去授权</button>'
547
555
  : '',
548
556
  state === 'missing'
549
- ? '<button class="btn small primary" data-cli-install="' + esc(name) + '">安装</button>'
557
+ ? '<button class="btn small primary" data-cli-install="' + esc(name) + '">'
558
+ + (info.reason === 'broken_install' ? '修复安装' : '安装') + '</button>'
550
559
  : '',
551
560
  upgradable
552
561
  ? '<button class="btn small ghost" data-cli-upgrade="' + esc(name) + '">升级</button>'
@@ -706,6 +715,7 @@ const cliAuthDialog = async (tool) => {
706
715
  * ccpg/python 走官方安装器,耗时可达数分钟。响应自带安装后立即探测的
707
716
  * 详情,直接合进缓存刷新界面。 */
708
717
  const cliInstall = async (tool, btn) => {
718
+ const originalText = btn.textContent;
709
719
  btn.disabled = true;
710
720
  btn.textContent = '安装中,可能需要 3–5 分钟…';
711
721
  try {
@@ -730,7 +740,7 @@ const cliInstall = async (tool, btn) => {
730
740
  } catch (error) {
731
741
  alert(error.message || 'CLI 安装失败,请稍后重试');
732
742
  btn.disabled = false;
733
- btn.textContent = '安装';
743
+ btn.textContent = originalText;
734
744
  }
735
745
  };
736
746
 
package/lib/proc.js CHANGED
@@ -12,6 +12,7 @@ import { join, dirname, isAbsolute, delimiter as pathDelimiter } from 'node:path
12
12
  import { promisify } from 'node:util';
13
13
 
14
14
  const execFileAsync = promisify(execFile);
15
+ const preferredCommandCache = new Map();
15
16
 
16
17
  /** 常见安装目录兑底(环境 PATH 之外的补充):GUI 启动的 DSH Desktop 继承
17
18
  * launchd 的 PATH(/usr/bin:/bin/…),不含 nvm/homebrew/.local —— 实测
@@ -187,6 +188,7 @@ export function childPathWithExtras(originalPath, extras, shimDirs) {
187
188
  export function resetExtraBinDirsCache() {
188
189
  extraBinDirsCache = null;
189
190
  desktopShimDirsCache = null;
191
+ preferredCommandCache.clear();
190
192
  }
191
193
 
192
194
  /** 候选扩展名顺序(纯函数,供测试):win32 下 CreateProcess 只认可执行
@@ -265,6 +267,9 @@ export function resolveCommandCandidates(tool) {
265
267
 
266
268
  /** 兼容旧签名:找不到时返回原名(后续 ENOENT 走既有 missing/降级语义)。 */
267
269
  export function resolveCommand(tool) {
270
+ const preferred = preferredCommandCache.get(tool);
271
+ if (preferred && existsSync(preferred)) return preferred;
272
+ if (preferred) preferredCommandCache.delete(tool);
268
273
  return resolveCommandOrNull(tool) || tool;
269
274
  }
270
275
 
@@ -291,8 +296,7 @@ export function decodeOutput(buf) {
291
296
  * cmd.exe /d /s /c 路由,cmd.exe 在第三个兑底目录(System32)。只前置
292
297
  * 解析到的那个目录会让 shim 内部找不到依赖,必须整组前置。输出按
293
298
  * encoding:buffer 收回后 decodeOutput(GBK 容错)。opts.env 只传增量变量。 */
294
- function execResolved(tool, args, { timeoutMs, cwd, env } = {}) {
295
- const resolved = resolveCommand(tool);
299
+ function execResolvedPath(resolved, args, { timeoutMs, cwd, env } = {}) {
296
300
  const childEnv = { ...process.env, ...desktopShimEnvDefaults(), ...(env || {}) };
297
301
  const key = pathEnvKey(childEnv, process.platform);
298
302
  const extras = extraBinDirs().filter((dir) => existsSync(dir));
@@ -319,6 +323,35 @@ function execResolved(tool, args, { timeoutMs, cwd, env } = {}) {
319
323
  );
320
324
  }
321
325
 
326
+ function execResolved(tool, args, options = {}) {
327
+ return execResolvedPath(resolveCommand(tool), args, options);
328
+ }
329
+
330
+ export function isBrokenCliOutput(message) {
331
+ return /MODULE_NOT_FOUND|ERR_MODULE_NOT_FOUND|Cannot find (?:module|package)/i.test(String(message || ''));
332
+ }
333
+
334
+ /** 中文错误契约的兜底判定:消息含中文才直接透传给用户,否则包一层
335
+ * 中文说明(技术详情进日志)。 */
336
+ export function hasChineseText(text) {
337
+ return /[\u4e00-\u9fff]/.test(String(text || ''));
338
+ }
339
+
340
+ export function isWindowsFileAccessError(message, platform = process.platform) {
341
+ if (platform !== 'win32') return false;
342
+ const raw = String(message || '');
343
+ // EBUSY:Windows「文件正被另一进程使用」(npm 安装被占用的另一种形态)
344
+ return /(?:EPERM|EACCES|EBUSY|errno\s*:?\s*-4048|operation not permitted)/i.test(raw)
345
+ && /(?:npm|@larksuite[\\/]cli|node_modules|cleanup)/i.test(raw);
346
+ }
347
+
348
+ function cliFailure(kind, message, raw = '') {
349
+ const error = new Error(message);
350
+ error.kind = kind;
351
+ error.raw = raw;
352
+ return error;
353
+ }
354
+
322
355
  /** 从 pnpm 冗长输出里提取对用户有意义的错误行。 */
323
356
  export function firstErrorLines(raw) {
324
357
  const lines = String(raw).split('\n').map((line) => line.trim()).filter(Boolean);
@@ -350,6 +383,11 @@ export function unexpectedStoreGuidance() {
350
383
  * async 函数;execFile 不经过 shell,参数数组中的每个元素都是独立参数,
351
384
  * 不存在拼接解释执行。
352
385
  */
386
+ function isLarkCliTool(tool) {
387
+ const name = String(tool || '').replace(/\\/g, '/').split('/').pop().toLowerCase();
388
+ return ['lark-cli', 'lark-cli.cmd', 'lark-cli.exe'].includes(name);
389
+ }
390
+
353
391
  export async function runCliTool(tool, args, { timeoutMs = 300000, cwd, env } = {}) {
354
392
  try {
355
393
  const { stdout, stderr } = await execResolved(tool, args, {
@@ -360,17 +398,39 @@ export async function runCliTool(tool, args, { timeoutMs = 300000, cwd, env } =
360
398
  return { stdout, stderr };
361
399
  } catch (error) {
362
400
  if (error.code === 'ENOENT') {
363
- throw new Error(missingCommandGuidance(tool));
401
+ throw cliFailure('missing_command', missingCommandGuidance(tool));
364
402
  }
365
403
  if (error.killed) {
366
- throw new Error('命令执行超时,请稍后重试');
404
+ throw cliFailure('timeout', '命令执行超时,请稍后重试');
367
405
  }
368
406
  const raw = `${error.stderr || ''}\n${error.stdout || ''}`;
369
407
  if (isUnexpectedStoreError(raw)) {
370
- throw new Error(unexpectedStoreGuidance());
408
+ throw cliFailure('pnpm_store_conflict', unexpectedStoreGuidance(), raw);
409
+ }
410
+ const isLarkNpmInstall = String(tool).toLowerCase().includes('npm')
411
+ && args.includes('@larksuite/cli@latest');
412
+ // spawn 级失败(杀软拦截执行等)没有 stdout/stderr:错误码与 message
413
+ // 里带命令路径,单独送检
414
+ const spawnText = `${error.code || ''}\n${error.message || ''}`;
415
+ if (isLarkNpmInstall && (isWindowsFileAccessError(raw) || isWindowsFileAccessError(spawnText))) {
416
+ throw cliFailure(
417
+ 'windows_file_access',
418
+ 'Windows 无法更新 lark-cli 文件,可能有程序或安全软件正在占用安装目录。请关闭正在运行的 lark-cli 或相关终端后重试;仍失败时请重启 Windows 后再次安装。',
419
+ raw,
420
+ );
421
+ }
422
+ if (isLarkCliTool(tool) && isBrokenCliOutput(raw)) {
423
+ throw cliFailure('broken_install', 'lark-cli 安装不完整,请先点击「修复安装」后再授权。', raw);
371
424
  }
372
425
  const message = firstErrorLines(raw);
373
- throw new Error(message || `命令执行失败(退出码 ${error.code ?? '未知'})`);
426
+ // 英文技术行不直接弹给用户:中文前缀 + 技术详情尾随(与 ccpg 下载
427
+ // 失败的处理同口径),完整原文保留在 error.raw 供日志诊断
428
+ const userMessage = hasChineseText(message)
429
+ ? message
430
+ : message
431
+ ? `命令执行失败。技术详情:${message}`
432
+ : `命令执行失败(退出码 ${error.code ?? '未知'})`;
433
+ throw cliFailure('command_failed', userMessage, raw);
374
434
  }
375
435
  }
376
436
 
@@ -492,37 +552,134 @@ export function parseCliVersion(stdout) {
492
552
  return match ? match[1] : null;
493
553
  }
494
554
 
555
+ export async function selectWorkingCliCandidate(candidates, probe) {
556
+ let sawBrokenInstall = false;
557
+ for (const candidate of candidates) {
558
+ try {
559
+ const result = await probe(candidate);
560
+ const output = `${result?.stdout || ''}\n${result?.stderr || ''}`;
561
+ // 损坏判定必须先于版本解析:cmd 包装未传播退出码时 Node 的
562
+ // MODULE_NOT_FOUND 堆栈会走 resolve 路径,尾行「Node.js v24.18.0」
563
+ // 能被 parseCliVersion 解析出版本号——先收版本会把损坏 shim 当成
564
+ // 可用候选缓存起来,恰好废掉「跳过损坏 shim」的核心目标
565
+ if (isBrokenCliOutput(output)) {
566
+ sawBrokenInstall = true;
567
+ continue;
568
+ }
569
+ const version = parseCliVersion(output);
570
+ if (version) return { command: candidate, version, reason: null };
571
+ } catch (error) {
572
+ const raw = `${error?.stderr || ''}\n${error?.stdout || ''}\n${error?.message || ''}`;
573
+ if (isBrokenCliOutput(raw)) sawBrokenInstall = true;
574
+ }
575
+ }
576
+ return {
577
+ command: null,
578
+ version: null,
579
+ reason: sawBrokenInstall ? 'broken_install' : 'unavailable',
580
+ };
581
+ }
582
+
583
+ export function shouldFallbackToPnpm(error, { platform = process.platform, pnpmAvailable = false } = {}) {
584
+ return platform === 'win32'
585
+ && pnpmAvailable
586
+ && ['windows_file_access', 'broken_install'].includes(error?.kind);
587
+ }
588
+
589
+ /** complete 轮询的终态判定:设备码过期/被拒之外,本地确定性故障(安装
590
+ * 损坏、命令缺失)不会因重试自愈——按终态返回,避免前端每 3s 重发、
591
+ * 每轮再起一个子进程直到设备码过期。timeout 是我们主动掐断的等待,
592
+ * 非终态。 */
593
+ export function isTerminalCliAuthFailure(error) {
594
+ if (['broken_install', 'missing_command'].includes(error?.kind)) return true;
595
+ return /expired|invalid|denied|拒绝|失效|过期/i.test(String(error?.message || ''));
596
+ }
597
+
598
+ /** 从 pnpm -g 前置校验报错里解析全局 bin 目录。pnpm 12 起输出会把
599
+ * "is not in PATH" 折行并加竖线装饰(单行正则取不到),且 runCliTool
600
+ * 的摘要只保留 ERR_ 行(路径被丢弃)——必须对 error.raw 完整输出跑。 */
601
+ export function extractPnpmGlobalBinDir(text) {
602
+ return /global bin directory\s+"(.+?)"\s+is not(?:\s*\n[^\n]*)?\s+in PATH/i
603
+ .exec(String(text || ''))?.[1] || null;
604
+ }
605
+
606
+ async function resolveWorkingCliCommand(tool, timeoutMs = 10_000) {
607
+ const preferred = preferredCommandCache.get(tool);
608
+ const candidates = [preferred, ...resolveCommandCandidates(tool)]
609
+ .filter(Boolean)
610
+ .filter((candidate, index, all) => all.indexOf(candidate) === index);
611
+ const selected = await selectWorkingCliCandidate(candidates, (candidate) => execResolvedPath(
612
+ candidate,
613
+ ['--version'],
614
+ { timeoutMs },
615
+ ));
616
+ if (selected.command) {
617
+ preferredCommandCache.set(tool, selected.command);
618
+ } else {
619
+ preferredCommandCache.delete(tool);
620
+ }
621
+ return selected;
622
+ }
623
+
495
624
  /**
496
- * 三态探测 + 版本 + 最新版(任一失败降级为 null,不阻塞三态):
497
- * version: `tool --version`
498
- * latest: ccpg-cli 走官方 `update --check --json`(结构化,为 agent
499
- * 设计);lark-cli 无 check 子命令,用 `npm view @larksuite/cli
500
- * version`(版本号全局一致,对 npm/pnpm/手动安装都成立)。
501
- * npm view 需要 npm 在 PATH(node 环境下成立);查询超时/失败返回 null,
502
- * 前端只显示当前版本不显示升级按钮。
625
+ * 三态探测 + 版本 + 最新版(任一失败降级为 null):先验证所有同名 CLI
626
+ * 候选的 --version,跳过 Windows 上 npm 回滚留下的损坏 shim,再读取授权状态。
627
+ * withLatest=false 跳过 latest 查询(两轮 registry curl 最坏 ~40s)——
628
+ * 授权 begin/complete 只需要三态,别让用户在弹窗里等版本号查询。
503
629
  */
504
- export async function probeCliDetail(tool, { timeoutMs = 15000 } = {}) {
505
- let stdout;
506
- let state = 'unauthorized';
630
+ export async function probeCliDetail(tool, { timeoutMs = 15000, withLatest = true } = {}) {
631
+ const selected = await resolveWorkingCliCommand(tool, Math.min(timeoutMs, 10_000));
632
+ if (!selected.command) {
633
+ return {
634
+ state: 'missing',
635
+ reason: selected.reason,
636
+ version: null,
637
+ latest: null,
638
+ };
639
+ }
640
+
641
+ let stdout = '';
507
642
  try {
508
- ({ stdout } = await execResolved(tool, ['auth', 'status'], {
643
+ ({ stdout } = await execResolvedPath(selected.command, ['auth', 'status'], {
509
644
  timeoutMs,
510
645
  env: tool === 'ccpg-cli' ? { CCPG_FULL_CLI: '1' } : undefined,
511
646
  }));
512
647
  } catch (error) {
513
- if (error.code === 'ENOENT') return { state: 'missing', version: null, latest: null };
514
- // 探测失败不区分超时/非零退出:统一按已装但状态未知处理
515
- stdout = `${error.stdout || ''}`;
648
+ const raw = `${error.stdout || ''}\n${error.stderr || ''}`;
649
+ if (isBrokenCliOutput(raw)) {
650
+ return {
651
+ state: 'missing',
652
+ reason: 'broken_install',
653
+ version: selected.version,
654
+ latest: null,
655
+ };
656
+ }
657
+ stdout = raw;
516
658
  }
517
- if (larkStyleAuthorized(stdout) || ccpgStyleAuthorized(stdout)) state = 'authorized';
659
+ const state = larkStyleAuthorized(stdout) || ccpgStyleAuthorized(stdout)
660
+ ? 'authorized'
661
+ : 'unauthorized';
662
+ const latest = withLatest
663
+ ? await cliLatestVersion(tool, timeoutMs).catch(() => null)
664
+ : null;
665
+ return { state, version: selected.version, latest };
666
+ }
518
667
 
519
- const [version, latest] = await Promise.all([
520
- execResolved(tool, ['--version'], { timeoutMs: 10_000 })
521
- .then((r) => parseCliVersion(r.stdout))
522
- .catch(() => null),
523
- cliLatestVersion(tool, timeoutMs).catch(() => null),
524
- ]);
525
- return { state, version, latest };
668
+ /** npm registry 的包 latest 版本(curl 直查 JSON,与机器上有无 node/
669
+ * npm/pnpm 完全解耦——工具链兜底链不可靠:npm 全局装的 pnpm.cmd 内部
670
+ * 仍要调 npm,barren 环境实测 pnpm view 报「'npm' 不是内部或外部命令」)。
671
+ * npmmirror 优先(国内),npmjs 兜底。 */
672
+ async function registryLatestVersion(name, timeoutMs = 25_000) {
673
+ for (const url of [`https://registry.npmmirror.com/${name}/latest`, `https://registry.npmjs.org/${name}/latest`]) {
674
+ try {
675
+ const { stdout } = await runCliTool('curl', ['-fsSL', '--max-time', '15', url], { timeoutMs });
676
+ const version = JSON.parse(String(stdout))?.version;
677
+ if (typeof version === 'string' && version) return version;
678
+ } catch {
679
+ // 换下一个源
680
+ }
681
+ }
682
+ return null;
526
683
  }
527
684
 
528
685
  /** 最新版查询:ccpg-cli 官方 check 接口;lark-cli 查 npm 包 dist-tag。 */
@@ -536,10 +693,16 @@ async function cliLatestVersion(tool, timeoutMs) {
536
693
  return typeof data?.latest_version === 'string' ? data.latest_version : null;
537
694
  }
538
695
  if (tool === 'lark-cli') {
539
- // runCliTool 而非裸 execResolved:走 Windows 兑底目录解析 npm,
540
- // ENOENT 时抛中文指引由上层 catch 降级(按钮不出现)而非静默挂死
541
- const { stdout } = await runCliTool('npm', ['view', '@larksuite/cli', 'version'], { timeoutMs });
542
- return parseCliVersion(stdout);
696
+ // curl 直查 registry(系统自带,零工具链依赖);失败再退 npm view。
697
+ // 升级检测不该依赖机器上恰好有哪套 node 工具链
698
+ const registry = await registryLatestVersion('@larksuite/cli', timeoutMs).catch(() => null);
699
+ if (registry) return registry;
700
+ try {
701
+ const { stdout } = await runCliTool('npm', ['view', '@larksuite/cli', 'version'], { timeoutMs });
702
+ return parseCliVersion(stdout);
703
+ } catch {
704
+ return null;
705
+ }
543
706
  }
544
707
  return null;
545
708
  }
@@ -586,12 +749,81 @@ async function installLarkSkills({ timeoutMs = 180_000 } = {}) {
586
749
  } catch {
587
750
  // 目录不存在 → 未安装,继续
588
751
  }
589
- const { stdout } = await runCliTool('npx', [
590
- '-y', 'skills', 'add', 'larksuite/cli',
591
- '-g', '--all', '--dangerously-accept-openclaw-risks',
592
- ], { timeoutMs });
593
- const lines = String(stdout).split('\n').map((l) => l.trim()).filter(Boolean);
594
- return lines.slice(-3).join('\n');
752
+ const args = ['skills', 'add', 'larksuite/cli', '-g', '--all', '--dangerously-accept-openclaw-risks'];
753
+ const tail = (out) => String(out).split('\n').map((l) => l.trim()).filter(Boolean).slice(-3).join('\n');
754
+ // 包执行器降级链:优先 npx(npm 机器);无 npm 的机器(仅独立 pnpm 或
755
+ // DSH Desktop 自带 shim)走 pnpm dlx——真实用户机 pnpm 装完 lark-cli 后
756
+ // 卡在 npx 缺失的引导文案上,被当成安装失败报上来;两者都缺则温和
757
+ // 跳过(技能是增强项,不该阻断安装成功的呈现)
758
+ if (resolveCommandOrNull('npx')) {
759
+ const { stdout } = await runCliTool('npx', ['-y', ...args], { timeoutMs });
760
+ return tail(stdout);
761
+ }
762
+ if (resolveCommandOrNull('pnpm')) {
763
+ const { stdout } = await runCliTool('pnpm', ['dlx', ...args], { timeoutMs });
764
+ return tail(stdout);
765
+ }
766
+ return 'lark-cli 已安装。默认技能包暂跳过(本机暂无 npm/npx,不影响 CLI 功能;如需技能可安装 Node.js 后重新点「安装」)';
767
+ }
768
+
769
+ function rememberGlobalBinDir(dir) {
770
+ const value = String(dir || '').trim();
771
+ if (!value) return;
772
+ const existing = extraBinDirs();
773
+ extraBinDirsCache = [value, ...existing.filter((item) => item.toLowerCase() !== value.toLowerCase())];
774
+ }
775
+
776
+ async function installLarkCliWithPnpm(timeoutMs) {
777
+ const pnpmHome = process.env.PNPM_HOME
778
+ || join(process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local'), 'pnpm');
779
+ const pnpmEnv = { PNPM_HOME: pnpmHome };
780
+ const pnpmInstall = () => runCliTool(
781
+ 'pnpm',
782
+ ['add', '-g', '@larksuite/cli@latest'],
783
+ { timeoutMs, env: pnpmEnv },
784
+ );
785
+ let stdout;
786
+ try {
787
+ ({ stdout } = await pnpmInstall());
788
+ } catch (error) {
789
+ // pnpm -g 前置校验要求全局 bin 目录在 PATH 中;自定义安装位不在
790
+ // GUI PATH 时,从错误中取出目录并重试一次。路径只在完整输出(raw)
791
+ // 里且被折行——见 extractPnpmGlobalBinDir。
792
+ const dir = extractPnpmGlobalBinDir(`${error?.raw || ''}\n${error?.message || ''}`);
793
+ if (!dir) throw error;
794
+ rememberGlobalBinDir(dir);
795
+ ({ stdout } = await pnpmInstall());
796
+ }
797
+ try {
798
+ const { stdout: binOut } = await runCliTool('pnpm', ['bin', '-g'], {
799
+ timeoutMs: 20_000,
800
+ env: pnpmEnv,
801
+ });
802
+ const dir = String(binOut || '').split('\n').map((line) => line.trim()).filter(Boolean).pop();
803
+ if (dir) {
804
+ rememberGlobalBinDir(dir);
805
+ const candidate = candidateSuffixes(process.platform)
806
+ .map((suffix) => join(dir, `lark-cli${suffix}`))
807
+ .find((item) => existsSync(item));
808
+ if (candidate) preferredCommandCache.set('lark-cli', candidate);
809
+ }
810
+ } catch {
811
+ // 静态兑底目录仍可供后续候选探测使用。
812
+ }
813
+ return stdout;
814
+ }
815
+
816
+ async function verifyLarkCliInstall(timeoutMs) {
817
+ const selected = await resolveWorkingCliCommand('lark-cli', Math.min(timeoutMs, 30_000));
818
+ if (!selected.command) {
819
+ if (selected.reason === 'broken_install') {
820
+ throw cliFailure('broken_install', 'lark-cli 安装不完整,请点击「修复安装」重试。');
821
+ }
822
+ // 不点名按钮:unavailable 时页面按钮文案随上次状态是「安装」或
823
+ // 「修复安装」,点名反而对不上
824
+ throw cliFailure('install_verify_failed', 'lark-cli 安装未完成,请稍后重试;若持续失败请重启 DSH Desktop 后再试。');
825
+ }
826
+ return selected;
595
827
  }
596
828
 
597
829
  /**
@@ -606,38 +838,37 @@ async function installLarkSkills({ timeoutMs = 180_000 } = {}) {
606
838
  export async function installCliTool(tool, { timeoutMs = 600_000 } = {}) {
607
839
  let stdout;
608
840
  if (tool === 'lark-cli') {
609
- // 预检+降级链:优先独立 npm;没有 npm 但有 pnpm(含 DSH Desktop 自带
610
- // shim)时用 pnpm 全局装;两者都没有时 Windows 先一键补装 Node.js
611
- // (用户目录免管理员,npmmirror 镜像),再用其自带 npm 装 lark-cli。
612
- // lark-cli 官方支持 pnpm 安装方式,装出来的二进制与 npm 路径一致,
613
- // 后续 lark-cli update 也能自升级。
841
+ // 优先 npm;Windows 上 npm 文件被占用或安装后仍是残缺 shim 时,
842
+ // 仅在 pnpm 可用的情况下切换到 pnpm,避免用另一套安装器掩盖网络错误。
614
843
  if (resolveCommandOrNull('npm')) {
615
- ({ stdout } = await runCliTool('npm', ['install', '-g', '@larksuite/cli@latest'], { timeoutMs }));
616
- } else if (resolveCommandOrNull('pnpm')) {
617
- const pnpmInstall = () => runCliTool('pnpm', ['add', '-g', '@larksuite/cli@latest'], { timeoutMs });
844
+ let npmError = null;
618
845
  try {
619
- ({ stdout } = await pnpmInstall());
846
+ ({ stdout } = await runCliTool('npm', ['install', '-g', '@larksuite/cli@latest'], { timeoutMs }));
847
+ await verifyLarkCliInstall(timeoutMs);
620
848
  } catch (error) {
621
- // pnpm -g 前置校验:自己配置的全局 bin 目录必须在 PATH 里(真实
622
- // 用户机报 The configured global bin directory "C:\Users\<u>\
623
- // AppData\Local\pnpm\bin" is not in PATH)。自定义安装位不在兑底
624
- // 目录里时,从报错原文解析出该目录补进 PATH 重试一次
625
- const dir = /global bin directory "(.+?)" is not in PATH/i.exec(String(error?.message || ''))?.[1];
626
- if (!dir) throw error;
627
- extraBinDirsCache = [...extraBinDirs(), dir];
628
- try {
629
- ({ stdout } = await pnpmInstall());
630
- } finally {
631
- resetExtraBinDirsCache();
632
- }
849
+ npmError = error;
850
+ }
851
+ const canFallback = shouldFallbackToPnpm(npmError, {
852
+ pnpmAvailable: Boolean(resolveCommandOrNull('pnpm')),
853
+ });
854
+ if (npmError && !canFallback) throw npmError;
855
+ if (canFallback) {
856
+ const pnpmOutput = await installLarkCliWithPnpm(timeoutMs);
857
+ stdout = `${stdout || ''}\n${pnpmOutput || ''}`;
858
+ await verifyLarkCliInstall(timeoutMs);
633
859
  }
860
+ } else if (resolveCommandOrNull('pnpm')) {
861
+ stdout = await installLarkCliWithPnpm(timeoutMs);
862
+ await verifyLarkCliInstall(timeoutMs);
634
863
  } else if (process.platform === 'win32') {
635
864
  const nodeNote = await installNodeWindows(timeoutMs);
636
865
  ({ stdout } = await runCliTool('npm', ['install', '-g', '@larksuite/cli@latest'], { timeoutMs }));
637
866
  stdout = `${nodeNote}\n${stdout}`;
867
+ await verifyLarkCliInstall(timeoutMs);
638
868
  } else {
639
869
  throw new Error(missingCommandGuidance('npm'));
640
870
  }
871
+
641
872
  const skillsNote = await installLarkSkills().catch((error) => {
642
873
  // 软失败:技能装不上不影响 CLI 本体已装成功(如 GitHub 不可达)
643
874
  return `lark-cli skills install soft-failed: ${error?.message || error}`;
@@ -879,7 +1110,10 @@ export async function upgradeCliTool(tool, { timeoutMs = 300_000 } = {}) {
879
1110
  const lines = String(stdout).split('\n').map((l) => l.trim()).filter(Boolean);
880
1111
  return lines.slice(-6).join('\n');
881
1112
  } catch (error) {
882
- if (tool === 'lark-cli' && /unknown command/i.test(String(error?.message || ''))) {
1113
+ if (tool === 'lark-cli') {
1114
+ // 老版本 update 子命令不存在(unknown command)→ 重装即升级;其余
1115
+ // 失败(如 pnpm 装的 lark-cli 内部走 npm 自升级而本机无 npm)也统
1116
+ // 一落到 installCliTool——它按机器环境选对安装器并兜底探测落点
883
1117
  return installCliTool('lark-cli', { timeoutMs });
884
1118
  }
885
1119
  throw error;
package/package.json CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "dsh-agentone",
3
- "version": "0.5.27",
3
+ "version": "0.5.29",
4
4
  "description": "AgentOne 平台集成插件:飞书登录、平台模型(同步失败自愈重试)、SkillHub 技能(升级检测一键升级、装后即时刷新、套餐技能自动同步)、套餐申请、插件管理与自助升级、lark-cli / ccpg-cli 探测与一键安装(请求超时与错误码透传、令牌轮换 single-flight、凭据损坏容错与并发安全写入、状态页 UX 增强)",
5
5
  "type": "module",
6
+ "scripts": {
7
+ "smoke": "node scripts/env-matrix-smoke.mjs"
8
+ },
6
9
  "main": "lib/index.js",
7
10
  "exports": {
8
11
  ".": "./lib/index.js",