dsh-agentone 0.5.43 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -48,7 +48,9 @@ lark-cli / ccpg-cli 状态探测。
48
48
  有本机记录但内容被改过的真冲突仍逐项人工确认,不自动覆盖。
49
49
  - **CLI 探测、安装与授权(M4)**:`lark-cli` / `ccpg-cli` 安装与授权态三态探测;
50
50
  未安装时「安装」一键自助安装(lark 走 `npm install -g @larksuite/cli@latest`,
51
- ccpg 走公司 `auto-install.sh`,口径与平台 cli-deployd 一致);
51
+ 无 npm 的机器退化 pnpm——含 DeepSeek Harness 桌面壳自带 Node/pnpm 工具链,
52
+ 全新 macOS 不装 Node 也能装;ccpg 走公司 `auto-install.sh`,口径与平台
53
+ cli-deployd 一致);
52
54
  已装未授权时「去授权」发起官方 Device Flow(授权链接 + ASCII 二维码 +
53
55
  验证码 + 自动轮询确认,设备码仅存插件进程内存);
54
56
  lark-cli 首装未 `config init` 时探测为「未配置」,「初始化应用」一键驱动
@@ -259,7 +259,17 @@ export function createDefaultsSync({ config, profiles, credentials, log, interva
259
259
  promise.finally(() => manualOperations.delete(promise)).catch(() => {});
260
260
  return promise;
261
261
  }
262
- return { sync, manualOperation, snapshot: () => structuredClone(state),
262
+ // 快照是 /api/status(5s 轮询)与 defaults/status 的下发边界:description
263
+ // (技能包全文,几百字/条)只在展示层被引用,而新版页面已彻底不渲染——
264
+ // 在出口剥掉,轮询不再背着几十 KB 死重;内部 state 保持完整
265
+ const snapshot = () => {
266
+ const clone = structuredClone(state);
267
+ for (const list of [clone.skills, clone.plugins]) {
268
+ for (const entry of list || []) delete entry.description;
269
+ }
270
+ return clone;
271
+ };
272
+ return { sync, manualOperation, snapshot,
263
273
  reset() { stop(); profiles.setPolicy([]); state = { source: null, revision: null,
264
274
  checked_at: null, user_id: null, error: null, running: false, skills: [], plugins: [] }; },
265
275
  dispose() { disposed = true; stop(); return Promise.allSettled([inFlight, ...manualOperations.keys()]); },
package/lib/index.js CHANGED
@@ -1057,7 +1057,7 @@ export function apply(ctx, config) {
1057
1057
  invalidateStatusCache();
1058
1058
  sendJson(response, 200, { ok: true, installed: result?.installed ?? 0, failed: result?.failed ?? 0 });
1059
1059
  } catch (error) {
1060
- sendJson(response, 502, { error: error?.message || '同步套餐技能失败' });
1060
+ sendJson(response, 502, { error: error?.message || '同步技能失败' });
1061
1061
  }
1062
1062
  });
1063
1063
 
package/lib/page.js CHANGED
@@ -395,52 +395,87 @@ const renderPlan = async () => {
395
395
  ).then(() => renderPlan());
396
396
  });
397
397
  };
