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/README.md +14 -7
- package/lib/client.js +5 -1
- package/lib/defaults-sync.js +257 -0
- package/lib/http.js +6 -3
- package/lib/index.js +195 -297
- package/lib/page.js +71 -9
- package/lib/platform-config.js +56 -0
- package/lib/proc.js +6 -4
- package/lib/profile-plugins.js +143 -0
- package/lib/skill-state.js +102 -0
- package/lib/skill.js +182 -104
- package/package.json +2 -2
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
|
|
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.
|
|
970
|
-
? '<span class="pill ok" style="font-size:11px"
|
|
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"
|
|
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"
|
|
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.
|
|
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.
|
|
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
|
|
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"
|
|
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
|
+
}
|