@morehao/dsh-skills-console 0.2.0 → 0.2.2
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 +54 -64
- package/client.js +2537 -1227
- package/icon.svg +2 -1
- package/lib/core/cli.js +3 -1
- package/lib/core/links.js +1 -1
- package/lib/core/service.js +340 -66
- package/lib/core/updates.js +12 -0
- package/lib/routes.js +26 -2
- package/package.json +10 -4
package/icon.svg
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="技能控制台">
|
|
2
|
+
<title>技能控制台</title>
|
|
2
3
|
<defs>
|
|
3
4
|
<linearGradient id="dshsc-icon-bg" x1="6" y1="2" x2="58" y2="62" gradientUnits="userSpaceOnUse">
|
|
4
5
|
<stop stop-color="#3b82f6"/>
|
package/lib/core/cli.js
CHANGED
|
@@ -213,10 +213,12 @@ export function createCli({ env = process.env, logger = () => {} } = {}) {
|
|
|
213
213
|
|
|
214
214
|
/**
|
|
215
215
|
* 安装:`-a universal` 必带(F17),`--json` 配 `-y`(F11),`--full-depth` 必带(F24)。
|
|
216
|
+
* 技能名留空 = `--skill '*'`(整仓全部);不能留空值的 `--skill`,那会把后面的 `-a` 当成它的参数。
|
|
216
217
|
* 永不带 `--all` / `-a '*'`。
|
|
217
218
|
*/
|
|
218
219
|
async add({ source, skills = [], timeoutMs = env.DSH_SKILLS_CONSOLE_TIMEOUT_MS ? Number(env.DSH_SKILLS_CONSOLE_TIMEOUT_MS) : 120000 }) {
|
|
219
|
-
const
|
|
220
|
+
const names = skills.length > 0 ? skills : ['*'];
|
|
221
|
+
const args = ['add', source, '--skill', ...names, '-a', 'universal', '-g', '-y', '--json', '--full-depth'];
|
|
220
222
|
const result = await call(args, { timeoutMs });
|
|
221
223
|
return result;
|
|
222
224
|
},
|
package/lib/core/links.js
CHANGED
|
@@ -55,7 +55,7 @@ export function inspectTarget({ linkPath, canonicalPath }) {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
/**
|
|
58
|
-
*
|
|
58
|
+
* 建链:同层用相对路径(可跨机器复用),算不出相对就退回绝对路径并标记。
|
|
59
59
|
* Windows 用 junction + 绝对路径(对齐 CLI 的 createSymlink,F2)。
|
|
60
60
|
*/
|
|
61
61
|
export function createLink({ linkPath, targetPath, platform = process.platform, fsImpl = fs }) {
|
package/lib/core/service.js
CHANGED
|
@@ -7,6 +7,7 @@ import { buildLedger, readLock, scanCanonical } from './inventory.js';
|
|
|
7
7
|
import { createBackup, readBackup, restoreBackup, trashSkill } from './trash.js';
|
|
8
8
|
import {
|
|
9
9
|
checkSkill,
|
|
10
|
+
fetchDefaultBranch,
|
|
10
11
|
fetchTree,
|
|
11
12
|
listSkillsFromTree,
|
|
12
13
|
parseSource,
|
|
@@ -30,6 +31,83 @@ function nowIso() {
|
|
|
30
31
|
return new Date().toISOString();
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
/** `skills add --json` 的输出:数组或 `{ installed: [...] }`,只取 name 并去重。 */
|
|
35
|
+
function installedNamesFrom(stdout) {
|
|
36
|
+
try {
|
|
37
|
+
const parsed = JSON.parse(stdout);
|
|
38
|
+
const rows = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.installed) ? parsed.installed : [];
|
|
39
|
+
const names = rows.map((row) => (typeof row === 'string' ? row : row?.name)).filter(Boolean);
|
|
40
|
+
return [...new Set(names)];
|
|
41
|
+
} catch {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 极简 frontmatter 解析:只取 CLI 安装必需的两项(name / description)。 */
|
|
47
|
+
function parseSkillFrontmatter(text) {
|
|
48
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(String(text || ''));
|
|
49
|
+
if (!match) return {};
|
|
50
|
+
const lines = match[1].split(/\r?\n/);
|
|
51
|
+
const fields = {};
|
|
52
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
53
|
+
const line = lines[index];
|
|
54
|
+
if (!line || /^\s/.test(line)) continue;
|
|
55
|
+
const colon = line.indexOf(':');
|
|
56
|
+
if (colon <= 0) continue;
|
|
57
|
+
const key = line.slice(0, colon).trim();
|
|
58
|
+
if (key !== 'name' && key !== 'description') continue;
|
|
59
|
+
let value = line.slice(colon + 1).trim();
|
|
60
|
+
const block = /^([|>])[+-]?$/.exec(value);
|
|
61
|
+
if (block) {
|
|
62
|
+
const folded = block[1] === '>';
|
|
63
|
+
const rows = [];
|
|
64
|
+
while (index + 1 < lines.length && /^\s+/.test(lines[index + 1])) {
|
|
65
|
+
rows.push(lines[index + 1].replace(/^\s+/, ''));
|
|
66
|
+
index += 1;
|
|
67
|
+
}
|
|
68
|
+
value = folded ? rows.join(' ') : rows.join('\n');
|
|
69
|
+
} else if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
70
|
+
const quote = value[0];
|
|
71
|
+
value = value.slice(1, -1);
|
|
72
|
+
if (quote === '"') value = value.replace(/\\"/g, '"').replace(/\\n/g, '\n');
|
|
73
|
+
}
|
|
74
|
+
fields[key] = value.trim();
|
|
75
|
+
}
|
|
76
|
+
return fields;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 用 raw 内容补 frontmatter 里的真实技能名/描述(CLI 按 name 安装);取不到就回落目录名。 */
|
|
80
|
+
async function enrichSkillEntries({ source, ref, entries, token, fetchImpl, concurrency = 8 }) {
|
|
81
|
+
const results = new Array(entries.length);
|
|
82
|
+
let cursor = 0;
|
|
83
|
+
const worker = async () => {
|
|
84
|
+
while (cursor < entries.length) {
|
|
85
|
+
const index = cursor;
|
|
86
|
+
cursor += 1;
|
|
87
|
+
results[index] = await inspectSkillEntry({ source, ref, entry: entries[index], token, fetchImpl });
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, entries.length) }, worker));
|
|
91
|
+
return results;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function inspectSkillEntry({ source, ref, entry, token, fetchImpl }) {
|
|
95
|
+
const fallback = { name: entry.name, description: '', skillPath: entry.skillPath, valid: true };
|
|
96
|
+
try {
|
|
97
|
+
const url = `https://raw.githubusercontent.com/${source}/${ref}/${entry.skillPath.split('/').map(encodeURIComponent).join('/')}`;
|
|
98
|
+
const response = await fetchImpl(url, {
|
|
99
|
+
headers: { 'user-agent': 'dsh-skills-console', ...(token ? { authorization: `Bearer ${token}` } : {}) },
|
|
100
|
+
});
|
|
101
|
+
if (!response?.ok) return fallback;
|
|
102
|
+
const fields = parseSkillFrontmatter(await response.text());
|
|
103
|
+
// CLI 要求 name + description 都在,缺一个就整个跳过:这里同步标成 invalid,别让用户选了才发现装不上
|
|
104
|
+
if (!fields.name || !fields.description) return { ...fallback, valid: false };
|
|
105
|
+
return { name: fields.name, description: fields.description, skillPath: entry.skillPath, valid: true };
|
|
106
|
+
} catch {
|
|
107
|
+
return fallback;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
33
111
|
/** 写操作全局单写者队列:`add` 会同时改 canonical 与 lock,按技能分桶没有意义(设计 §14.3)。 */
|
|
34
112
|
function createQueue(logger) {
|
|
35
113
|
let chain = Promise.resolve();
|
|
@@ -63,6 +141,10 @@ function createQueue(logger) {
|
|
|
63
141
|
export function createService({ paths, store, registry, cli, logger, env = process.env, fetchImpl = fetch, visibleProvider = async () => ({ skills: [], complete: true }), workspacesProvider = async () => [] }) {
|
|
64
142
|
const enqueue = createQueue(logger);
|
|
65
143
|
const tokenOf = (env.GITHUB_TOKEN || env.GH_TOKEN) || null;
|
|
144
|
+
// 探测缓存:同一来源短时间内重复检测直接复用;并发的相同请求合并成一次(不重复烧 API 配额)
|
|
145
|
+
const probeCache = new Map();
|
|
146
|
+
const PROBE_TTL_MS = 60_000;
|
|
147
|
+
const PROBE_MAX = 50;
|
|
66
148
|
|
|
67
149
|
function state() {
|
|
68
150
|
return store.state;
|
|
@@ -78,7 +160,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
78
160
|
|
|
79
161
|
function defaultTargets(current) {
|
|
80
162
|
const targets = ['dsh-global'];
|
|
81
|
-
// null = 老语义「所有探测到的 Agent」;数组 =
|
|
163
|
+
// null = 老语义「所有探测到的 Agent」;数组 = 用户在「自动接入」里勾选的那些
|
|
82
164
|
const selected = current.settings.autoLinkTargets ?? null;
|
|
83
165
|
for (const target of detectedTargets()) {
|
|
84
166
|
if (target.kind !== 'agent') continue;
|
|
@@ -121,6 +203,90 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
121
203
|
return { created, skipped, failed };
|
|
122
204
|
}
|
|
123
205
|
|
|
206
|
+
/** 订阅的分支:显式 ref > 记录里的 ref > 仓库默认分支。解析成功后顺手记住,省掉下次请求。 */
|
|
207
|
+
async function branchOf(subscription, { persist = false } = {}) {
|
|
208
|
+
if (subscription.ref) return subscription.ref;
|
|
209
|
+
let branch = null;
|
|
210
|
+
try {
|
|
211
|
+
branch = await fetchDefaultBranch({
|
|
212
|
+
source: subscription.source,
|
|
213
|
+
token: tokenOf,
|
|
214
|
+
fetchImpl,
|
|
215
|
+
});
|
|
216
|
+
} catch {
|
|
217
|
+
branch = null;
|
|
218
|
+
}
|
|
219
|
+
if (!branch) return null;
|
|
220
|
+
if (persist && !store.readOnly) {
|
|
221
|
+
store.update((draft) => {
|
|
222
|
+
const row = draft.subscriptions.find((item) => item.id === subscription.id);
|
|
223
|
+
if (row && !row.ref) row.ref = branch;
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
return branch;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* 读一次订阅快照:只有网络与纯计算,**不写 store**。
|
|
231
|
+
* 上游失败一律抛 ServiceError —— 添加订阅必须先读到快照再记账,
|
|
232
|
+
* 否则会留下「订阅存在但一个 skill 都没有」的空壳(上游 404 时还会显示添加成功)。
|
|
233
|
+
*/
|
|
234
|
+
async function readSnapshot(subscription, { force = false, withCommit = true } = {}) {
|
|
235
|
+
const source = subscription.source;
|
|
236
|
+
try {
|
|
237
|
+
const branch = await branchOf(subscription);
|
|
238
|
+
if (!branch) {
|
|
239
|
+
throw new ServiceError(
|
|
240
|
+
'error.skills.upstream-failed',
|
|
241
|
+
`读不到 ${source} 的默认分支(仓库不存在、私有,或 GitHub 接口不可用/已限流)`,
|
|
242
|
+
{ source },
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
const result = await fetchTree({
|
|
246
|
+
source,
|
|
247
|
+
ref: branch,
|
|
248
|
+
etag: force ? null : subscription.snapshot?.etag || null,
|
|
249
|
+
token: tokenOf,
|
|
250
|
+
fetchImpl,
|
|
251
|
+
});
|
|
252
|
+
if (result.error) {
|
|
253
|
+
throw new ServiceError('error.skills.upstream-failed', `读取 ${source}@${branch} 失败:${result.error}`, {
|
|
254
|
+
source,
|
|
255
|
+
ref: branch,
|
|
256
|
+
status: result.status ?? null,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
if (result.notModified) return { notModified: true, branch };
|
|
260
|
+
const entries = listSkillsFromTree({
|
|
261
|
+
tree: result.tree,
|
|
262
|
+
rootSha: result.rootSha,
|
|
263
|
+
subpath: subscription.subpath,
|
|
264
|
+
repoName: source.split('/').pop(),
|
|
265
|
+
});
|
|
266
|
+
const previousRoot = subscription.snapshot?.rootSha || null;
|
|
267
|
+
// 探测只需要文件树,不付 resolveCommit 那一次请求
|
|
268
|
+
const commit = !withCommit
|
|
269
|
+
? null
|
|
270
|
+
: previousRoot && previousRoot === result.rootSha && subscription.snapshot?.commit
|
|
271
|
+
? subscription.snapshot.commit
|
|
272
|
+
: await resolveCommit({ source, ref: branch, token: tokenOf, fetchImpl });
|
|
273
|
+
const changed =
|
|
274
|
+
!subscription.snapshot ||
|
|
275
|
+
previousRoot !== result.rootSha ||
|
|
276
|
+
JSON.stringify(entries) !== JSON.stringify(subscription.snapshot.entries);
|
|
277
|
+
return {
|
|
278
|
+
branch,
|
|
279
|
+
changed,
|
|
280
|
+
snapshot: { commit, rootSha: result.rootSha, entries, fetchedAt: nowIso(), etag: result.etag },
|
|
281
|
+
};
|
|
282
|
+
} catch (error) {
|
|
283
|
+
if (error instanceof ServiceError) throw error;
|
|
284
|
+
throw new ServiceError('error.skills.upstream-failed', `读取 ${source} 失败:${error?.message || error}`, {
|
|
285
|
+
source,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
124
290
|
function recordLink(name, target, linkPath, mode, created) {
|
|
125
291
|
if (store.readOnly) return;
|
|
126
292
|
const existing = state().links.find((item) => item.skill === name && item.target === target.target);
|
|
@@ -455,8 +621,8 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
455
621
|
if (!capabilities.usable) {
|
|
456
622
|
throw new ServiceError('error.skills.cli-missing', 'skills CLI 不可用或不支持 --json/--full-depth,写操作已禁用', capabilities);
|
|
457
623
|
}
|
|
458
|
-
|
|
459
|
-
|
|
624
|
+
// 技能名可留空:有订阅快照就装快照里的条目,否则交给 CLI 的 `--skill '*'`(整仓全部)
|
|
625
|
+
const requested = (skills || []).filter(Boolean);
|
|
460
626
|
|
|
461
627
|
return enqueue('install', async () => {
|
|
462
628
|
const subscription = subscriptionId
|
|
@@ -467,10 +633,11 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
467
633
|
if (!parseSource(resolvedSource)) throw new ServiceError('error.skills.bad-source', '只接受 owner/repo 或 GitHub URL(公开源)');
|
|
468
634
|
|
|
469
635
|
const snapshot = subscription?.snapshot || null;
|
|
636
|
+
const planned = requested.length > 0 ? requested : (snapshot?.entries || []).map((entry) => entry.name);
|
|
470
637
|
const pin = commit || snapshot?.commit || subscription?.ref || null;
|
|
471
638
|
const sourceArg = pin ? `${resolvedSource}#${pin}` : resolvedSource;
|
|
472
639
|
|
|
473
|
-
const result = await cli.add({ source: sourceArg, skills:
|
|
640
|
+
const result = await cli.add({ source: sourceArg, skills: planned });
|
|
474
641
|
if (result.timedOut) throw new ServiceError('error.skills.cli-timeout', 'skills add 超时');
|
|
475
642
|
if (result.code !== 0) {
|
|
476
643
|
throw new ServiceError('error.skills.cli-failed', 'skills add 失败', {
|
|
@@ -478,6 +645,13 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
478
645
|
stderr: result.stderr.slice(-2000),
|
|
479
646
|
});
|
|
480
647
|
}
|
|
648
|
+
// 留空时实际装了哪些技能以 CLI 的 JSON 输出为准(可能不止一个)
|
|
649
|
+
const names = planned.length > 0 ? planned : installedNamesFrom(result.stdout);
|
|
650
|
+
if (names.length === 0) {
|
|
651
|
+
throw new ServiceError('error.skills.cli-failed', 'skills add 成功,但输出里没有安装结果,无法记账', {
|
|
652
|
+
stdout: result.stdout.slice(-2000),
|
|
653
|
+
});
|
|
654
|
+
}
|
|
481
655
|
|
|
482
656
|
const lock = readLock(paths);
|
|
483
657
|
const installed = [];
|
|
@@ -596,7 +770,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
596
770
|
return {
|
|
597
771
|
ok: false,
|
|
598
772
|
code: 'error.skills.cli-failed',
|
|
599
|
-
message: 'skills remove
|
|
773
|
+
message: 'skills remove 失败;已断开本插件建立的软链,真身可能仍在 canonical',
|
|
600
774
|
detail: { code: result.code, stderr: result.stderr.slice(-2000) },
|
|
601
775
|
trash: trash.key,
|
|
602
776
|
};
|
|
@@ -605,26 +779,136 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
605
779
|
});
|
|
606
780
|
},
|
|
607
781
|
|
|
782
|
+
/** 只读探测来源里的 SKILL.md,供「添加技能」多选;不写 state、不建订阅。 */
|
|
783
|
+
async probeSource({ source, ref, subpath }) {
|
|
784
|
+
const parsed = parseSource(source);
|
|
785
|
+
if (!parsed) throw new ServiceError('error.skills.bad-source', '只接受 owner/repo 或 GitHub URL(公开源)');
|
|
786
|
+
const canonicalSource = `${parsed.owner}/${parsed.repo}`;
|
|
787
|
+
const requestedRef = typeof ref === 'string' && ref.trim() ? ref.trim() : null;
|
|
788
|
+
const requestedSubpath = typeof subpath === 'string' && subpath.trim()
|
|
789
|
+
? subpath.trim().replace(/^\/+|\/+$/g, '') || null
|
|
790
|
+
: null;
|
|
791
|
+
const key = `${canonicalSource}@${requestedRef || ''}#${requestedSubpath || ''}`;
|
|
792
|
+
const cached = probeCache.get(key);
|
|
793
|
+
if (cached && (cached.pending || Date.now() - cached.at < PROBE_TTL_MS)) {
|
|
794
|
+
return cached.pending || cached.value;
|
|
795
|
+
}
|
|
796
|
+
const pending = readSnapshot(
|
|
797
|
+
{
|
|
798
|
+
id: null,
|
|
799
|
+
source: canonicalSource,
|
|
800
|
+
ref: requestedRef,
|
|
801
|
+
subpath: requestedSubpath,
|
|
802
|
+
snapshot: null,
|
|
803
|
+
},
|
|
804
|
+
{ force: true, withCommit: false },
|
|
805
|
+
).then(async (probe) => {
|
|
806
|
+
const enriched = await enrichSkillEntries({
|
|
807
|
+
source: canonicalSource,
|
|
808
|
+
ref: probe.branch,
|
|
809
|
+
entries: probe.snapshot.entries,
|
|
810
|
+
token: tokenOf,
|
|
811
|
+
fetchImpl,
|
|
812
|
+
});
|
|
813
|
+
const skills = enriched
|
|
814
|
+
.filter((skill) => skill.valid)
|
|
815
|
+
.map(({ name, description, skillPath }) => ({ name, description, skillPath }));
|
|
816
|
+
return {
|
|
817
|
+
ok: true,
|
|
818
|
+
source: canonicalSource,
|
|
819
|
+
ref: probe.branch,
|
|
820
|
+
skills,
|
|
821
|
+
skipped: enriched.length - skills.length,
|
|
822
|
+
};
|
|
823
|
+
});
|
|
824
|
+
probeCache.set(key, { pending, at: Date.now() });
|
|
825
|
+
try {
|
|
826
|
+
const value = await pending;
|
|
827
|
+
probeCache.set(key, { value, at: Date.now() });
|
|
828
|
+
for (const oldest of probeCache.keys()) {
|
|
829
|
+
if (probeCache.size <= PROBE_MAX) break;
|
|
830
|
+
probeCache.delete(oldest);
|
|
831
|
+
}
|
|
832
|
+
return value;
|
|
833
|
+
} catch (error) {
|
|
834
|
+
// 失败不留缓存,用户改一下来源或重试就能重新探测
|
|
835
|
+
probeCache.delete(key);
|
|
836
|
+
throw error;
|
|
837
|
+
}
|
|
838
|
+
},
|
|
839
|
+
|
|
608
840
|
async addSubscription({ source, ref, subpath, skills = '*' }) {
|
|
609
841
|
assertWritable();
|
|
610
842
|
const parsed = parseSource(source);
|
|
611
843
|
if (!parsed) throw new ServiceError('error.skills.bad-source', '订阅只接受 owner/repo 或 GitHub URL');
|
|
612
|
-
const
|
|
613
|
-
const
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
844
|
+
const canonicalSource = `${parsed.owner}/${parsed.repo}`;
|
|
845
|
+
const requestedRef = typeof ref === 'string' && ref.trim() ? ref.trim() : null;
|
|
846
|
+
const requestedSubpath = typeof subpath === 'string' && subpath.trim()
|
|
847
|
+
? subpath.trim().replace(/^\/+|\/+$/g, '') || null
|
|
848
|
+
: null;
|
|
849
|
+
const id = snapshotId({ source: canonicalSource, ref: requestedRef, subpath: requestedSubpath });
|
|
850
|
+
// 老记录可能用 URL / 原始字符串算过 id:按来源+ref+子目录再找一遍,避免同一仓库出现两条订阅
|
|
851
|
+
const existing =
|
|
852
|
+
state().subscriptions.find((item) => item.id === id) ||
|
|
853
|
+
state().subscriptions.find(
|
|
854
|
+
(item) =>
|
|
855
|
+
item.source === canonicalSource &&
|
|
856
|
+
(item.ref || null) === requestedRef &&
|
|
857
|
+
(item.subpath || null) === requestedSubpath,
|
|
858
|
+
);
|
|
859
|
+
// 已有快照的重复添加直接返回;空壳(旧版本失败留下的)要走一次真实拉取,成功后再补全
|
|
860
|
+
if (existing?.snapshot) return { ok: true, subscription: existing, existing: true };
|
|
861
|
+
|
|
862
|
+
const candidate = {
|
|
863
|
+
id: existing?.id || id,
|
|
864
|
+
source: canonicalSource,
|
|
865
|
+
ref: requestedRef || existing?.ref || null,
|
|
866
|
+
subpath: requestedSubpath,
|
|
620
867
|
skills,
|
|
621
868
|
lastCheck: null,
|
|
622
869
|
snapshot: null,
|
|
623
870
|
};
|
|
871
|
+
|
|
872
|
+
// 先读快照再落盘:上游失败时 state 里不能留下订阅,接口也必须如实报错
|
|
873
|
+
const probe = await readSnapshot(candidate, { force: true });
|
|
874
|
+
// 一条 skill 都没有 = 订阅了也没东西可装(多半是 subpath/ref 写错),同样不能算添加成功
|
|
875
|
+
if (probe.snapshot.entries.length === 0) {
|
|
876
|
+
throw new ServiceError(
|
|
877
|
+
'error.skills.no-skills',
|
|
878
|
+
`${canonicalSource}@${probe.branch}${requestedSubpath ? `/${requestedSubpath}` : ''} 里没有找到 SKILL.md`,
|
|
879
|
+
{ source: canonicalSource, ref: probe.branch, subpath: requestedSubpath },
|
|
880
|
+
);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
const record = {
|
|
884
|
+
...candidate,
|
|
885
|
+
...(existing?.adopted ? { adopted: true } : {}),
|
|
886
|
+
ref: probe.branch,
|
|
887
|
+
snapshot: probe.snapshot,
|
|
888
|
+
lastCheck: {
|
|
889
|
+
at: nowIso(),
|
|
890
|
+
commit: probe.snapshot.commit,
|
|
891
|
+
treeEtag: probe.snapshot.etag,
|
|
892
|
+
status: 'changed',
|
|
893
|
+
},
|
|
894
|
+
};
|
|
624
895
|
store.update((draft) => {
|
|
625
|
-
draft.subscriptions.
|
|
896
|
+
const index = draft.subscriptions.findIndex((item) => item.id === record.id);
|
|
897
|
+
if (index >= 0) draft.subscriptions[index] = { ...draft.subscriptions[index], ...record };
|
|
898
|
+
else draft.subscriptions.push(record);
|
|
626
899
|
});
|
|
627
|
-
|
|
900
|
+
logger('subscription.add', {
|
|
901
|
+
id: record.id,
|
|
902
|
+
source: record.source,
|
|
903
|
+
ref: record.ref,
|
|
904
|
+
entries: record.snapshot.entries.length,
|
|
905
|
+
});
|
|
906
|
+
return {
|
|
907
|
+
ok: true,
|
|
908
|
+
changed: true,
|
|
909
|
+
entries: record.snapshot.entries.length,
|
|
910
|
+
subscription: state().subscriptions.find((item) => item.id === record.id),
|
|
911
|
+
};
|
|
628
912
|
},
|
|
629
913
|
|
|
630
914
|
async removeSubscription({ id, keepInstalled = true }) {
|
|
@@ -646,55 +930,39 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
646
930
|
assertWritable();
|
|
647
931
|
const subscription = state().subscriptions.find((item) => item.id === id);
|
|
648
932
|
if (!subscription) throw new ServiceError('error.skills.no-subscription', '订阅不存在');
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
if (row) row.lastCheck = { at: nowIso(), status: 'unknown', detail: result.error };
|
|
662
|
-
});
|
|
663
|
-
return { ok: false, code: 'error.skills.upstream-failed', message: result.error };
|
|
664
|
-
}
|
|
665
|
-
if (result.notModified) {
|
|
666
|
-
store.update((draft) => {
|
|
667
|
-
const row = draft.subscriptions.find((item) => item.id === id);
|
|
668
|
-
// 304 只刷新时间,绝不把 entries 置空
|
|
669
|
-
if (row) row.lastCheck = { ...(row.lastCheck || {}), at: nowIso(), status: 'unchanged' };
|
|
670
|
-
});
|
|
671
|
-
return { ok: true, notModified: true, subscription: state().subscriptions.find((item) => item.id === id) };
|
|
933
|
+
let probe;
|
|
934
|
+
try {
|
|
935
|
+
probe = await readSnapshot(subscription, { force });
|
|
936
|
+
} catch (error) {
|
|
937
|
+
// 失败留痕(清单里能看到 lastCheck),但接口必须报错,不能把失败伪装成成功
|
|
938
|
+
if (!store.readOnly) {
|
|
939
|
+
store.update((draft) => {
|
|
940
|
+
const row = draft.subscriptions.find((item) => item.id === id);
|
|
941
|
+
if (row) row.lastCheck = { at: nowIso(), status: 'unknown', detail: error.message };
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
throw error;
|
|
672
945
|
}
|
|
673
|
-
|
|
674
|
-
const entries = listSkillsFromTree({
|
|
675
|
-
tree: result.tree,
|
|
676
|
-
rootSha: result.rootSha,
|
|
677
|
-
subpath: subscription.subpath,
|
|
678
|
-
repoName: subscription.source.split('/').pop(),
|
|
679
|
-
});
|
|
680
|
-
const previousRoot = subscription.snapshot?.rootSha || null;
|
|
681
|
-
const commit =
|
|
682
|
-
previousRoot && previousRoot === result.rootSha && subscription.snapshot?.commit
|
|
683
|
-
? subscription.snapshot.commit
|
|
684
|
-
: await resolveCommit({ source: subscription.source, ref, token: tokenOf, fetchImpl });
|
|
685
|
-
|
|
686
|
-
const changed =
|
|
687
|
-
!subscription.snapshot ||
|
|
688
|
-
previousRoot !== result.rootSha ||
|
|
689
|
-
JSON.stringify(entries) !== JSON.stringify(subscription.snapshot.entries);
|
|
946
|
+
const at = nowIso();
|
|
690
947
|
store.update((draft) => {
|
|
691
948
|
const row = draft.subscriptions.find((item) => item.id === id);
|
|
692
949
|
if (!row) return;
|
|
693
|
-
row.
|
|
694
|
-
|
|
950
|
+
if (!row.ref && probe.branch) row.ref = probe.branch;
|
|
951
|
+
if (probe.notModified) {
|
|
952
|
+
// 304 只刷新时间,绝不把 entries 置空
|
|
953
|
+
row.lastCheck = { ...(row.lastCheck || {}), at, status: 'unchanged' };
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
row.snapshot = probe.snapshot;
|
|
957
|
+
row.lastCheck = { at, commit: probe.snapshot.commit, treeEtag: probe.snapshot.etag, status: probe.changed ? 'changed' : 'unchanged' };
|
|
695
958
|
});
|
|
696
|
-
logger('subscription.refresh', { id, entries: entries.length, changed });
|
|
697
|
-
return {
|
|
959
|
+
if (!probe.notModified) logger('subscription.refresh', { id, entries: probe.snapshot.entries.length, changed: probe.changed });
|
|
960
|
+
return {
|
|
961
|
+
ok: true,
|
|
962
|
+
changed: !probe.notModified && probe.changed,
|
|
963
|
+
...(probe.notModified ? { notModified: true } : {}),
|
|
964
|
+
subscription: state().subscriptions.find((item) => item.id === id),
|
|
965
|
+
};
|
|
698
966
|
},
|
|
699
967
|
|
|
700
968
|
async checkUpdates({ names, subscriptionId } = {}) {
|
|
@@ -708,16 +976,18 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
708
976
|
for (const install of installs) {
|
|
709
977
|
const subscription = state().subscriptions.find((item) => item.id === install.subscriptionId);
|
|
710
978
|
if (!subscription) continue;
|
|
711
|
-
|
|
712
|
-
if (!groups.has(
|
|
713
|
-
groups.get(
|
|
979
|
+
// 按订阅(id 已含 source/ref/subpath)分组:不能用 `ref || 'main'` 当键,否则 null-ref 与 main 会撞车
|
|
980
|
+
if (!groups.has(subscription.id)) groups.set(subscription.id, { subscription, installs: [] });
|
|
981
|
+
groups.get(subscription.id).installs.push(install);
|
|
714
982
|
}
|
|
715
983
|
|
|
716
984
|
const results = [];
|
|
717
985
|
for (const { subscription, installs: groupInstalls } of groups.values()) {
|
|
986
|
+
// 没写 ref 的订阅(旧版本/接管建的)按仓库默认分支检查,别硬套 main
|
|
987
|
+
const ref = (await branchOf(subscription, { persist: true })) || 'main';
|
|
718
988
|
const tree = await fetchTree({
|
|
719
989
|
source: subscription.source,
|
|
720
|
-
ref
|
|
990
|
+
ref,
|
|
721
991
|
etag: subscription.snapshot?.etag || null,
|
|
722
992
|
token: tokenOf,
|
|
723
993
|
fetchImpl,
|
|
@@ -779,9 +1049,10 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
779
1049
|
if (!install) throw new ServiceError('error.skills.not-managed', `${name} 没有安装记录`);
|
|
780
1050
|
const subscription = state().subscriptions.find((item) => item.id === install.subscriptionId);
|
|
781
1051
|
if (!subscription) throw new ServiceError('error.skills.no-subscription', '该技能不属于任何订阅,无法生成更新计划');
|
|
1052
|
+
const ref = (await branchOf(subscription)) || 'main';
|
|
782
1053
|
const tree = await fetchTree({
|
|
783
1054
|
source: subscription.source,
|
|
784
|
-
ref
|
|
1055
|
+
ref,
|
|
785
1056
|
token: tokenOf,
|
|
786
1057
|
fetchImpl,
|
|
787
1058
|
});
|
|
@@ -791,7 +1062,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
791
1062
|
const entry = tree.tree.find((item) => item.type === 'blob' && item.path === skillPath);
|
|
792
1063
|
if (!entry) throw new ServiceError('error.skills.upstream-deleted', '上游已移除该技能');
|
|
793
1064
|
const targetTreeSha = skillTreeSha({ tree: tree.tree, skillPath, rootSha: tree.rootSha });
|
|
794
|
-
const targetCommit = await resolveCommit({ source: subscription.source, ref
|
|
1065
|
+
const targetCommit = await resolveCommit({ source: subscription.source, ref, token: tokenOf, fetchImpl });
|
|
795
1066
|
const token = planToken({
|
|
796
1067
|
subscriptionId: subscription.id,
|
|
797
1068
|
from: install.commit || null,
|
|
@@ -806,6 +1077,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
806
1077
|
token,
|
|
807
1078
|
fromCommit: install.commit || null,
|
|
808
1079
|
toCommit: targetCommit,
|
|
1080
|
+
ref,
|
|
809
1081
|
targetTreeSha,
|
|
810
1082
|
skillPath,
|
|
811
1083
|
localModified: Boolean(install.installedHash) && contentHash(path.join(paths.canonical, name)) !== install.installedHash,
|
|
@@ -833,7 +1105,9 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
833
1105
|
? createBackup({ paths, skill: name, fromDir: canonicalPath, hash: install.installedHash })
|
|
834
1106
|
: null;
|
|
835
1107
|
|
|
836
|
-
const sourceArg = plan.toCommit
|
|
1108
|
+
const sourceArg = plan.toCommit
|
|
1109
|
+
? `${subscription.source}#${plan.toCommit}`
|
|
1110
|
+
: `${subscription.source}#${plan.ref || subscription.ref || 'main'}`;
|
|
837
1111
|
const result = await cli.add({ source: sourceArg, skills: [name] });
|
|
838
1112
|
const lock = readLock(paths);
|
|
839
1113
|
const lockHash = lock.skills?.[name]?.skillFolderHash || null;
|
package/lib/core/updates.js
CHANGED
|
@@ -64,6 +64,18 @@ export async function fetchTree({ source, ref = 'main', etag, token, fetchImpl }
|
|
|
64
64
|
};
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* 仓库默认分支。`ref` 缺省时以它为准:不少技能仓库默认是 `master`,
|
|
69
|
+
* 硬套 `main` 只会得到 404(例如 alchaincyf/huashu-design)。
|
|
70
|
+
*/
|
|
71
|
+
export async function fetchDefaultBranch({ source, token, fetchImpl } = {}) {
|
|
72
|
+
const parsed = parseSource(source);
|
|
73
|
+
if (!parsed) return null;
|
|
74
|
+
const result = await callJson(`${API}/repos/${parsed.owner}/${parsed.repo}`, { token, fetchImpl });
|
|
75
|
+
if (result.error || !result.body?.default_branch) return null;
|
|
76
|
+
return result.body.default_branch;
|
|
77
|
+
}
|
|
78
|
+
|
|
67
79
|
/** 解析 ref 对应的 commit(只在安装/计划时需要,检测阶段不付这笔成本)。 */
|
|
68
80
|
export async function resolveCommit({ source, ref = 'main', token, fetchImpl } = {}) {
|
|
69
81
|
const parsed = parseSource(source);
|
package/lib/routes.js
CHANGED
|
@@ -1,9 +1,28 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
1
2
|
import { ServiceError } from './core/service.js';
|
|
2
3
|
import { StateReadOnlyError } from './core/state.js';
|
|
3
4
|
|
|
4
5
|
export const ROUTE_PREFIX = '/api/morehao/skills-console';
|
|
5
6
|
const MAX_BODY = 256 * 1024;
|
|
6
7
|
|
|
8
|
+
/**
|
|
9
|
+
* 插件身份:包名(去掉 scope,页头显示 dsh-skills-console)+ 版本。
|
|
10
|
+
* 从 package.json 读一次,随 /state 下发 —— 前端别硬编码版本,否则两处漂移。
|
|
11
|
+
*/
|
|
12
|
+
export const PLUGIN_INFO = (() => {
|
|
13
|
+
try {
|
|
14
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
15
|
+
const name = String(pkg.name || 'dsh-skills-console');
|
|
16
|
+
return {
|
|
17
|
+
name: name.includes('/') ? name.slice(name.lastIndexOf('/') + 1) : name,
|
|
18
|
+
version: pkg.version || null,
|
|
19
|
+
repository: pkg.repository?.url || null,
|
|
20
|
+
};
|
|
21
|
+
} catch {
|
|
22
|
+
return { name: 'dsh-skills-console', version: null, repository: null };
|
|
23
|
+
}
|
|
24
|
+
})();
|
|
25
|
+
|
|
7
26
|
function sendJson(res, status, payload) {
|
|
8
27
|
const body = JSON.stringify(payload);
|
|
9
28
|
res.writeHead(status, {
|
|
@@ -40,6 +59,7 @@ function matchRoute(method, pathname) {
|
|
|
40
59
|
if (segments.length === 1 && segments[0] === 'settings' && method === 'POST') return { name: 'settings' };
|
|
41
60
|
if (segments.length === 1 && segments[0] === 'links' && method === 'POST') return { name: 'links' };
|
|
42
61
|
if (segments.length === 1 && segments[0] === 'install' && method === 'POST') return { name: 'install' };
|
|
62
|
+
if (segments.length === 1 && segments[0] === 'probe' && method === 'POST') return { name: 'probe' };
|
|
43
63
|
if (segments.length === 1 && segments[0] === 'diagnostics' && method === 'GET') return { name: 'diagnostics' };
|
|
44
64
|
|
|
45
65
|
if (segments[0] === 'skills' && segments.length === 2 && method === 'GET') {
|
|
@@ -123,8 +143,10 @@ export function createRoutes({ service, logger }) {
|
|
|
123
143
|
|
|
124
144
|
async function dispatch(route, { body, query, service }) {
|
|
125
145
|
switch (route.name) {
|
|
126
|
-
case 'state':
|
|
127
|
-
|
|
146
|
+
case 'state': {
|
|
147
|
+
const state = await service.getState({ cwd: query.cwd || body.cwd, workspace: query.workspace });
|
|
148
|
+
return { ...state, plugin: PLUGIN_INFO };
|
|
149
|
+
}
|
|
128
150
|
case 'agents':
|
|
129
151
|
return { ok: true, all: service.registry.all(), detected: service.registry.detected() };
|
|
130
152
|
case 'settings':
|
|
@@ -133,6 +155,8 @@ async function dispatch(route, { body, query, service }) {
|
|
|
133
155
|
return service.setLinks(body);
|
|
134
156
|
case 'install':
|
|
135
157
|
return service.install(body);
|
|
158
|
+
case 'probe':
|
|
159
|
+
return service.probeSource(body);
|
|
136
160
|
case 'diagnostics':
|
|
137
161
|
return service.diagnostics();
|
|
138
162
|
case 'skill.body':
|