dsh-agentone 0.5.28 → 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,15 +693,15 @@ 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
- // npm 机器查 npm view;无 npm 的机器(仅独立 pnpm / DSH Desktop 自带
540
- // shim)退化 pnpm view——否则升级检测在整类机器上都悄悄失效,升级
541
- // 按钮永远不出现(与安装走 pnpm 分支是同一环境类)
696
+ // curl 直查 registry(系统自带,零工具链依赖);失败再退 npm view。
697
+ // 升级检测不该依赖机器上恰好有哪套 node 工具链
698
+ const registry = await registryLatestVersion('@larksuite/cli', timeoutMs).catch(() => null);
699
+ if (registry) return registry;
542
700
  try {
543
701
  const { stdout } = await runCliTool('npm', ['view', '@larksuite/cli', 'version'], { timeoutMs });
544
702
  return parseCliVersion(stdout);
545
703
  } catch {
546
- const version = await pnpmViewLatest('@larksuite/cli').catch(() => null);
547
- return version || null;
704
+ return null;
548
705
  }
549
706
  }
550
707
  return null;
@@ -609,6 +766,66 @@ async function installLarkSkills({ timeoutMs = 180_000 } = {}) {
609
766
  return 'lark-cli 已安装。默认技能包暂跳过(本机暂无 npm/npx,不影响 CLI 功能;如需技能可安装 Node.js 后重新点「安装」)';
610
767
  }
611
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;
827
+ }
828
+
612
829
  /**
613
830
  * 触发 CLI 官方安装(工具缺失时用户自助安装):
614
831
  * lark-cli npm 全局装最新版(包名 @larksuite/cli,npm/pnpm 环境均可;
@@ -621,47 +838,37 @@ async function installLarkSkills({ timeoutMs = 180_000 } = {}) {
621
838
  export async function installCliTool(tool, { timeoutMs = 600_000 } = {}) {
622
839
  let stdout;
623
840
  if (tool === 'lark-cli') {
624
- // 预检+降级链:优先独立 npm;没有 npm 但有 pnpm(含 DSH Desktop 自带
625
- // shim)时用 pnpm 全局装;两者都没有时 Windows 先一键补装 Node.js
626
- // (用户目录免管理员,npmmirror 镜像),再用其自带 npm 装 lark-cli。
627
- // lark-cli 官方支持 pnpm 安装方式,装出来的二进制与 npm 路径一致,
628
- // 后续 lark-cli update 也能自升级。
841
+ // 优先 npm;Windows 上 npm 文件被占用或安装后仍是残缺 shim 时,
842
+ // 仅在 pnpm 可用的情况下切换到 pnpm,避免用另一套安装器掩盖网络错误。
629
843
  if (resolveCommandOrNull('npm')) {
630
- ({ stdout } = await runCliTool('npm', ['install', '-g', '@larksuite/cli@latest'], { timeoutMs }));
631
- } else if (resolveCommandOrNull('pnpm')) {
632
- const pnpmInstall = () => runCliTool('pnpm', ['add', '-g', '@larksuite/cli@latest'], { timeoutMs });
844
+ let npmError = null;
633
845
  try {
634
- ({ stdout } = await pnpmInstall());
846
+ ({ stdout } = await runCliTool('npm', ['install', '-g', '@larksuite/cli@latest'], { timeoutMs }));
847
+ await verifyLarkCliInstall(timeoutMs);
635
848
  } catch (error) {
636
- // pnpm -g 前置校验:自己配置的全局 bin 目录必须在 PATH 里(真实
637
- // 用户机报 The configured global bin directory "C:\Users\<u>\
638
- // AppData\Local\pnpm\bin" is not in PATH)。自定义安装位不在兑底
639
- // 目录里时,从报错原文解析出该目录补进 PATH 重试一次
640
- const dir = /global bin directory "(.+?)" is not in PATH/i.exec(String(error?.message || ''))?.[1];
641
- if (!dir) throw error;
642
- extraBinDirsCache = [...extraBinDirs(), dir];
643
- ({ stdout } = await pnpmInstall());
849
+ npmError = error;
644
850
  }
645
- // pnpm 全局 bin 的真实落点随版本/配置漂移(v10 是 %LOCALAPPDATA%\
646
- // pnpm\bin,PNPM_HOME 自定义更随意,v11 又不同)——装完问 pnpm 本尊
647
- // 要实际目录并常驻兑底缓存:否则状态页探测不到刚装的 lark-cli,
648
- // 明明装成功却显示「未安装」(真实用户机 0.5.27 实测)
649
- try {
650
- const { stdout: binOut } = await runCliTool('pnpm', ['bin', '-g'], { timeoutMs: 20_000 });
651
- const dir = String(binOut || '').split('\n').map((l) => l.trim()).filter(Boolean).pop();
652
- if (dir && !extraBinDirs().includes(dir)) {
653
- extraBinDirsCache = [...extraBinDirs(), dir];
654
- }
655
- } catch {
656
- // 问不到不阻断:静态兑底目录仍兜底
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);
657
859
  }
860
+ } else if (resolveCommandOrNull('pnpm')) {
861
+ stdout = await installLarkCliWithPnpm(timeoutMs);
862
+ await verifyLarkCliInstall(timeoutMs);
658
863
  } else if (process.platform === 'win32') {
659
864
  const nodeNote = await installNodeWindows(timeoutMs);
660
865
  ({ stdout } = await runCliTool('npm', ['install', '-g', '@larksuite/cli@latest'], { timeoutMs }));
661
866
  stdout = `${nodeNote}\n${stdout}`;
867
+ await verifyLarkCliInstall(timeoutMs);
662
868
  } else {
663
869
  throw new Error(missingCommandGuidance('npm'));
664
870
  }
871
+
665
872
  const skillsNote = await installLarkSkills().catch((error) => {
666
873
  // 软失败:技能装不上不影响 CLI 本体已装成功(如 GitHub 不可达)
667
874
  return `lark-cli skills install soft-failed: ${error?.message || error}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-agentone",
3
- "version": "0.5.28",
3
+ "version": "0.5.29",
4
4
  "description": "AgentOne 平台集成插件:飞书登录、平台模型(同步失败自愈重试)、SkillHub 技能(升级检测一键升级、装后即时刷新、套餐技能自动同步)、套餐申请、插件管理与自助升级、lark-cli / ccpg-cli 探测与一键安装(请求超时与错误码透传、令牌轮换 single-flight、凭据损坏容错与并发安全写入、状态页 UX 增强)",
5
5
  "type": "module",
6
6
  "scripts": {