dsh-agentone 0.5.38 → 0.5.40

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/page.js CHANGED
@@ -392,6 +392,46 @@ const renderPlan = async () => {
392
392
  ).then(() => renderPlan());
393
393
  });
394
394
  };
395
+ const renderDefaults = (s) => {
396
+ const body = $('defaults-body');
397
+ const config = s.defaults;
398
+ const labels = { current: '已符合', missing: '待安装', pending: '待应用', running: '同步中',
399
+ failed: '失败', conflict: '本机冲突', report_pending: '已安装,待回报', restart_required: '已安装,重启后生效' };
400
+ const sources = { platform: '平台清单', compatibility: '旧平台兼容默认', cache: '本机缓存' };
401
+ if (!config || !s.logged_in) { body.innerHTML = '<div class="muted">登录后检查平台默认能力</div>'; return; }
402
+ const summary = '<div class="skill-sub">' + esc(sources[config.source] || '尚未检查')
403
+ + (config.revision !== null ? ' · 版本 ' + esc(config.revision) : '')
404
+ + (config.checked_at ? ' · ' + esc(new Date(config.checked_at).toLocaleString()) : '')
405
+ + (config.running ? ' · 正在检查' : '') + '</div>'
406
+ + (config.error || config.profile_error ? '<div class="skill-sub" style="color:var(--err)">' + esc(config.error || config.profile_error) + '</div>' : '');
407
+ const entries = [...(config.skills || []), ...(config.plugins || [])];
408
+ const html = summary + entries.map((item) => {
409
+ const action = ['failed', 'report_pending', 'missing'].includes(item.state) ? 'retry'
410
+ : ['conflict', 'pending'].includes(item.state) ? 'apply' : null;
411
+ const button = action ? '<button class="btn small ghost" data-default-action="' + action
412
+ + '" data-kind="' + item.kind + '" data-name="' + esc(item.name) + '"'
413
+ + (config.running ? ' disabled' : '') + '>' + (action === 'retry' ? '重试' : '确认应用') + '</button>' : '';
414
+ return '<div class="skill-item"><div class="skill-info"><div class="skill-name">'
415
+ + esc(item.description || item.name) + ' <span class="tag">' + (item.kind === 'skill' ? '技能' : '插件')
416
+ + '</span></div><div class="skill-sub">' + esc(item.name) + ' · ' + esc(labels[item.state] || item.state)
417
+ + (item.version ? ' · ' + esc(item.version) : '')
418
+ + (item.target_version && item.target_version !== item.version ? ' → ' + esc(item.target_version) : '')
419
+ + (item.error ? '<br><span style="color:var(--err)">' + esc(item.error) + '</span>' : '')
420
+ + '</div></div>' + button + '</div>';
421
+ }).join('') + (!entries.length ? '<div class="skill-sub">当前清单为空</div>' : '');
422
+ if (body.dataset.fingerprint !== html) {
423
+ body.dataset.fingerprint = html; body.innerHTML = html;
424
+ body.querySelectorAll('[data-default-action]').forEach((btn) => {
425
+ const request = () => fetch('api/defaults/' + btn.dataset.defaultAction, { method: 'POST',
426
+ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kind: btn.dataset.kind,
427
+ name: btn.dataset.name, confirm: btn.dataset.defaultAction === 'apply' }) });
428
+ btn.onclick = () => btn.dataset.defaultAction === 'apply'
429
+ ? confirmThen(btn, '替换当前版本?', request, '已开始应用,请查看同步状态')
430
+ : withLoading(btn, request, '已开始重试,请查看同步状态');
431
+ });
432
+ }
433
+ $('defaults-sync-btn').disabled = config.running;
434
+ };
395
435
  const renderSkills = (s) => {
396
436
  const skillhubEnabled = s.capabilities?.channels?.skillhub?.enabled !== false;
397
437
  // null = 平台拉取失败(区别于 [] 套餐确实没配技能),见 /api/status 组装
@@ -415,7 +455,9 @@ const renderSkills = (s) => {
415
455
  return;
416
456
  }
417
457
  const rows = list.map((skill) => {
418
- const tag = skill.source === 'plan' ? '<span class="tag">套餐</span>' : '';
458
+ const sources = skill.sources || (skill.source === 'plan' ? ['plan'] : []);
459
+ const tag = (sources.includes('plan') ? '<span class="tag">套餐</span>' : '')
460
+ + (sources.includes('builtin') ? '<span class="tag">平台默认</span>' : '');
419
461
  // 可升级:平台对比 latest 指纹/版本后给的结论,提示「v旧 → v新」;
420
462
  // latest 查询失败时平台降级 false,这里不额外猜测
421
463
  const upgradable = skill.installed && skill.upgrade_available;
@@ -966,13 +1008,13 @@ const renderPlugins = async () => {
966
1008
  // 标签——它永远不进 bundle 层,不能显示「未激活/重启生效」误导用户
967
1009
  const statePill = !plugin.version
968
1010
  ? '<span class="pill warn" style="font-size:11px">未安装</span>'
969
- : plugin.active
970
- ? '<span class="pill ok" style="font-size:11px">已激活</span>'
1011
+ : plugin.registered
1012
+ ? '<span class="pill ok" style="font-size:11px">已注册</span>'
971
1013
  : plugin.bundled
972
- ? '<span class="pill warn" style="font-size:11px">未激活</span>'
1014
+ ? '<span class="pill warn" style="font-size:11px">未注册</span>'
973
1015
  : '<span class="tag">依赖</span>';
974
1016
  const builtinTag = plugin.builtin
975
- ? '<span class="tag">内置</span>'
1017
+ ? '<span class="tag">受管</span>'
976
1018
  : '';
977
1019
  const version = plugin.latest
978
1020
  ? esc(plugin.version) + ' → <b>' + esc(plugin.latest) + '</b>'
@@ -999,7 +1041,7 @@ const renderPlugins = async () => {
999
1041
  return '<div class="skill-item"><div class="skill-info"><div class="skill-name">'
1000
1042
  + esc(plugin.name) + builtinTag + statePill
1001
1043
  + '</div><div class="skill-sub">' + desc + esc(plugin.source) + ' · ' + version
1002
- + (plugin.bundled && !plugin.active ? ' · 未在 bundle 层,重启 dsh 后生效' : '')
1044
+ + (plugin.registered ? ' · 已安装并注册,重启后生效' : plugin.bundled ? ' · 未注册到当前 profile' : '')
1003
1045
  + '</div></div>' + actionHtml + '</div>';
1004
1046
  });
1005
1047
  // 指纹保护(与 renderSkills 同思路):内容没变不碰 innerHTML——升级后
@@ -1008,7 +1050,7 @@ const renderPlugins = async () => {
1008
1050
  plugin.name,
1009
1051
  plugin.version || '',
1010
1052
  plugin.latest || '',
1011
- plugin.active ? 1 : 0,
1053
+ plugin.registered ? 1 : 0,
1012
1054
  plugin.bundled ? 1 : 0,
1013
1055
  plugin.self_managed ? 1 : 0,
1014
1056
  ].join(':')).join('|');
@@ -1272,6 +1314,7 @@ const refresh = async () => {
1272
1314
  lines.push('<div class="kv"><span class="k">环境变量</span><span class="v muted">已写入 agentone/skillhub.env(0600)</span></div>');
1273
1315
  $('skillhub-body').innerHTML = lines.join('');
1274
1316
  }
1317
+ renderDefaults(s);
1275
1318
  renderSkills(s);
1276
1319
  renderAbout(s);
1277
1320
  };
@@ -1302,6 +1345,9 @@ $('login-btn').onclick = async () => {
1302
1345
  btn.dataset.opened = '1';
1303
1346
  btn.textContent = '已打开授权窗口,完成后自动继续(可重开)';
1304
1347
  };
1348
+ $('defaults-sync-btn').onclick = () => withLoading($('defaults-sync-btn'),
1349
+ () => fetch('api/defaults/sync', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }),
1350
+ '已开始检查默认能力,请查看同步状态');
1305
1351
  $('sync-btn').onclick = () => syncModelNow($('sync-btn'));