398
+ // 平台清单同步态与插件列表的同名合并:renderDefaults 记录待处理插件并排除
399
+ // 已列名条目,renderPlugins 把同步状态/原因/重试合并进同名行——同一个包只
400
+ // 出现一行(真实用户机 dsh-im:清单同步失败 + 本机未启用曾显示成两条)
401
+ let defaultsPendingPlugins = [];
402
+ let listedPluginNames = new Set();
403
+ let mergedPendingSig = '';
404
+ const DEFAULTS_STATE_LABELS = { current: '已符合', missing: '待安装', pending: '待应用', running: '同步中',
405
+ failed: '失败', conflict: '本机冲突', report_pending: '已安装,待回报', restart_required: '已安装,重启后生效' };
406
+ /** 同步动作按钮的统一绑定(待处理块与合并进插件行的按钮同一契约)。 */
407
+ const bindDefaultsActions = (el) => {
408
+ el.querySelectorAll('[data-default-action]').forEach((btn) => {
409
+ const request = () => fetch('api/defaults/' + btn.dataset.defaultAction, { method: 'POST',
410
+ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kind: btn.dataset.kind,
411
+ name: btn.dataset.name, confirm: btn.dataset.defaultAction === 'apply' }) });
412
+ btn.onclick = () => btn.dataset.defaultAction === 'apply'
413
+ ? confirmThen(btn, '替换当前版本?', request, '已开始应用,请查看同步状态')
414
+ : withLoading(btn, request, '已开始重试,请查看同步状态');
415
+ });
416
+ };
417
+ const defaultsPendingSig = () => defaultsPendingPlugins.map((item) => [item.name, item.state,
418
+ item.target_version || '', item.error || ''].join('~')).join('|');
419
+
398
420
  const renderDefaults = (s) => {
399
- const body = $('defaults-body');
400
421
  const config = s.defaults;
401
- const labels = { current: '已符合', missing: '待安装', pending: '待应用', running: '同步中',
402
- failed: '失败', conflict: '本机冲突', report_pending: '已安装,待回报', restart_required: '已安装,重启后生效' };
422
+ const syncBtn = $('defaults-sync-btn');
423
+ const labels = DEFAULTS_STATE_LABELS;
403
424
  const sources = { platform: '平台清单', compatibility: '旧平台兼容默认', cache: '本机缓存' };
404
- if (!config || !s.logged_in) { body.innerHTML = '<div class="muted">登录后检查平台默认能力</div>'; return; }
425
+ const clearBlock = (el) => { el.innerHTML = ''; delete el.dataset.fingerprint; };
426
+ if (!config || !s.logged_in) {
427
+ syncBtn.hidden = true;
428
+ for (const id of ['defaults-meta', 'defaults-plugins', 'defaults-skills']) clearBlock($(id));
429
+ return;
430
+ }
431
+ syncBtn.hidden = false;
432
+ syncBtn.disabled = Boolean(config.running);
405
433
  const entries = [...(config.skills || []), ...(config.plugins || [])];
406
434
  // legacy:0.5.39 之前装的技能只有平台台账、没有本机记录(见 defaults-sync)。
407
435
  // 这类条目单独给一个批量入口,不然升级后每台机器都要点 N 次「确认应用」。
408
436
  const legacyCount = entries.filter((item) => item.legacy).length;
409
- const summary = '<div class="skill-sub">' + esc(sources[config.source] || '尚未检查')
410
- + (config.revision !== null ? ' · 版本 ' + esc(config.revision) : '')
437
+ const pendingCount = entries.filter((item) => item.state !== 'current').length;
438
+ // 待处理插件交给 renderPlugins 合并进同名行(见顶部说明)
439
+ defaultsPendingPlugins = (config.plugins || []).filter((item) => item.state !== 'current');
440
+ // 清单状态收成插件卡头一行 meta(来源/版本/时间/符合统计/错误):健康项不
441
+ // 铺开——技能与插件分别在各自 Tab 的列表里以「平台默认/受管」标签呈现,
442
+ // 这里只按类型冒泡待处理项,长描述彻底退出列表。
443
+ const metaHtml = '<div class="skill-sub">' + esc(sources[config.source] || '尚未检查')
444
+ + (config.revision !== null ? ' · 清单版本 ' + esc(config.revision) : '')
411
445
  + (config.checked_at ? ' · ' + esc(new Date(config.checked_at).toLocaleString()) : '')
446
+ + ' · <span style="color:var(--ok)">' + (entries.length - pendingCount) + ' 项已符合</span>'
447
+ + (pendingCount ? ' · <span style="color:var(--warn)">' + pendingCount + ' 项待处理(见下)</span>' : '')
448
+ + (!entries.length ? ' · 当前清单为空' : '')
412
449
  + (config.running ? ' · 正在检查' : '') + '</div>'
413
- + (config.error || config.profile_error ? '<div class="skill-sub" style="color:var(--err)">' + esc(config.error || config.profile_error) + '</div>' : '')
414
- + (legacyCount ? '<div class="skill-sub"><button id="defaults-repair" class="btn small ghost"'
415
- + (config.running ? ' disabled' : '') + '>重新应用旧版安装的技能(' + legacyCount + ')</button>'
416
- + ' 技能目录会按平台当前版本覆盖</div>' : '');
417
- const html = summary + entries.map((item) => {
450
+ + (config.error || config.profile_error
451
+ ? '<div class="skill-sub" style="color:var(--err)">' + esc(config.error || config.profile_error) + '</div>' : '');
452
+ const meta = $('defaults-meta');
453
+ if (meta.dataset.fingerprint !== metaHtml) { meta.dataset.fingerprint = metaHtml; meta.innerHTML = metaHtml; }
454
+ const statePill = (state) => state === 'failed' || state === 'conflict'
455
+ ? '<span class="pill" style="font-size:11px;background:var(--err-weak);color:var(--err)">'
456
+ + esc(labels[state] || state) + '</span>'
457
+ : '<span class="pill warn" style="font-size:11px">' + esc(labels[state] || state) + '</span>';
458
+ // 单行紧凑条目:名称 · 类型徽标 · 状态徽标 · 版本(→ 目标版本)+ 行内操作
459
+ const rowHtml = (item) => {
418
460
  const action = ['failed', 'report_pending', 'missing'].includes(item.state) ? 'retry'
419
461
  : ['conflict', 'pending'].includes(item.state) ? 'apply' : null;
420
462
  const button = action ? '<button class="btn small ghost" data-default-action="' + action
421
463
  + '" data-kind="' + item.kind + '" data-name="' + esc(item.name) + '"'
422
464
  + (config.running ? ' disabled' : '') + '>' + (action === 'retry' ? '重试' : '确认应用') + '</button>' : '';
423
465
  return '<div class="skill-item"><div class="skill-info"><div class="skill-name">'
424
- + esc(item.description || item.name) + ' <span class="tag">' + (item.kind === 'skill' ? '技能' : '插件')
425
- + '</span></div><div class="skill-sub">' + esc(item.name) + ' · ' + esc(labels[item.state] || item.state)
426
- + (item.legacy ? '(旧版安装)' : '')
427
- + (item.version ? ' · ' + esc(item.version) : '')
466
+ + esc(item.name) + ' <span class="tag">' + (item.kind === 'skill' ? '技能' : '插件') + '</span>'
467
+ + statePill(item.state)
468
+ + (item.legacy ? ' <span class="tag">旧版安装</span>' : '')
469
+ + '</div><div class="skill-sub">'
470
+ + (item.version ? esc(item.version) : '')
428
471
  + (item.target_version && item.target_version !== item.version ? ' → ' + esc(item.target_version) : '')
429
472
  + (item.error ? '<br><span style="color:var(--err)">' + esc(item.error) + '</span>' : '')
430
473
  + '</div></div>' + button + '</div>';
431
- }).join('') + (!entries.length ? '<div class="skill-sub">当前清单为空</div>' : '');
432
- if (body.dataset.fingerprint !== html) {
433
- body.dataset.fingerprint = html; body.innerHTML = html;
434
- body.querySelectorAll('[data-default-action]').forEach((btn) => {
435
- const request = () => fetch('api/defaults/' + btn.dataset.defaultAction, { method: 'POST',
436
- headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kind: btn.dataset.kind,
437
- name: btn.dataset.name, confirm: btn.dataset.defaultAction === 'apply' }) });
438
- btn.onclick = () => btn.dataset.defaultAction === 'apply'
439
- ? confirmThen(btn, '替换当前版本?', request, '已开始应用,请查看同步状态')
440
- : withLoading(btn, request, '已开始重试,请查看同步状态');
441
- });
442
- const repair = body.querySelector('#defaults-repair');
443
- if (repair) repair.onclick = async () => {
474
+ };
475
+ const bindRepair = (el) => {
476
+ const repair = el.querySelector('#defaults-repair');
477
+ if (!repair) return;
478
+ repair.onclick = async () => {
444
479
  if (repair.dataset.confirming !== '1') {
445
480
  repair.dataset.confirming = '1';
446
481
  repair.dataset.originalLabel = repair.textContent;
@@ -470,8 +505,31 @@ const renderDefaults = (s) => {
470
505
  await refresh();
471
506
  }
472
507
  };
473
- }
474
- $('defaults-sync-btn').disabled = config.running;
508
+ };
509
+ // 按类型各归其位:待处理插件进插件 Tab、待处理技能进技能 Tab,只列非已符合
510
+ // 项;5s 轮询高频调用,块内容没变时靠指纹跳过重绘(不打断进行中的点击)
511
+ const renderBlock = (id, list) => {
512
+ const el = $(id);
513
+ // 插件待处理项:已进插件列表的同名条目不在这里重复出现(合并进那一行)
514
+ const pending = (list || []).filter((item) => item.state !== 'current'
515
+ && !(id === 'defaults-plugins' && listedPluginNames.has(item.name)));
516
+ const legacy = id === 'defaults-skills' && legacyCount
517
+ ? '<div class="skill-sub"><button id="defaults-repair" class="btn small ghost"'
518
+ + (config.running ? ' disabled' : '') + '>重新应用旧版安装的技能(' + legacyCount + ')</button>'
519
+ + ' 技能目录会按平台当前版本覆盖</div>'
520
+ : '';
521
+ const html = (pending.length || legacy)
522
+ ? '<div class="skill-sub" style="margin-top:10px">平台同步待处理</div>'
523
+ + pending.map(rowHtml).join('') + legacy
524
+ : '';
525
+ if (el.dataset.fingerprint !== html) { el.dataset.fingerprint = html; el.innerHTML = html; bindDefaultsActions(el); bindRepair(el); }
526
+ };
527
+ renderBlock('defaults-plugins', config.plugins);
528
+ renderBlock('defaults-skills', config.skills);
529
+ // 待处理集合变化(新失败/恢复)→ 让已加载的插件列表行重新合并;
530
+ // renderPlugins 侧会先对齐 mergedPendingSig,不会互相触发死循环
531
+ const pendingSig = defaultsPendingSig();
532
+ if (renderPlugins._loaded && pendingSig !== mergedPendingSig) void renderPlugins();
475
533
  };