1306
1352
  $('skillhub-sync-btn').onclick = () => withLoading(
1307
1353
  $('skillhub-sync-btn'),
@@ -1328,6 +1374,14 @@ logoutBtn.onclick = () => withLoading(
1328
1374
  () => fetch('api/logout', { method: 'POST' }),
1329
1375
  '已退出登录',
1330
1376
  );
1377
+ $('skill-direct-install').onclick = () => {
1378
+ const slug = $('skill-direct-slug').value.trim();
1379
+ if (!slug) { notify('请填写技能标识,例如 611/c611-ops-workbench', 'err'); return; }
1380
+ return skillAction(slug, 'install', $('skill-direct-install'), '已安装最新版本');
1381
+ };
1382
+ $('skill-direct-slug').addEventListener('keydown', (event) => {
1383
+ if (event.key === 'Enter') $('skill-direct-install').click();
1384
+ });
1331
1385
  $('search-btn').onclick = searchCatalog;
1332
1386
  $('search-input').addEventListener('keydown', (event) => {
1333
1387
  if (event.key === 'Enter') searchCatalog();
@@ -1420,14 +1474,22 @@ export function statusPageHtml() {
1420
1474
  <button id="search-btn" class="btn ghost small">搜索</button>
1421
1475
  </div>
1422
1476
  <div id="search-results"></div>
1477
+ <div class="search" style="margin-top:12px">
1478
+ <input id="skill-direct-slug" aria-label="技能标识" placeholder="命名空间/技能名,如 611/c611-ops-workbench" />
1479
+ <button id="skill-direct-install" class="btn ghost small" data-loading="安装中…">安装最新版本</button>
1480
+ </div>
1423
1481
  </div>
1424
1482
  </div>
1425
1483
 
1426
1484
  <div class="tabpane" id="pane-plugins" hidden>
1427
1485
  <div class="card">
1428
- <h3>dsh 插件 <span class="muted">镜像内置与自行安装的插件</span></h3>
1486
+ <h3>平台默认能力 <button id="defaults-sync-btn" class="btn ghost small" style="margin-left:auto">检查并更新</button></h3>
1487
+ <div id="defaults-body" aria-live="polite"><div class="skill-sub">检查中…</div></div>
1488
+ </div>
1489
+ <div class="card">
1490
+ <h3>dsh 插件 <span class="muted">当前 profile 已安装的插件</span></h3>
1429
1491
  <div id="plugins-list"></div>
1430
- <div class="hint" id="plugins-hint">标注「内置」的插件由镜像预装,不可卸载,可升级到最新版。更多插件可在 dsh 设置的插件市场里安装。</div>
1492
+ <div class="hint" id="plugins-hint">受管技能和插件始终更新到最新发布版本;仅管理员停用后可卸载受管插件。</div>
1431
1493
  </div>
1432
1494
  </div>
1433
1495
 
@@ -0,0 +1,56 @@
1
+ // 平台默认清单的公开契约;合法空清单不会退回兼容默认。
2
+ import { platformError, platformFetch } from './http.js';
3
+ import { canonicalSkillSlug } from './skill-state.js';
4
+ import { PROTECTED_PACKAGES, validatePackage } from './profile-plugins.js';
5
+
6
+ export function compatibilityDefaults(userId) {
7
+ return { schema_version: 1, revision: 0, user_id: userId, auto_sync: true,
8
+ skills_enabled: true, skill_registry: '', skills: [], plugins: [
9
+ { name: 'dsh-better-sidebar', version: 'latest', description: '侧边工作台', enabled: true, allow_build: ['node-pty'] },
10
+ { name: 'dsh-harness-one', version: 'latest', description: '可视化 AI 工作流', enabled: true, allow_build: ['node-pty'] },
11
+ { name: 'dshmarket', version: 'latest', description: 'DSH 插件市场', enabled: true, allow_build: ['node-pty'] },
12
+ { name: 'dsh-im', version: 'latest', description: '即时通讯集成', enabled: true, allow_build: [] },
13
+ ] };
14
+ }
15
+ function invalid() { throw new Error('平台默认能力配置格式异常,请联系管理员检查配置'); }
16
+ export function validatePlatformConfig(document, userId) {
17
+ if (!document || document.schema_version !== 1 || !Number.isSafeInteger(document.revision) || document.revision < 0
18
+ || typeof document.user_id !== 'string' || document.user_id !== userId
19
+ || typeof document.auto_sync !== 'boolean' || typeof document.skills_enabled !== 'boolean'
20
+ || typeof document.skill_registry !== 'string' || !Array.isArray(document.skills) || !Array.isArray(document.plugins)
21
+ || document.skills.length > 100 || document.plugins.length > 100) invalid();
22
+ const skills = document.skills.map((item) => {
23
+ if (!item || typeof item.description !== 'string' || item.description.length > 500 || typeof item.enabled !== 'boolean') invalid();
24
+ return { canonical_slug: canonicalSkillSlug(item.canonical_slug), description: item.description, enabled: item.enabled };
25
+ });
26
+ const plugins = document.plugins.map((item) => {
27
+ if (!item || typeof item.description !== 'string' || item.description.length > 500 || typeof item.enabled !== 'boolean'
28
+ || typeof item.version !== 'string' || !Array.isArray(item.allow_build)
29
+ || PROTECTED_PACKAGES.has(item.name)) invalid();
30
+ validatePackage(item.name, item.version, item.allow_build);
31
+ return { name: item.name, version: 'latest', description: item.description, enabled: item.enabled, allow_build: [...item.allow_build] };
32
+ });
33
+ if (new Set(skills.map((item) => item.canonical_slug)).size !== skills.length
34
+ || new Set(plugins.map((item) => item.name)).size !== plugins.length) invalid();
35
+ return { schema_version: 1, revision: document.revision, user_id: userId, auto_sync: document.auto_sync,
36
+ skills_enabled: document.skills_enabled, skill_registry: document.skill_registry,
37
+ skills: skills.filter((item) => item.enabled), plugins: plugins.filter((item) => item.enabled) };
38
+ }
39
+ export async function fetchPlatformConfig(platformUrl, token, userId, signal) {
40
+ const response = await platformFetch(`${platformUrl.replace(/\/+$/, '')}/api/plugin/v1/config`, {
41
+ signal, headers: { Authorization: `Bearer ${token}` },
42
+ });
43
+ if (response.status === 404) {
44
+ // 仅接受 FastAPI 缺路由的 JSON,不接受代理 HTML 404 或业务 404。
45
+ const body = response.headers.get('content-type')?.includes('application/json')
46
+ ? await response.json().catch(() => null) : null;
47
+ if (body?.detail === 'Not Found' && Object.keys(body).length === 1) {
48
+ return { document: compatibilityDefaults(userId), source: 'compatibility' };
49
+ }
50
+ throw new Error('默认能力接口不可用,请检查平台地址或升级后台');
51
+ }
52
+ if (!response.ok) throw await platformError(response, '获取默认能力配置失败,请稍后重试');
53
+ let document;
54
+ try { document = await response.json(); } catch { throw new Error('平台清单响应不是有效 JSON,请检查平台地址后重试'); }
55
+ return { document: validatePlatformConfig(document, userId), source: 'platform' };
56
+ }
package/lib/proc.js CHANGED
@@ -309,11 +309,12 @@ function composeChildEnv(extraEnv) {
309
309
  return childEnv;
310
310
  }
311
311
 
312
- function execResolvedPath(resolved, args, { timeoutMs, cwd, env } = {}) {
312
+ function execResolvedPath(resolved, args, { timeoutMs, cwd, env, signal } = {}) {
313
313
  const childEnv = composeChildEnv(env);
314
314
  const shim = cmdShimInvocation(process.platform, resolved, args);
315
315
  return execFileAsync(shim ? shim.file : resolved, shim ? shim.args : args, {
316
316
  timeout: timeoutMs,
317
+ signal,
317
318
  killSignal: 'SIGKILL',
318
319
  cwd,
319
320
  env: childEnv,
@@ -396,12 +397,13 @@ function isLarkCliTool(tool) {
396
397
  return ['lark-cli', 'lark-cli.cmd', 'lark-cli.exe'].includes(name);
397
398
  }
398
399
 
399
- export async function runCliTool(tool, args, { timeoutMs = 300000, cwd, env } = {}) {
400
+ export async function runCliTool(tool, args, { timeoutMs = 300000, cwd, env, signal } = {}) {
400
401
  try {
401
402
  const { stdout, stderr } = await execResolved(tool, args, {
402
403
  timeoutMs,
403
404
  cwd,
404
405
  env,
406
+ signal,
405
407
  });
406
408
  return { stdout, stderr };
407
409
  } catch (error) {
@@ -464,8 +466,8 @@ function missingCommandGuidance(tool) {
464
466
  }
465
467
 
466
468
  /** dsh plugin 子命令:转发给官方 CLI(profile 由调用方给出)。 */
467
- export function dshPluginCli(args, profile = 'web') {
468
- return runCliTool('dsh', ['plugin', '--profile', profile, ...args]);
469
+ export function dshPluginCli(args, profile = 'web', options = {}) {
470
+ return runCliTool('dsh', ['plugin', '--profile', profile, ...args], options);
469
471
  }
470
472
 
471
473
  /** pnpm 子命令(当前仅 view 查询);cwd 指向目标 profile 目录。 */
@@ -0,0 +1,143 @@
1
+ // 当前 profile 的插件操作;读取真实包和注册状态,写操作共享实例队列。
2
+ import { readFile } from 'node:fs/promises';
3
+ import { isAbsolute, join, relative, resolve } from 'node:path';
4
+ import { dshPluginCli, pnpmViewLatest } from './proc.js';
5
+ import { resolveDshHome } from './store.js';
6
+
7
+ export const NPM_NAME = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
8
+ export const EXACT_VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
9
+ export const PROTECTED_PACKAGES = new Set([
10
+ 'dsh-agentone', '@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@deepseek-ai/dsh-headless',
11
+ ]);
12
+ export function validatePackage(name, version = 'latest', allowBuild = []) {
13
+ if (typeof name !== 'string' || name.length > 214 || !NPM_NAME.test(name)) throw new Error('插件名不合法,请填写完整的 npm 包名');
14
+ if (version !== 'latest' && !EXACT_VERSION.test(version)) throw new Error('插件版本仅支持精确版本或 latest');
15
+ if (!Array.isArray(allowBuild) || allowBuild.length > 20 || allowBuild.some((entry) => typeof entry !== 'string' || entry.length > 214 || !NPM_NAME.test(entry))) {
16
+ throw new Error('安装期构建授权必须为明确的 npm 包名');
17
+ }
18
+ }
19
+ async function readJson(path) {
20
+ try { return JSON.parse(await readFile(path, 'utf8')); }
21
+ catch (error) { if (error.code === 'ENOENT') return null; throw new Error('插件配置文件无法读取,请检查本机 profile'); }
22
+ }
23
+ function validProfile(name) {
24
+ return typeof name === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,119}$/.test(name) && name !== 'node_modules';
25
+ }
26
+ export function createProfilePlugins({ ctx, config = {}, desktopRun, runCli = dshPluginCli, latest = pnpmViewLatest }) {
27
+ let queue = Promise.resolve();
28
+ let closed = false;
29
+ let policy = [];
30
+ function target() {
31
+ if (closed) throw new Error('插件已停止,请重新打开后再试');
32
+ const desktop = ctx.get?.('desktopProfiles');
33
+ const isDesktop = Boolean(desktop) || /DSH Desktop\.(?:app|exe)/i.test(process.execPath);
34
+ const name = desktop?.current?.name || (!isDesktop ? config.profileName : null);
35
+ if (!validProfile(name)) throw new Error('无法确定当前 DSH profile,请在 AgentOne 插件配置中填写 profileName 后重启');
36
+ const homeService = ctx.get?.('dshHomePath');
37
+ const home = resolve(config.dshHome || (typeof homeService === 'function' ? homeService() : homeService) || resolveDshHome());
38
+ return { home, name, dir: join(home, 'profiles', name), desktop: isDesktop };
39
+ }
40
+ function enqueue(operation) {
41
+ let expected;
42
+ try { expected = target(); } catch (error) { return Promise.reject(error); }
43
+ const result = queue.catch(() => {}).then(() => { check(expected); return operation(expected); });
44
+ queue = result.catch(() => {});
45
+ return result;
46
+ }
47
+ function check(expected, options = {}) {
48
+ options.signal?.throwIfAborted();
49
+ if (options.isCurrent && !options.isCurrent()) throw new Error('登录身份或配置已改变,请重新同步');
50
+ const actual = target();
51
+ if (actual.dir !== expected.dir || actual.desktop !== expected.desktop) throw new Error('当前 profile 已改变,请重新同步');
52
+ }
53
+ async function list() {
54
+ const current = target();
55
+ const manifest = await readJson(join(current.dir, 'package.json'));
56
+ const dependencies = manifest?.dependencies || {};
57
+ const bundles = manifest?.dsh?.profile?.bundles || [];
58
+ if (!Array.isArray(bundles)) throw new Error('当前 profile 的插件注册清单格式异常');
59
+ const names = [...new Set([...Object.keys(dependencies), ...bundles, ...policy.map((item) => item.name)])]
60
+ .filter((name) => !name.startsWith('@deepseek-ai/dsh-') || !PROTECTED_PACKAGES.has(name));
61
+ const plugins = [];
62
+ for (const name of names) {
63
+ validatePackage(name);
64
+ const spec = dependencies[name] ?? null;
65
+ const pkg = await readJson(join(current.dir, 'node_modules', name, 'package.json'));
66
+ const version = typeof pkg?.version === 'string' ? pkg.version : null;
67
+ const patch = pkg?.dsh?.bundle?.patch;
68
+ let bundled = false;
69
+ if (typeof patch === 'string' && patch && !isAbsolute(patch)) {
70
+ const packageRoot = join(current.dir, 'node_modules', name);
71
+ const patchPath = resolve(packageRoot, patch);
72
+ const inside = relative(packageRoot, patchPath);
73
+ if (inside && !inside.startsWith('..') && !isAbsolute(inside)) {
74
+ try { bundled = Boolean((await readFile(patchPath, 'utf8')).trim()); } catch {}
75
+ }
76
+ }
77
+ const registered = Boolean(version && bundled && bundles.includes(name));
78
+ const managed = policy.find((item) => item.name === name);
79
+ plugins.push({ name, spec, version, bundled, registered, active: registered,
80
+ source: typeof spec === 'string' && /^(?:file|link|workspace):/.test(spec) ? '本地链接' : 'npm',
81
+ builtin: Boolean(managed) || name === 'dsh-agentone',
82
+ description: managed?.description || null, target_version: managed?.version || null,
83
+ state: registered ? 'restart_required' : version ? 'pending' : 'missing',
84
+ loaded: null,
85
+ });
86
+ }
87
+ return { profile: current.name, bundles, plugins };
88
+ }
89
+ async function execute(args, current, options) {
90
+ check(current, options);
91
+ await options.beforeCommit?.();
92
+ check(current, options);
93
+ if (current.desktop) {
94
+ const service = ctx.get?.('desktopPnpm');
95
+ if (!service || !desktopRun) throw new Error('桌面插件安装通道尚未就绪,请稍后重试');
96
+ return desktopRun(service, current.dir)(args, options);
97
+ }
98
+ return runCli(args, current.name, { env: { DSH_HOME: current.home }, signal: options.signal });
99
+ }
100
+ function install(name, version = 'latest', options = {}) {
101
+ validatePackage(name, version, options.allowBuild || []);
102
+ return enqueue(async () => {
103
+ const current = target(); check(current, options);
104
+ const before = (await list()).plugins.find((plugin) => plugin.name === name);
105
+ if (options.missingOnly && before?.source === '本地链接') throw new Error('插件使用本地链接,请确认后手动应用');
106
+ if (options.missingOnly && !options.updateLatest && before?.registered) {
107
+ return { ...before, state: version !== 'latest' && version !== before.version ? 'pending' : 'current' };
108
+ }
109
+ const wanted = options.missingOnly && !options.updateLatest && before?.version ? before.version
110
+ : version === 'latest' ? await latest(name, current.dir) : version;
111
+ if (before?.registered && before.version === wanted) return { ...before, state: 'current' };
112
+ if (!wanted || !EXACT_VERSION.test(wanted)) throw new Error('查询插件目标版本失败,请稍后重试');
113
+ let commandError = null;
114
+ try { await execute(['add', `${name}@${wanted}`, ...(options.allowBuild || []).map((entry) => `--allow-build=${entry}`)], current, options); }
115
+ catch (error) { commandError = error; }
116
+ check(current, options);
117
+ const installed = (await list()).plugins.find((plugin) => plugin.name === name);
118
+ const valid = installed?.version === wanted && installed?.bundled && installed?.registered;
119
+ if (commandError && (!/ERR_PNPM_IGNORED_BUILDS/.test(commandError.message) || !valid)) throw commandError;
120
+ if (!valid) throw new Error(`插件安装未完成:需确认版本 ${wanted}、插件声明与 profile 注册,请重试`);
121
+ return { ...installed, state: 'restart_required', registered: true, loaded: null };
122
+ });
123
+ }
124
+ function remove(name, options = {}) {
125
+ validatePackage(name);
126
+ if (PROTECTED_PACKAGES.has(name)) throw new Error('AgentOne 自身和 DSH 基础组件不可卸载');
127
+ if (policy.some((item) => item.name === name)) throw new Error('此插件由平台默认能力清单管理,请先由管理员停用');
128
+ return enqueue(async () => {
129
+ const current = target(); await execute(['remove', name], current, options); check(current, options);
130
+ const state = await list();
131
+ if (state.plugins.some((plugin) => plugin.name === name)) throw new Error('插件卸载未完成,请重试');
132
+ return state;
133
+ });
134
+ }
135
+ return { target, list, install, remove, resolveVersion: async (name, version = 'latest') => {
136
+ validatePackage(name, version);
137
+ const exact = version === 'latest' ? await latest(name, target().dir) : version;
138
+ if (!exact || !EXACT_VERSION.test(exact)) throw new Error('查询插件目标版本失败,请稍后重试');
139
+ return exact;
140
+ }, setPolicy(entries) { policy = entries; },
141
+ async dispose() { closed = true; await queue; },
142
+ };
143
+ }
@@ -0,0 +1,102 @@
1
+ // 技能的物理目录记录:绑定平台、用户与 registry;不保存 token。
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { lstat, mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises';
4
+ import { join, resolve } from 'node:path';
5
+ import { resolveDshHome } from './store.js';
6
+
7
+ export function canonicalSkillSlug(value) {
8
+ if (typeof value === 'string') {
9
+ value = value.trim();
10
+ if (value.includes('/')) {
11
+ const parts = value.split('/');
12
+ if (parts.length !== 2 || parts.some((part) => !/^[a-z0-9][a-z0-9_-]*$/.test(part) || part.includes('--'))) {
13
+ throw new Error('技能标识应为技能名、命名空间/技能名或命名空间--技能名');
14
+ }
15
+ value = parts.join('--');
16
+ }
17
+ }
18
+ if (typeof value !== 'string' || value.length > 255 || !/^[a-z0-9][a-z0-9_-]*$/.test(value)) {
19
+ throw new Error('技能标识格式不正确');
20
+ }
21
+ if (value.includes('--')) {
22
+ const [namespace, slug, ...extra] = value.split('--');
23
+ if (!namespace || !slug || extra.length) throw new Error('技能标识格式不正确');
24
+ if (namespace === 'global') return slug;
25
+ }
26
+ return value;
27
+ }
28
+
29
+ export function skillPaths(config, canonicalSlug) {
30
+ const slug = canonicalSkillSlug(canonicalSlug);
31
+ const home = resolve(config?.dshHome || resolveDshHome());
32
+ const root = join(home, 'skills');
33
+ return { home, root, dir: join(root, slug), record: join(home, 'agentone', 'skill-state', `${slug}.json`) };
34
+ }
35
+
36
+ export function skillOwner(platformUrl, options = {}) {
37
+ return {
38
+ platform: platformUrl.replace(/\/+$/, ''),
39
+ user_id: options.userId || null,
40
+ registry: (options.registry || '').replace(/\/+$/, ''),
41
+ };
42
+ }
43
+
44
+ export async function readSkillRecord(paths) {
45
+ try {
46
+ const record = JSON.parse(await readFile(paths.record, 'utf8'));
47
+ return record?.schema_version === 1 ? record : null;
48
+ } catch (error) {
49
+ if (error?.code && error.code !== 'ENOENT') throw error;
50
+ return null;
51
+ }
52
+ }
53
+
54
+ export async function writeSkillRecord(paths, record) {
55
+ const parent = join(paths.home, 'agentone', 'skill-state');
56
+ await mkdir(parent, { recursive: true, mode: 0o700 });
57
+ const temporary = `${paths.record}.${randomUUID()}.tmp`;
58
+ await writeFile(temporary, JSON.stringify(record) + '\n', { mode: 0o600 });
59
+ await rename(temporary, paths.record);
60
+ }
61
+
62
+ /** 摘要包含全部普通文件的相对路径与内容;拒绝 symlink,避免越界读写。 */
63
+ export async function skillDigest(dir) {
64
+ const root = await lstat(dir);
65
+ if (root.isSymbolicLink() || !root.isDirectory()) throw new Error('技能目录必须是普通目录,请检查本机文件');
66
+ const hash = createHash('sha256');
67
+ async function walk(current, prefix) {
68
+ const entries = await readdir(current, { withFileTypes: true });
69
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
70
+ const relative = `${prefix}${entry.name}`;
71
+ const path = join(current, entry.name);
72
+ if (entry.isSymbolicLink()) throw new Error('技能目录包含符号链接,请先检查本机文件');
73
+ if (entry.isDirectory()) await walk(path, `${relative}/`);
74
+ else if (entry.isFile()) {
75
+ const bytes = await readFile(path);
76
+ hash.update(JSON.stringify([relative, bytes.length])); hash.update(bytes);
77
+ } else throw new Error('技能目录包含不支持的文件类型');
78
+ }
79
+ }
80
+ await walk(dir, '');
81
+ return hash.digest('hex');
82
+ }
83
+
84
+ export async function localSkillState(platformUrl, canonicalSlug, options = {}) {
85
+ const paths = skillPaths(options.config, canonicalSlug);
86
+ const record = await readSkillRecord(paths);
87
+ let stat;
88
+ try { stat = await lstat(paths.dir); }
89
+ catch (error) { if (error.code === 'ENOENT') return { state: 'missing', record, paths }; throw error; }
90
+ if (!stat.isDirectory() || stat.isSymbolicLink()) return { state: 'conflict', record, paths };
91
+ const owner = skillOwner(platformUrl, options);
92
+ if (!record || Object.keys(owner).some((key) => owner[key] !== record[key])) {
93
+ return { state: 'conflict', record, paths };
94
+ }
95
+ let digest;
96
+ try {
97
+ if (!(await readFile(join(paths.dir, 'SKILL.md'), 'utf8')).trim()) return { state: 'conflict', record, paths };
98
+ digest = await skillDigest(paths.dir);
99
+ } catch { return { state: 'conflict', record, paths }; }
100
+ if (digest !== record.digest) return { state: 'conflict', record, paths };
101
+ return { state: record.report_pending ? 'report_pending' : 'current', record, paths };
102
+ }