476
534
  const renderSkills = (s) => {
477
535
  const skillhubEnabled = s.capabilities?.channels?.skillhub?.enabled !== false;
@@ -1044,8 +1102,14 @@ const renderPlugins = async () => {
1044
1102
  }
1045
1103
  // 渲染成功即标记已加载:避免切 tab 重复拉取(outdated 查询有网络开销)
1046
1104
  renderPlugins._loaded = true;
1105
+ // 同名合并登记(见 renderDefaults 顶部说明):已列名的插件,其清单同步
1106
+ // 状态合并进本行;待处理块不再单列一条
1107
+ listedPluginNames = new Set(data.plugins.map((plugin) => plugin.name));
1108
+ const pendingByN = new Map(defaultsPendingPlugins.map((item) => [item.name, item]));
1047
1109
  if (!data.plugins?.length) {
1048
1110
  list.innerHTML = '<div class="skill-sub">暂无已安装插件;内置插件缺失时会在 dsh 启动时自动恢复。</div>';
1111
+ mergedPendingSig = defaultsPendingSig();
1112
+ if (lastStatus) renderDefaults(lastStatus);
1049
1113
  return;
1050
1114
  }
1051
1115
  const rows = data.plugins.map((plugin) => {
@@ -1065,6 +1129,15 @@ const renderPlugins = async () => {
1065
1129
  const builtinTag = plugin.builtin
1066
1130
  ? '<span class="tag">受管</span>'
1067
1131
  : '';
1132
+ // 清单同步态合并:同名待处理插件在本行直接给出状态/原因/动作
1133
+ const pending = pendingByN.get(plugin.name);
1134
+ const syncPill = pending
1135
+ ? (['failed', 'conflict'].includes(pending.state)
1136
+ ? '<span class="pill" style="font-size:11px;background:var(--err-weak);color:var(--err)">同步'
1137
+ + esc(DEFAULTS_STATE_LABELS[pending.state] || pending.state) + '</span>'
1138
+ : '<span class="pill warn" style="font-size:11px">同步'
1139
+ + esc(DEFAULTS_STATE_LABELS[pending.state] || pending.state) + '</span>')
1140
+ : '';
1068
1141
  const version = plugin.latest
1069
1142
  ? esc(plugin.version) + ' → <b>' + esc(plugin.latest) + '</b>'
1070
1143
  : esc(plugin.version || '—');
@@ -1084,17 +1157,25 @@ const renderPlugins = async () => {
1084
1157
  actions.push('<button class="btn small danger" data-remove="' + esc(plugin.name) + '">卸载</button>');
1085
1158
  }
1086
1159
  }
1160
+ if (pending && ['failed', 'report_pending', 'missing'].includes(pending.state)) {
1161
+ actions.push('<button class="btn small ghost" data-default-action="retry" data-kind="plugin" data-name="' + esc(plugin.name) + '">重试</button>');
1162
+ } else if (pending && ['conflict', 'pending'].includes(pending.state)) {
1163
+ actions.push('<button class="btn small ghost" data-default-action="apply" data-kind="plugin" data-name="' + esc(plugin.name) + '">确认应用</button>');
1164
+ }
1087
1165
  const actionHtml = actions.length
1088
1166
  ? '<div style="display:flex;gap:6px">' + actions.join('') + '</div>'
1089
1167
  : '';
1090
1168
  return '<div class="skill-item"><div class="skill-info"><div class="skill-name">'
1091
- + esc(plugin.name) + builtinTag + statePill
1169
+ + esc(plugin.name) + builtinTag + statePill + syncPill
1092
1170
  + '</div><div class="skill-sub">' + desc + esc(plugin.source) + ' · ' + version
1093
1171
  + (plugin.registered ? ' · 已启用,重启后生效' : plugin.bundled ? ' · 已安装,未启用当前 profile(「升级」或重装可启用)' : '')
1172
+ + (pending ? ' · 平台同步 ' + esc(pending.version || '') + (pending.target_version && pending.target_version !== pending.version ? ' → ' + esc(pending.target_version) : '') : '')
1173
+ + (pending?.error ? '<br><span style="color:var(--err)">' + esc(pending.error) + '</span>' : '')
1094
1174
  + '</div></div>' + actionHtml + '</div>';
1095
1175
  });
1096
1176
  // 指纹保护(与 renderSkills 同思路):内容没变不碰 innerHTML——升级后
1097
- // 的定向刷新、切 tab 重入都不会重建按钮节点(打断卸载二次确认)或闪动
1177
+ // 的定向刷新、切 tab 重入都不会重建按钮节点(打断卸载二次确认)或闪动。
1178
+ // 清单同步态参与指纹:失败/恢复会让行内容变化
1098
1179
  const fingerprint = data.plugins.map((plugin) => [
1099
1180
  plugin.name,
1100
1181
  plugin.version || '',
@@ -1102,6 +1183,10 @@ const renderPlugins = async () => {
1102
1183
  plugin.registered ? 1 : 0,
1103
1184
  plugin.bundled ? 1 : 0,
1104
1185
  plugin.self_managed ? 1 : 0,
1186
+ pendingByN.get(plugin.name)
1187
+ ? [pendingByN.get(plugin.name).state, pendingByN.get(plugin.name).target_version || '',
1188
+ pendingByN.get(plugin.name).error || ''].join('~')
1189
+ : '',
1105
1190
  ].join(':')).join('|');
1106
1191
  if (list.dataset.fingerprint !== fingerprint) {
1107
1192
  list.dataset.fingerprint = fingerprint;
@@ -1125,6 +1210,12 @@ const renderPlugins = async () => {
1125
1210
  body: JSON.stringify({ name: btn.dataset.remove }),
1126
1211
  }).then(() => renderPlugins()), '插件已卸载');
1127
1212
  });
1213
+ // 合并进插件行的同步动作按钮(与待处理块同一契约)
1214
+ bindDefaultsActions(list);
1215
+ // 对齐「已合并的待处理集合」:renderDefaults 的变化触发到此为止(不会回环);
1216
+ // 列名登记可能让待处理块的排除集变化,定向重绘一次
1217
+ mergedPendingSig = defaultsPendingSig();
1218
+ if (lastStatus) renderDefaults(lastStatus);
1128
1219
  };
1129
1220
  // ==about-upgrade-begin==
1130
1221
  // 关于页版本行:aboutSelf(GET api/plugin/self)有值时展示升级入口或
@@ -1403,7 +1494,7 @@ $('skillhub-sync-btn').onclick = () => withLoading(
1403
1494
  () => fetch('api/skillhub/sync', { method: 'POST' }),
1404
1495
  'SkillHub 配置已同步',
1405
1496
  );
1406
- // 手动同步套餐技能:文案按结果给(装了几个),失败由 withLoading 展示
1497
+ // 手动同步技能:文案按结果给(装了几个),失败由 withLoading 展示
1407
1498
  $('skill-sync-btn').onclick = () => withLoading(
1408
1499
  $('skill-sync-btn'),
1409
1500
  async () => {
@@ -1514,9 +1605,10 @@ export function statusPageHtml() {
1514
1605
  <div class="card" id="skills-card">
1515
1606
  <h3>平台技能 <span class="muted">套餐自动同步</span></h3>
1516
1607
  <div id="skills-list"></div>
1608
+ <div id="defaults-skills"></div>
1517
1609
  <div class="hint" id="skills-meta"></div>
1518
1610
  <div class="row" style="margin-top:8px">
1519
- <button id="skill-sync-btn" class="btn ghost small" data-loading="同步中…">同步套餐技能</button>
1611
+ <button id="skill-sync-btn" class="btn ghost small" data-loading="同步中…">同步技能</button>
1520
1612
  </div>
1521
1613
  <div class="search" style="margin-top:12px">
1522
1614
  <input id="search-input" placeholder="搜索技能市场,如:数据 / 报告 / lark" />
@@ -1532,11 +1624,11 @@ export function statusPageHtml() {
1532
1624
 
1533
1625
  <div class="tabpane" id="pane-plugins" hidden>
1534
1626
  <div class="card">
1535
- <h3>平台默认能力 <button id="defaults-sync-btn" class="btn ghost small" style="margin-left:auto">检查并更新</button></h3>
1536
- <div id="defaults-body" aria-live="polite"><div class="skill-sub">检查中…</div></div>
1537
- </div>
1538
- <div class="card">
1539
- <h3>dsh 插件 <span class="muted">当前 profile 已安装的插件</span></h3>
1627
+ <h3>dsh 插件 <span class="muted">当前 profile 已安装的插件</span>
1628
+ <button id="defaults-sync-btn" class="btn ghost small" style="margin-left:auto" hidden>检查并更新</button>
1629
+ </h3>
1630
+ <div id="defaults-meta" aria-live="polite"></div>
1631
+ <div id="defaults-plugins"></div>
1540
1632
  <div id="plugins-list"></div>
1541
1633
  <div class="hint" id="plugins-hint">受管技能和插件始终更新到最新发布版本;仅管理员停用后可卸载受管插件。</div>
1542
1634
  </div>
package/lib/proc.js CHANGED
@@ -93,7 +93,19 @@ function extraBinDirs() {
93
93
  // 无 nvm:跳过,走后面的通用目录
94
94
  }
95
95
  dirs.push(join(home, '.local/bin'), '/opt/homebrew/bin', '/usr/local/bin');
96
+ // pnpm 独立安装与 pnpm -g 全局 bin 的默认落点(darwin PNPM_HOME=
97
+ // ~/Library/pnpm,兼容 bin 子目录布局;Linux 默认 ~/.local/share/pnpm):
98
+ // GUI 贫瘠 PATH 下 pnpm 装完 lark-cli 要能立即探测到
99
+ dirs.push(
100
+ join(home, 'Library', 'pnpm'),
101
+ join(home, 'Library', 'pnpm', 'bin'),
102
+ join(home, '.local', 'share', 'pnpm'),
103
+ );
96
104
  }
105
+ // 桌面壳自带真实 Node 二进制的 bin 目录(DeepSeek Harness runtime):
106
+ // 无 nvm/brew/node 的全新机器上 node 由宿主兜底(排在真工具链之后)
107
+ const bundled = desktopBundledRuntime();
108
+ if (bundled?.node) dirs.push(dirname(bundled.node));
97
109
  // DSH Desktop shim 目录固定排在兑底列表末尾:这些 shim 是 Electron 当
98
110
  // node 用的包装(见 desktopShimDirs),真工具链存在时应让位。
99
111
  dirs.push(...desktopShimDirs());
@@ -106,37 +118,51 @@ function extraBinDirs() {
106
118
  * node 环境但 lark-cli 安装仍报「未找到 npm」。macOS 位
107
119
  * ~/Library/Application Support/DSH Desktop/cli/<profile hash>/bin;
108
120
  * Windows 实测在 Roaming 下(真实用户机报错的命令路径前缀就是
109
- * C:\Users\<user>\AppData\Roaming\DSH…),Local 也一并扫。两种布局都
110
- * 兼容:shims/ 直挂、cli/<hash>/bin。独立成函数并单独缓存:extraBinDirs
111
- * 引用之外,execResolved 还要用它把这些 shim 目录排到子进程 PATH 末尾。 */
121
+ * C:\Users\<user>\AppData\Roaming\DSH…),Local 也一并扫。两代品牌的
122
+ * userData 目录都扫(老 DSH Desktop 与新 DeepSeek Harness 的
123
+ * @deepseek-ai/dsh-desktop)。两种布局都兼容:shims/ 直挂、cli/<hash>/bin。
124
+ * 独立成函数并单独缓存:extraBinDirs 引用之外,execResolved 还要用它把
125
+ * 这些 shim 目录排到子进程 PATH 末尾。 */
112
126
  let desktopShimDirsCache = null;
113
- export function desktopShimDirs() {
114
- if (desktopShimDirsCache !== null) return desktopShimDirsCache;
127
+ export function desktopShimDirs(options) {
128
+ // 传 options 的是纯探测调用(测试注入假环境),不进进程缓存
129
+ const injected = options !== undefined && options !== null;
130
+ if (!injected && desktopShimDirsCache !== null) return desktopShimDirsCache;
131
+ const {
132
+ home = homedir(),
133
+ platform = process.platform,
134
+ env = process.env,
135
+ exists = existsSync,
136
+ readdir = readdirSync,
137
+ } = options || {};
115
138
  const dirs = [];
116
- const home = homedir();
117
- const desktopRoots = process.platform === 'win32'
139
+ const roaming = env.APPDATA || join(home, 'AppData', 'Roaming');
140
+ const local = env.LOCALAPPDATA || join(home, 'AppData', 'Local');
141
+ const desktopRoots = platform === 'win32'
118
142
  ? [
119
- join(process.env.APPDATA || join(home, 'AppData', 'Roaming'), 'DSH Desktop'),
120
- join(process.env.LOCALAPPDATA || join(home, 'AppData', 'Local'), 'DSH Desktop'),
143
+ join(roaming, 'DSH Desktop'),
144
+ join(local, 'DSH Desktop'),
145
+ join(roaming, '@deepseek-ai', 'dsh-desktop'),
146
+ join(local, '@deepseek-ai', 'dsh-desktop'),
121
147
  ]
122
- : [join(home, 'Library', 'Application Support', 'DSH Desktop')];
148
+ : [
149
+ join(home, 'Library', 'Application Support', 'DSH Desktop'),
150
+ join(home, 'Library', 'Application Support', '@deepseek-ai', 'dsh-desktop'),
151
+ ];
152
+ const hasNodeShim = (dir) => exists(join(dir, 'node')) || exists(join(dir, 'node.exe')) || exists(join(dir, 'node.cmd'));
123
153
  for (const root of desktopRoots) {
124
154
  try {
125
155
  const shimDir = join(root, 'shims');
126
- if (existsSync(join(shimDir, 'node')) || existsSync(join(shimDir, 'node.exe')) || existsSync(join(shimDir, 'node.cmd'))) {
127
- dirs.push(shimDir);
128
- }
129
- for (const entry of readdirSync(join(root, 'cli'))) {
156
+ if (hasNodeShim(shimDir)) dirs.push(shimDir);
157
+ for (const entry of readdir(join(root, 'cli'))) {
130
158
  const shimBin = join(root, 'cli', entry, 'bin');
131
- if (existsSync(join(shimBin, 'node')) || existsSync(join(shimBin, 'node.exe')) || existsSync(join(shimBin, 'node.cmd'))) {
132
- dirs.push(shimBin);
133
- }
159
+ if (hasNodeShim(shimBin)) dirs.push(shimBin);
134
160
  }
135
161
  } catch {
136
162
  // 目录不存在:跳过
137
163
  }
138
164
  }
139
- desktopShimDirsCache = dirs;
165
+ if (!injected) desktopShimDirsCache = dirs;
140
166
  return dirs;
141
167
  }
142
168
 
@@ -171,6 +197,110 @@ export function desktopShimEnvDefaults({
171
197
  return defaults;
172
198
  }
173
199
 
200
+ /** DeepSeek Harness / DSH Desktop 桌面壳自带的 Node + pnpm 工具链。官方应用
201
+ * 在 <安装目录>/Contents/Resources(win32: <安装目录>/resources)下发布:
202
+ * runtime/primary-runtime/dependencies/node/bin/node 真实 Node 二进制
203
+ * runtime/pnpm/bin/pnpm.mjs pnpm 11
204
+ * 宿主把它交给 dsh 核心(profileContext.packageManager)做装包,但插件宿主
205
+ * 进程的 PATH 里没有——全新 macOS(无 nvm/brew/node,只装了桌面应用)上点
206
+ * 「安装」lark-cli 只能得到「请先安装 Node.js」(真实用户机实测)。这里从
207
+ * execPath/resourcesPath/argv 反推同一套工具链:纯文件系统判定,不依赖宿主
208
+ * 注入任何变量。传 options 的是纯探测调用(测试注入假环境),不进缓存。 */
209
+ let desktopBundledRuntimeCache;
210
+ export function desktopBundledRuntime(options) {
211
+ const injected = options !== undefined && options !== null;
212
+ if (!injected && desktopBundledRuntimeCache !== undefined) return desktopBundledRuntimeCache;
213
+ const {
214
+ execPath = process.execPath,
215
+ argv = process.argv,
216
+ platform = process.platform,
217
+ resourcesPath = process.resourcesPath,
218
+ exists = existsSync,
219
+ } = options || {};
220
+ const nodeName = platform === 'win32' ? 'node.exe' : 'node';
221
+ const roots = [];
222
+ const push = (value) => {
223
+ if (typeof value === 'string' && value && !roots.includes(value)) roots.push(value);
224
+ };
225
+ push(resourcesPath);
226
+ if (typeof execPath === 'string' && execPath) {
227
+ // 形态一:Electron 壳可执行文件(darwin <App>/Contents/MacOS/<exe>)
228
+ push(join(dirname(execPath), '..', 'Resources'));
229
+ push(join(dirname(execPath), 'resources'));
230
+ // 形态二:真实 node(<Res>/runtime/primary-runtime/dependencies/node/bin)
231
+ let dir = dirname(execPath);
232
+ for (let i = 0; i < 6 && dir && dir !== dirname(dir); i += 1) {
233
+ push(join(dir, 'Resources'));
234
+ push(join(dir, 'resources'));
235
+ dir = dirname(dir);
236
+ }
237
+ }
238
+ // 形态三:宿主 argv 里直接带 runtime 布局的路径(桌面壳把 pnpm 入口与
239
+ // node 启动目录作为启动参数传给宿主进程,布局演进时按路径特征反推)
240
+ for (const item of Array.isArray(argv) ? argv : []) {
241
+ const value = String(item || '').replace(/\\/g, '/');
242
+ const pnpmMatch = value.match(/^(.+?)\/runtime\/pnpm\/bin\/pnpm\.(?:mjs|cjs)$/);
243
+ if (pnpmMatch) push(pnpmMatch[1]);
244
+ const runtimeMatch = value.match(/^(.+?)\/runtime\/(?:bin|primary-runtime)$/);
245
+ if (runtimeMatch) push(runtimeMatch[1]);
246
+ }
247
+ const finish = (runtime) => {
248
+ if (!injected) desktopBundledRuntimeCache = runtime;
249
+ return runtime;
250
+ };
251
+ const seen = new Set();
252
+ for (const root of roots) {
253
+ if (!root || seen.has(root)) continue;
254
+ seen.add(root);
255
+ const pnpm = [
256
+ join(root, 'runtime', 'pnpm', 'bin', 'pnpm.mjs'),
257
+ join(root, 'runtime', 'primary-runtime', 'dependencies', 'pnpm', 'bin', 'pnpm.mjs'),
258
+ join(root, 'app', 'node_modules', 'pnpm', 'bin', 'pnpm.mjs'),
259
+ ].find((entry) => exists(entry));
260
+ if (!pnpm) continue;
261
+ const node = [
262
+ join(root, 'runtime', 'primary-runtime', 'dependencies', 'node', 'bin', nodeName),
263
+ join(root, 'runtime', 'bin', nodeName),
264
+ ].find((entry) => exists(entry)) || null;
265
+ return finish({ resources: root, node, pnpm });
266
+ }
267
+ return finish(null);
268
+ }
269
+
270
+ /** 兜底命令映射:PATH 与常规兑底目录都没有时,把 pnpm/node 指到桌面壳自带
271
+ * 工具链(pnpm 是 .mjs 脚本入口,由 scriptInvocation 用自带 node 执行)。
272
+ * 不映射 dsh——桌面壳内的 dsh CLI 明确拒绝操作 desktop profile(见
273
+ * desktop.js),插件管理走 pluginManager 服务,不缺这个兜底。 */
274
+ export function bundledCommandFor(tool, runtime = desktopBundledRuntime()) {
275
+ if (!runtime) return null;
276
+ if (tool === 'pnpm') return runtime.pnpm;
277
+ if (tool === 'node') return runtime.node;
278
+ return null;
279
+ }
280
+
281
+ /** 脚本入口的执行形态(纯函数,供测试):resolveCommandOrNull 会把 pnpm 指到
282
+ * 桌面壳自带的 pnpm.mjs——它不是可执行文件,必须经解释器运行。优先自带
283
+ * 真实 node;没有时退化 Electron-as-node(插件宿主本身就是这个形态)。
284
+ * 返回 null 表示 resolved 可直接执行。 */
285
+ export function scriptInvocation(resolved, args, {
286
+ runtime = desktopBundledRuntime(),
287
+ electron = process.versions?.electron,
288
+ execPath = process.execPath,
289
+ } = {}) {
290
+ if (!/\.(?:mjs|cjs)$/i.test(String(resolved || ''))) return null;
291
+ if (runtime?.node) {
292
+ // Windows 的 node 启动器可能是 .cmd shim(runtime/bin/node.cmd 要求
293
+ // DSH_DESKTOP_NODE_EXECUTABLE 指向真实可执行文件,与
294
+ // desktopShimEnvDefaults 同一约定);真实 node.exe 则无需任何变量
295
+ const env = /\.cmd$/i.test(runtime.node) ? { DSH_DESKTOP_NODE_EXECUTABLE: execPath } : {};
296
+ return { file: runtime.node, args: [resolved, ...args], env };
297
+ }
298
+ if (electron) {
299
+ return { file: execPath, args: [resolved, ...args], env: { ELECTRON_RUN_AS_NODE: '1' } };
300
+ }
301
+ return null;
302
+ }
303
+
174
304
  /** 组装子进程 PATH(纯函数,供测试):非 shim 兑底目录排最前(GUI 贫瘠
175
305
  * PATH 也能解析到真工具),原有 PATH 居中,DSH Desktop shim 目录垫底——
176
306
  * shim 是 Electron 包装,真 node/pnpm 存在时优先用真的;贫瘠机器上原
@@ -188,6 +318,7 @@ export function childPathWithExtras(originalPath, extras, shimDirs) {
188
318
  export function resetExtraBinDirsCache() {
189
319
  extraBinDirsCache = null;
190
320
  desktopShimDirsCache = null;
321
+ desktopBundledRuntimeCache = undefined;
191
322
  preferredCommandCache.clear();
192
323
  }
193
324
 
@@ -229,18 +360,31 @@ export function pathEnvKey(env, platform) {
229
360
 
230
361
  /** 命令解析:PATH 里找不到时从兑底目录解析绝对路径(找不到返回 null)。
231
362
  * 所有 execFile 调用统一过这里;返回 null 时调用方走「未找到命令」的
232
- * 中文指引分支。绝对路径原样返回(ccpg 安装器等)。 */
363
+ * 中文指引分支。绝对路径原样返回(ccpg 安装器等)。优先级:PATH 与常规
364
+ * 兑底目录 → 桌面壳自带工具链(pnpm/node)→ 桌面 shim 目录垫底——shim
365
+ * 是 Electron 包装且可能随应用改名而指向已卸载的旧路径(真实用户机实测
366
+ * DSH Desktop → DeepSeek Harness 后残留指向已删除的 /Applications/DSH
367
+ * Desktop.app),真工具链存在时永远让位。 */
233
368
  export function resolveCommandOrNull(tool) {
234
369
  if (isAbsolute(tool)) return existsSync(tool) ? tool : null;
235
- const pathDirs = String(process.env.PATH || '').split(pathDelimiter).filter(Boolean);
236
370
  const suffixes = candidateSuffixes(process.platform);
237
- for (const dir of [...pathDirs, ...extraBinDirs()]) {
238
- for (const suffix of suffixes) {
239
- const candidate = join(dir, tool + suffix);
240
- if (existsSync(candidate)) return candidate;
371
+ const lookup = (dirs) => {
372
+ for (const dir of dirs) {
373
+ for (const suffix of suffixes) {
374
+ const candidate = join(dir, tool + suffix);
375
+ if (existsSync(candidate)) return candidate;
376
+ }
241
377
  }
242
- }
243
- return null;
378
+ return null;
379
+ };
380
+ const pathDirs = String(process.env.PATH || '').split(pathDelimiter).filter(Boolean);
381
+ const shimDirs = desktopShimDirs();
382
+ const shimSet = new Set(shimDirs);
383
+ const found = lookup([...pathDirs, ...extraBinDirs().filter((dir) => !shimSet.has(dir))]);
384
+ if (found) return found;
385
+ const bundled = bundledCommandFor(tool);
386
+ if (bundled && existsSync(bundled)) return bundled;
387
+ return lookup(shimDirs);
244
388
  }
245
389
 
246
390
  /** 与 resolveCommandOrNull 同口径,但返回全部按序去重候选。python 这类
@@ -310,9 +454,13 @@ function composeChildEnv(extraEnv) {
310
454
  }
311
455
 
312
456
  function execResolvedPath(resolved, args, { timeoutMs, cwd, env, signal } = {}) {
313
- const childEnv = composeChildEnv(env);
314
- const shim = cmdShimInvocation(process.platform, resolved, args);
315
- return execFileAsync(shim ? shim.file : resolved, shim ? shim.args : args, {
457
+ // 脚本入口(桌面壳自带 pnpm.mjs)换成解释器执行形态
458
+ const script = scriptInvocation(resolved, args);
459
+ const file = script ? script.file : resolved;
460
+ const argv = script ? script.args : args;
461
+ const childEnv = composeChildEnv({ ...(script?.env || {}), ...(env || {}) });
462
+ const shim = cmdShimInvocation(process.platform, file, argv);
463
+ return execFileAsync(shim ? shim.file : file, shim ? shim.args : argv, {
316
464
  timeout: timeoutMs,
317
465
  signal,
318
466
  killSignal: 'SIGKILL',
@@ -447,9 +595,15 @@ export async function runCliTool(tool, args, { timeoutMs = 300000, cwd, env, sig
447
595
  /** 工具缺失时的中文指引:不同工具有不同的安装出路。 */
448
596
  function missingCommandGuidance(tool) {
449
597
  if (tool === 'npm' || tool === 'npx') {
598
+ // 触达这里的机器连桌面壳自带工具链都没有(scriptInvocation/兜底映射
599
+ // 均未命中)——重启通常能恢复探测,再给手动安装出路。社区桌面端
600
+ // (DSH Desktop/NEXT)与官方壳分开点名,避免指错重启对象
601
+ const brand = /DSH (?:Desktop|NEXT)\.(?:app|exe)/i.test(process.execPath)
602
+ ? 'DSH Desktop'
603
+ : 'DeepSeek Harness';
450
604
  return process.platform === 'win32'
451
- ? '未找到 npm/npx 命令:lark-cli 依赖 Node.js 环境,请先从 https://nodejs.org 下载安装 Node.js(LTS 版,安装时保持默认选项),安装完成后重启 DSH Desktop 再点「安装」'
452
- : '未找到 npm/npx 命令:lark-cli 依赖 Node.js 环境,请先安装 Node.js(推荐 nvm:nvm install --lts),安装完成后重启 DSH Desktop 再点「安装」';
605
+ ? `未找到 npm/npx 命令:lark-cli 依赖 Node.js 环境,请先从 https://nodejs.org 下载安装 Node.js(LTS 版,安装时保持默认选项),安装完成后重启 ${brand} 再点「安装」`
606
+ : `未找到 npm/npx 命令:lark-cli 依赖 Node.js 环境,且未探测到 ${brand} 自带的 Node 运行时。请重启 ${brand} 后重试;仍失败时安装 Node.js(推荐 nvm:nvm install --lts)后再点「安装」`;
453
607
  }
454
608
  if (tool === 'pnpm') {
455
609
  return '未找到 pnpm 命令,无法查询插件最新版本。可安装 pnpm(npm install -g pnpm)后重启 DSH Desktop;不影响插件正常使用,仅影响升级提示。';
@@ -983,10 +1137,27 @@ function rememberGlobalBinDir(dir) {
983
1137
  extraBinDirsCache = [value, ...existing.filter((item) => item.toLowerCase() !== value.toLowerCase())];
984
1138
  }
985
1139
 
1140
+ /** pnpm 全局 home(PNPM_HOME)的平台默认值。老实现只区分 win32 与「其余
1141
+ * 平台」——macOS/Linux 上退化成 Windows 目录(~/AppData/Local/pnpm 在
1142
+ * macOS 是无效路径,装出来的 lark-cli 谁也找不到)。pnpm 官方默认:
1143
+ * win32 %LOCALAPPDATA%\pnpm、darwin ~/Library/pnpm、
1144
+ * linux ~/.local/share/pnpm;显式 PNPM_HOME 永远优先。 */
1145
+ export function pnpmHomeDefault({ platform = process.platform, env = process.env, home = homedir() } = {}) {
1146
+ const configured = String(env.PNPM_HOME || '').trim();
1147
+ if (configured) return configured;
1148
+ if (platform === 'win32') {
1149
+ return join(env.LOCALAPPDATA || join(home, 'AppData', 'Local'), 'pnpm');
1150
+ }
1151
+ if (platform === 'darwin') return join(home, 'Library', 'pnpm');
1152
+ return join(home, '.local', 'share', 'pnpm');
1153
+ }
1154
+
986
1155
  async function installLarkCliWithPnpm(timeoutMs) {
987
- const pnpmHome = process.env.PNPM_HOME
988
- || join(process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local'), 'pnpm');
1156
+ const pnpmHome = pnpmHomeDefault();
989
1157
  const pnpmEnv = { PNPM_HOME: pnpmHome };
1158
+ // pnpm -g 前置校验要求全局 bin 目录在 PATH 中:先记住再装,避免第一次
1159
+ // 必失败(错误输出折行解析的重试兜底保留,覆盖自定义安装位)
1160
+ rememberGlobalBinDir(pnpmHome);
990
1161
  const pnpmInstall = () => runCliTool(
991
1162
  'pnpm',
992
1163
  ['add', '-g', '@larksuite/cli@latest'],
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-agentone",
3
- "version": "0.5.43",
4
- "description": "AgentOne 平台集成:平台模型、SkillHub 技能、套餐与 CLI;修复热重载后路由未释放导致的启用失败",
3
+ "version": "1.0.0",
4
+ "description": "AgentOne 平台集成:平台模型、SkillHub 技能、套餐与 CLI;平台清单融入技能/插件双 Tab,无 Node 机器用桌面壳自带工具链装 lark-cli",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "smoke": "node scripts/env-matrix-smoke.mjs"