@morehao/dsh-skills-console 0.2.1 → 0.2.3
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 +18 -39
- package/client.js +195 -17
- package/lib/core/cli.js +3 -1
- package/lib/core/git.js +282 -0
- package/lib/core/links.js +1 -1
- package/lib/core/service.js +620 -87
- package/lib/core/updates.js +100 -12
- package/lib/routes.js +3 -0
- package/package.json +1 -1
package/lib/core/service.js
CHANGED
|
@@ -2,12 +2,15 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { StateReadOnlyError } from './state.js';
|
|
4
4
|
import { contentHash } from './hash.js';
|
|
5
|
+
import { createGit } from './git.js';
|
|
5
6
|
import { ensureLink, inspectTarget, removeLink } from './links.js';
|
|
6
7
|
import { buildLedger, readLock, scanCanonical } from './inventory.js';
|
|
7
8
|
import { createBackup, readBackup, restoreBackup, trashSkill } from './trash.js';
|
|
8
9
|
import {
|
|
9
10
|
checkSkill,
|
|
11
|
+
fetchDefaultBranch,
|
|
10
12
|
fetchTree,
|
|
13
|
+
hashShape,
|
|
11
14
|
listSkillsFromTree,
|
|
12
15
|
parseSource,
|
|
13
16
|
planToken,
|
|
@@ -30,6 +33,118 @@ function nowIso() {
|
|
|
30
33
|
return new Date().toISOString();
|
|
31
34
|
}
|
|
32
35
|
|
|
36
|
+
/** 本机时区的 `HH:mm`。手写而不是 `toLocaleTimeString`:不受 ICU / locale 差异影响。 */
|
|
37
|
+
function formatClock(ms) {
|
|
38
|
+
const at = new Date(ms);
|
|
39
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
40
|
+
return `${pad(at.getHours())}:${pad(at.getMinutes())}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 上游失败 → 可执行错误(T1)。四类必须互不相同,否则用户只知道「读不到」,
|
|
45
|
+
* 不知道该等、该改来源,还是该查网络。
|
|
46
|
+
*/
|
|
47
|
+
function upstreamError(source, failure) {
|
|
48
|
+
const status = failure?.status ?? null;
|
|
49
|
+
const resetAt = failure?.resetAt ?? null;
|
|
50
|
+
const detail = { source, status, resetAt, error: failure?.error ?? null };
|
|
51
|
+
if (failure?.rateLimited) {
|
|
52
|
+
const resume = resetAt ? `约 ${formatClock(resetAt)} 后自动恢复;` : '';
|
|
53
|
+
return new ServiceError(
|
|
54
|
+
'error.skills.rate-limited',
|
|
55
|
+
`GitHub 接口已限流(匿名 60 次/小时,按出口 IP 共享)。${resume}设置 GITHUB_TOKEN 可提到 5000 次/小时`,
|
|
56
|
+
detail,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
if (status === 404) {
|
|
60
|
+
return new ServiceError('error.skills.repo-not-found', `读不到 ${source}:仓库不存在或为私有`, detail);
|
|
61
|
+
}
|
|
62
|
+
if (status === 401 || status === 403) {
|
|
63
|
+
return new ServiceError('error.skills.repo-forbidden', `读取 ${source} 被拒绝(HTTP ${status})`, detail);
|
|
64
|
+
}
|
|
65
|
+
if (status) {
|
|
66
|
+
return new ServiceError('error.skills.upstream-failed', `读取 ${source} 失败:HTTP ${status}`, detail);
|
|
67
|
+
}
|
|
68
|
+
return new ServiceError('error.skills.network', '无法连接 GitHub(网络或代理问题)', detail);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** `skills add --json` 的输出:数组或 `{ installed: [...] }`,只取 name 并去重。 */
|
|
72
|
+
function installedNamesFrom(stdout) {
|
|
73
|
+
try {
|
|
74
|
+
const parsed = JSON.parse(stdout);
|
|
75
|
+
const rows = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.installed) ? parsed.installed : [];
|
|
76
|
+
const names = rows.map((row) => (typeof row === 'string' ? row : row?.name)).filter(Boolean);
|
|
77
|
+
return [...new Set(names)];
|
|
78
|
+
} catch {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 极简 frontmatter 解析:只取 CLI 安装必需的两项(name / description)。 */
|
|
84
|
+
function parseSkillFrontmatter(text) {
|
|
85
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(String(text || ''));
|
|
86
|
+
if (!match) return {};
|
|
87
|
+
const lines = match[1].split(/\r?\n/);
|
|
88
|
+
const fields = {};
|
|
89
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
90
|
+
const line = lines[index];
|
|
91
|
+
if (!line || /^\s/.test(line)) continue;
|
|
92
|
+
const colon = line.indexOf(':');
|
|
93
|
+
if (colon <= 0) continue;
|
|
94
|
+
const key = line.slice(0, colon).trim();
|
|
95
|
+
if (key !== 'name' && key !== 'description') continue;
|
|
96
|
+
let value = line.slice(colon + 1).trim();
|
|
97
|
+
const block = /^([|>])[+-]?$/.exec(value);
|
|
98
|
+
if (block) {
|
|
99
|
+
const folded = block[1] === '>';
|
|
100
|
+
const rows = [];
|
|
101
|
+
while (index + 1 < lines.length && /^\s+/.test(lines[index + 1])) {
|
|
102
|
+
rows.push(lines[index + 1].replace(/^\s+/, ''));
|
|
103
|
+
index += 1;
|
|
104
|
+
}
|
|
105
|
+
value = folded ? rows.join(' ') : rows.join('\n');
|
|
106
|
+
} else if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
107
|
+
const quote = value[0];
|
|
108
|
+
value = value.slice(1, -1);
|
|
109
|
+
if (quote === '"') value = value.replace(/\\"/g, '"').replace(/\\n/g, '\n');
|
|
110
|
+
}
|
|
111
|
+
fields[key] = value.trim();
|
|
112
|
+
}
|
|
113
|
+
return fields;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 用 raw 内容补 frontmatter 里的真实技能名/描述(CLI 按 name 安装);取不到就回落目录名。 */
|
|
117
|
+
async function enrichSkillEntries({ source, ref, entries, token, fetchImpl, concurrency = 8 }) {
|
|
118
|
+
const results = new Array(entries.length);
|
|
119
|
+
let cursor = 0;
|
|
120
|
+
const worker = async () => {
|
|
121
|
+
while (cursor < entries.length) {
|
|
122
|
+
const index = cursor;
|
|
123
|
+
cursor += 1;
|
|
124
|
+
results[index] = await inspectSkillEntry({ source, ref, entry: entries[index], token, fetchImpl });
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, entries.length) }, worker));
|
|
128
|
+
return results;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function inspectSkillEntry({ source, ref, entry, token, fetchImpl }) {
|
|
132
|
+
const fallback = { name: entry.name, description: '', skillPath: entry.skillPath, valid: true };
|
|
133
|
+
try {
|
|
134
|
+
const url = `https://raw.githubusercontent.com/${source}/${ref}/${entry.skillPath.split('/').map(encodeURIComponent).join('/')}`;
|
|
135
|
+
const response = await fetchImpl(url, {
|
|
136
|
+
headers: { 'user-agent': 'dsh-skills-console', ...(token ? { authorization: `Bearer ${token}` } : {}) },
|
|
137
|
+
});
|
|
138
|
+
if (!response?.ok) return fallback;
|
|
139
|
+
const fields = parseSkillFrontmatter(await response.text());
|
|
140
|
+
// CLI 要求 name + description 都在,缺一个就整个跳过:这里同步标成 invalid,别让用户选了才发现装不上
|
|
141
|
+
if (!fields.name || !fields.description) return { ...fallback, valid: false };
|
|
142
|
+
return { name: fields.name, description: fields.description, skillPath: entry.skillPath, valid: true };
|
|
143
|
+
} catch {
|
|
144
|
+
return fallback;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
33
148
|
/** 写操作全局单写者队列:`add` 会同时改 canonical 与 lock,按技能分桶没有意义(设计 §14.3)。 */
|
|
34
149
|
function createQueue(logger) {
|
|
35
150
|
let chain = Promise.resolve();
|
|
@@ -63,6 +178,11 @@ function createQueue(logger) {
|
|
|
63
178
|
export function createService({ paths, store, registry, cli, logger, env = process.env, fetchImpl = fetch, visibleProvider = async () => ({ skills: [], complete: true }), workspacesProvider = async () => [] }) {
|
|
64
179
|
const enqueue = createQueue(logger);
|
|
65
180
|
const tokenOf = (env.GITHUB_TOKEN || env.GH_TOKEN) || null;
|
|
181
|
+
const git = createGit({ env, logger });
|
|
182
|
+
// 探测缓存:同一来源短时间内重复检测直接复用;并发的相同请求合并成一次(不重复烧 API 配额)
|
|
183
|
+
const probeCache = new Map();
|
|
184
|
+
const PROBE_TTL_MS = 60_000;
|
|
185
|
+
const PROBE_MAX = 50;
|
|
66
186
|
|
|
67
187
|
function state() {
|
|
68
188
|
return store.state;
|
|
@@ -121,8 +241,267 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
121
241
|
return { created, skipped, failed };
|
|
122
242
|
}
|
|
123
243
|
|
|
124
|
-
|
|
125
|
-
|
|
244
|
+
/**
|
|
245
|
+
* 订阅的分支:显式 ref > 记录里的 ref > 仓库默认分支。解析成功后顺手记住,省掉下次请求。
|
|
246
|
+
* 返回 `{ branch, failure }`:`failure` 原样透出 `fetchDefaultBranch` 的结构化失败,
|
|
247
|
+
* 由 `readSnapshot` 决定报哪种错误(T1)。
|
|
248
|
+
*/
|
|
249
|
+
async function branchOf(subscription, { persist = false } = {}) {
|
|
250
|
+
if (subscription.ref) return { branch: subscription.ref, failure: null };
|
|
251
|
+
let resolved;
|
|
252
|
+
try {
|
|
253
|
+
resolved = await fetchDefaultBranch({
|
|
254
|
+
source: subscription.source,
|
|
255
|
+
token: tokenOf,
|
|
256
|
+
fetchImpl,
|
|
257
|
+
});
|
|
258
|
+
} catch (error) {
|
|
259
|
+
return { branch: null, failure: { status: null, rateLimited: false, resetAt: null, error: error?.message || String(error) } };
|
|
260
|
+
}
|
|
261
|
+
const branch = resolved?.branch || null;
|
|
262
|
+
if (!branch) return { branch: null, failure: resolved || null };
|
|
263
|
+
if (persist && !store.readOnly) {
|
|
264
|
+
store.update((draft) => {
|
|
265
|
+
const row = draft.subscriptions.find((item) => item.id === subscription.id);
|
|
266
|
+
if (row && !row.ref) row.ref = branch;
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
return { branch, failure: null };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* 读一次订阅快照:只有网络与纯计算,**不写 store**(`persistRef` 例外,只补订阅的 ref)。
|
|
274
|
+
* 上游失败一律抛 ServiceError —— 添加订阅必须先读到快照再记账,
|
|
275
|
+
* 否则会留下「订阅存在但一个 skill 都没有」的空壳(上游 404 时还会显示添加成功)。
|
|
276
|
+
*
|
|
277
|
+
* T4:REST 因**限流**或 **404** 失败时先试 git(零配额);git 也不行才抛 T1 的错误。
|
|
278
|
+
*/
|
|
279
|
+
async function readSnapshot(subscription, { force = false, withCommit = true, persistRef = false } = {}) {
|
|
280
|
+
const source = subscription.source;
|
|
281
|
+
try {
|
|
282
|
+
const { branch, failure } = await branchOf(subscription, { persist: persistRef });
|
|
283
|
+
if (!branch) {
|
|
284
|
+
if (failure?.rateLimited || failure?.status === 404) {
|
|
285
|
+
const viaGit = await gitSnapshot(subscription, { ref: subscription.ref || null });
|
|
286
|
+
if (viaGit) return viaGit;
|
|
287
|
+
}
|
|
288
|
+
throw upstreamError(source, failure);
|
|
289
|
+
}
|
|
290
|
+
const result = await fetchTree({
|
|
291
|
+
source,
|
|
292
|
+
ref: branch,
|
|
293
|
+
etag: force ? null : subscription.snapshot?.etag || null,
|
|
294
|
+
token: tokenOf,
|
|
295
|
+
fetchImpl,
|
|
296
|
+
});
|
|
297
|
+
if (result.error) {
|
|
298
|
+
// 元数据已经通了,树再 404 说明是 ref 没了(不是仓库不存在);只有限流值得转 git
|
|
299
|
+
if (result.rateLimited) {
|
|
300
|
+
const viaGit = await gitSnapshot(subscription, { ref: branch });
|
|
301
|
+
if (viaGit) return viaGit;
|
|
302
|
+
throw upstreamError(source, {
|
|
303
|
+
status: result.status ?? null,
|
|
304
|
+
rateLimited: true,
|
|
305
|
+
resetAt: result.resetAt ?? null,
|
|
306
|
+
error: result.error,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
throw new ServiceError('error.skills.upstream-failed', `读取 ${source}@${branch} 失败:${result.error}`, {
|
|
310
|
+
source,
|
|
311
|
+
ref: branch,
|
|
312
|
+
status: result.status ?? null,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
if (result.notModified) return { notModified: true, branch };
|
|
316
|
+
const entries = listSkillsFromTree({
|
|
317
|
+
tree: result.tree,
|
|
318
|
+
rootSha: result.rootSha,
|
|
319
|
+
subpath: subscription.subpath,
|
|
320
|
+
repoName: source.split('/').pop(),
|
|
321
|
+
});
|
|
322
|
+
const previousRoot = subscription.snapshot?.rootSha || null;
|
|
323
|
+
// 探测只需要文件树,不付 resolveCommit 那一次请求
|
|
324
|
+
const commit = !withCommit
|
|
325
|
+
? null
|
|
326
|
+
: previousRoot && previousRoot === result.rootSha && subscription.snapshot?.commit
|
|
327
|
+
? subscription.snapshot.commit
|
|
328
|
+
: await resolveCommit({ source, ref: branch, token: tokenOf, fetchImpl });
|
|
329
|
+
const changed =
|
|
330
|
+
!subscription.snapshot ||
|
|
331
|
+
previousRoot !== result.rootSha ||
|
|
332
|
+
JSON.stringify(entries) !== JSON.stringify(subscription.snapshot.entries);
|
|
333
|
+
return {
|
|
334
|
+
branch,
|
|
335
|
+
changed,
|
|
336
|
+
via: 'rest',
|
|
337
|
+
tree: result.tree,
|
|
338
|
+
rootSha: result.rootSha,
|
|
339
|
+
snapshot: { commit, rootSha: result.rootSha, entries, fetchedAt: nowIso(), etag: result.etag },
|
|
340
|
+
};
|
|
341
|
+
} catch (error) {
|
|
342
|
+
if (error instanceof ServiceError) throw error;
|
|
343
|
+
throw new ServiceError('error.skills.upstream-failed', `读取 ${source} 失败:${error?.message || error}`, {
|
|
344
|
+
source,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* T4:用 git 组装一份与 REST 同形的快照。`etag` 恒为 `null` —— 下次刷新会完整读一次,
|
|
351
|
+
* 绝不会拿旧 ETag 去冒充 304。失败返回 `null`,由调用方抛 T1 的错误。
|
|
352
|
+
*/
|
|
353
|
+
async function gitSnapshot(subscription, { ref = null, purpose = 'snapshot' } = {}) {
|
|
354
|
+
const source = subscription.source;
|
|
355
|
+
const event = purpose === 'probe' ? 'git.probe' : 'git.snapshot';
|
|
356
|
+
if (!git.enabled()) return null;
|
|
357
|
+
const startedAt = Date.now();
|
|
358
|
+
let url;
|
|
359
|
+
try {
|
|
360
|
+
url = git.url(source);
|
|
361
|
+
} catch (error) {
|
|
362
|
+
logger('git.fallback', { source, kind: error.kind || 'invalid-url', purpose });
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
if (!(await git.isAvailable())) {
|
|
366
|
+
logger('git.fallback', { source, kind: 'git-missing', purpose });
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
try {
|
|
370
|
+
let branch = ref || null;
|
|
371
|
+
if (!branch) {
|
|
372
|
+
const head = await git.lsRemote({ url });
|
|
373
|
+
branch = head.branch || 'main';
|
|
374
|
+
}
|
|
375
|
+
const tree = await git.tree({ url, ref: branch });
|
|
376
|
+
const entries = listSkillsFromTree({
|
|
377
|
+
tree: tree.entries,
|
|
378
|
+
rootSha: tree.rootSha,
|
|
379
|
+
subpath: subscription.subpath,
|
|
380
|
+
repoName: source.split('/').pop(),
|
|
381
|
+
});
|
|
382
|
+
const changed =
|
|
383
|
+
!subscription.snapshot ||
|
|
384
|
+
subscription.snapshot.rootSha !== tree.rootSha ||
|
|
385
|
+
JSON.stringify(entries) !== JSON.stringify(subscription.snapshot.entries);
|
|
386
|
+
logger(event, {
|
|
387
|
+
source,
|
|
388
|
+
ref: branch,
|
|
389
|
+
commit: tree.commit,
|
|
390
|
+
entries: entries.length,
|
|
391
|
+
changed,
|
|
392
|
+
ms: Date.now() - startedAt,
|
|
393
|
+
});
|
|
394
|
+
return {
|
|
395
|
+
branch,
|
|
396
|
+
changed,
|
|
397
|
+
via: 'git',
|
|
398
|
+
tree: tree.entries,
|
|
399
|
+
rootSha: tree.rootSha,
|
|
400
|
+
snapshot: { commit: tree.commit, rootSha: tree.rootSha, entries, fetchedAt: nowIso(), etag: null },
|
|
401
|
+
};
|
|
402
|
+
} catch (error) {
|
|
403
|
+
logger('git.fallback', {
|
|
404
|
+
source,
|
|
405
|
+
kind: error.kind || 'clone-failed',
|
|
406
|
+
message: error.message,
|
|
407
|
+
purpose,
|
|
408
|
+
ms: Date.now() - startedAt,
|
|
409
|
+
});
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* T4 commit 短路:订阅快照的 commit 与远端 HEAD 一致 → 没变化,不必请求 REST。
|
|
416
|
+
* 只对「跟着默认分支」的订阅生效:盯 tag / 其他分支时 HEAD 不代表它。
|
|
417
|
+
*/
|
|
418
|
+
async function remoteCommitUnchanged(subscription) {
|
|
419
|
+
const commit = subscription.snapshot?.commit;
|
|
420
|
+
if (!commit || !git.enabled()) return false;
|
|
421
|
+
if (!(await git.isAvailable())) return false;
|
|
422
|
+
try {
|
|
423
|
+
const head = await git.lsRemote({ url: git.url(subscription.source) });
|
|
424
|
+
if (subscription.ref && head.branch && subscription.ref !== head.branch) return false;
|
|
425
|
+
return head.commit === commit;
|
|
426
|
+
} catch (error) {
|
|
427
|
+
logger('git.fallback', {
|
|
428
|
+
source: subscription.source,
|
|
429
|
+
kind: error.kind || 'clone-failed',
|
|
430
|
+
purpose: 'refresh',
|
|
431
|
+
});
|
|
432
|
+
return false;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* 探测走 git:`ls-remote` 拿默认分支,`clone --filter=blob:none --no-checkout` 拿整树。
|
|
438
|
+
* 不占 GitHub REST 配额,也不需要 token / gh —— 出口 IP 匿名配额被耗尽时这条路仍然通。
|
|
439
|
+
* 失败一律返回 `null`,由调用方回退现有 REST 路径(默认开 = 只增不减)。
|
|
440
|
+
*/
|
|
441
|
+
async function probeViaGit({ source, ref, subpath }) {
|
|
442
|
+
const result = await gitSnapshot({ source, subpath, snapshot: null }, { ref, purpose: 'probe' });
|
|
443
|
+
if (!result) return null;
|
|
444
|
+
return { branch: result.branch, via: 'git', snapshot: result.snapshot };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* T3.7 安装后基线补录。CLI 在拿不到树时把 64 位内容哈希写进 lock(`add.ts:2254`),
|
|
449
|
+
* 「手填技能名」这条安装路径又没有快照可依 —— 装上之后用 git 把目录 tree SHA 补进
|
|
450
|
+
* `installs[].treeSha`,让 T2 的判定对这条路径同样生效。
|
|
451
|
+
*
|
|
452
|
+
* best effort:只读临时目录;任何失败只记日志,**不改安装结果**,`treeSha` 留 null。
|
|
453
|
+
*/
|
|
454
|
+
async function backfillBaselines({ names, source, lock, ref }) {
|
|
455
|
+
if (!git.enabled() || names.length === 0) return [];
|
|
456
|
+
const targets = [];
|
|
457
|
+
for (const name of names) {
|
|
458
|
+
const lockEntry = lock.skills?.[name];
|
|
459
|
+
if (hashShape(lockEntry?.skillFolderHash || null) !== 'content') continue;
|
|
460
|
+
const install = store.findInstall(name);
|
|
461
|
+
if (!install || install.treeSha) continue;
|
|
462
|
+
targets.push({ name, skillPath: lockEntry?.skillPath || install.skillPath || null });
|
|
463
|
+
}
|
|
464
|
+
if (targets.length === 0) return [];
|
|
465
|
+
if (!(await git.isAvailable())) {
|
|
466
|
+
logger('git.error', { source, kind: 'git-missing', purpose: 'baseline' });
|
|
467
|
+
return [];
|
|
468
|
+
}
|
|
469
|
+
const startedAt = Date.now();
|
|
470
|
+
try {
|
|
471
|
+
const url = git.url(source);
|
|
472
|
+
let useRef = ref || null;
|
|
473
|
+
if (!useRef) useRef = (await git.lsRemote({ url })).commit;
|
|
474
|
+
// 与探测共用进程内缓存:同一 (url, ref) 只 clone 一次
|
|
475
|
+
const tree = await git.tree({ url, ref: useRef });
|
|
476
|
+
const updated = [];
|
|
477
|
+
for (const target of targets) {
|
|
478
|
+
const treeSha = target.skillPath
|
|
479
|
+
? skillTreeSha({ tree: tree.entries, skillPath: target.skillPath, rootSha: tree.rootSha })
|
|
480
|
+
: null;
|
|
481
|
+
if (!treeSha) {
|
|
482
|
+
logger('git.error', { name: target.name, kind: 'no-tree-entry', purpose: 'baseline' });
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
store.update((draft) => {
|
|
486
|
+
const row = draft.installs.find((item) => item.name === target.name);
|
|
487
|
+
if (row && !row.treeSha) row.treeSha = treeSha;
|
|
488
|
+
});
|
|
489
|
+
updated.push({ name: target.name, treeSha });
|
|
490
|
+
}
|
|
491
|
+
if (updated.length > 0) logger('git.baseline', { source, ref: useRef, updated, ms: Date.now() - startedAt });
|
|
492
|
+
return updated;
|
|
493
|
+
} catch (error) {
|
|
494
|
+
logger('git.error', {
|
|
495
|
+
source,
|
|
496
|
+
kind: error.kind || 'clone-failed',
|
|
497
|
+
message: error.message,
|
|
498
|
+
purpose: 'baseline',
|
|
499
|
+
});
|
|
500
|
+
return [];
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function recordLink(name, target, linkPath, mode, created) { if (store.readOnly) return;
|
|
126
505
|
const existing = state().links.find((item) => item.skill === name && item.target === target.target);
|
|
127
506
|
if (existing) return;
|
|
128
507
|
if (!created) return;
|
|
@@ -203,6 +582,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
203
582
|
skillPath: record.skillPath,
|
|
204
583
|
ref: record.ref,
|
|
205
584
|
commit: null,
|
|
585
|
+
treeSha: null,
|
|
206
586
|
installedHash: record.installedHash,
|
|
207
587
|
installedAt: nowIso(),
|
|
208
588
|
updatedAt: nowIso(),
|
|
@@ -455,8 +835,8 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
455
835
|
if (!capabilities.usable) {
|
|
456
836
|
throw new ServiceError('error.skills.cli-missing', 'skills CLI 不可用或不支持 --json/--full-depth,写操作已禁用', capabilities);
|
|
457
837
|
}
|
|
458
|
-
|
|
459
|
-
|
|
838
|
+
// 技能名可留空:有订阅快照就装快照里的条目,否则交给 CLI 的 `--skill '*'`(整仓全部)
|
|
839
|
+
const requested = (skills || []).filter(Boolean);
|
|
460
840
|
|
|
461
841
|
return enqueue('install', async () => {
|
|
462
842
|
const subscription = subscriptionId
|
|
@@ -467,10 +847,11 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
467
847
|
if (!parseSource(resolvedSource)) throw new ServiceError('error.skills.bad-source', '只接受 owner/repo 或 GitHub URL(公开源)');
|
|
468
848
|
|
|
469
849
|
const snapshot = subscription?.snapshot || null;
|
|
850
|
+
const planned = requested.length > 0 ? requested : (snapshot?.entries || []).map((entry) => entry.name);
|
|
470
851
|
const pin = commit || snapshot?.commit || subscription?.ref || null;
|
|
471
852
|
const sourceArg = pin ? `${resolvedSource}#${pin}` : resolvedSource;
|
|
472
853
|
|
|
473
|
-
const result = await cli.add({ source: sourceArg, skills:
|
|
854
|
+
const result = await cli.add({ source: sourceArg, skills: planned });
|
|
474
855
|
if (result.timedOut) throw new ServiceError('error.skills.cli-timeout', 'skills add 超时');
|
|
475
856
|
if (result.code !== 0) {
|
|
476
857
|
throw new ServiceError('error.skills.cli-failed', 'skills add 失败', {
|
|
@@ -478,6 +859,13 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
478
859
|
stderr: result.stderr.slice(-2000),
|
|
479
860
|
});
|
|
480
861
|
}
|
|
862
|
+
// 留空时实际装了哪些技能以 CLI 的 JSON 输出为准(可能不止一个)
|
|
863
|
+
const names = planned.length > 0 ? planned : installedNamesFrom(result.stdout);
|
|
864
|
+
if (names.length === 0) {
|
|
865
|
+
throw new ServiceError('error.skills.cli-failed', 'skills add 成功,但输出里没有安装结果,无法记账', {
|
|
866
|
+
stdout: result.stdout.slice(-2000),
|
|
867
|
+
});
|
|
868
|
+
}
|
|
481
869
|
|
|
482
870
|
const lock = readLock(paths);
|
|
483
871
|
const installed = [];
|
|
@@ -493,12 +881,16 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
493
881
|
warnings.push({ name, warning: 'lock 里没有该技能的记录(来源形态不支持记账)' });
|
|
494
882
|
}
|
|
495
883
|
const expected = snapshot?.entries?.find((entry) => entry.name === name);
|
|
496
|
-
|
|
884
|
+
const lockHash = lockEntry?.skillFolderHash || null;
|
|
885
|
+
const shape = hashShape(lockHash);
|
|
886
|
+
// 只有两边都是 40 位 tree SHA 才有可比性。限流安装时 CLI 写的是 64 位内容哈希
|
|
887
|
+
// (add.ts:2254),拿它比 tree SHA 必然不等 —— 那是形态问题,不是装错了。
|
|
888
|
+
if (expected?.treeSha && shape === 'tree' && hashShape(expected.treeSha) === 'tree' && lockHash !== expected.treeSha) {
|
|
497
889
|
warnings.push({
|
|
498
890
|
name,
|
|
499
891
|
warning: 'lock 的 skillFolderHash 与快照 treeSha 不一致(可能装到了别的 commit)',
|
|
500
892
|
expected: expected.treeSha,
|
|
501
|
-
actual:
|
|
893
|
+
actual: lockHash,
|
|
502
894
|
});
|
|
503
895
|
}
|
|
504
896
|
const installedHash = fs.statSync(canonicalPath).isDirectory() ? contentHash(canonicalPath) : null;
|
|
@@ -508,6 +900,8 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
508
900
|
skillPath: lockEntry?.skillPath || expected?.skillPath || `${name}/SKILL.md`,
|
|
509
901
|
ref: pin,
|
|
510
902
|
commit: snapshot?.commit || null,
|
|
903
|
+
// 安装那一刻的 tree 基线:lock 被写成内容哈希时,更新判定靠它(T2)
|
|
904
|
+
treeSha: expected?.treeSha || null,
|
|
511
905
|
installedHash,
|
|
512
906
|
installedAt: nowIso(),
|
|
513
907
|
updatedAt: nowIso(),
|
|
@@ -520,7 +914,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
520
914
|
if (index >= 0) draft.installs[index] = { ...draft.installs[index], ...record, installedAt: draft.installs[index].installedAt };
|
|
521
915
|
else draft.installs.push(record);
|
|
522
916
|
});
|
|
523
|
-
installed.push({ name, lockHash:
|
|
917
|
+
installed.push({ name, lockHash, lockHashShape: shape, installedHash });
|
|
524
918
|
}
|
|
525
919
|
|
|
526
920
|
const linkTargets = Array.isArray(targets) && targets.length > 0 ? targets : defaultTargets(state());
|
|
@@ -532,6 +926,14 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
532
926
|
linkResult.failed.push(...one.failed);
|
|
533
927
|
}
|
|
534
928
|
|
|
929
|
+
// T3.7:lock 被写成 64 位内容哈希形态(限流安装)时用 git 补一条 tree 基线,
|
|
930
|
+
// 否则「手填技能名」装出来的技能在更新检查里只能是 unknown。
|
|
931
|
+
const baselines = await backfillBaselines({ names, source: resolvedSource, lock, ref: pin });
|
|
932
|
+
for (const item of installed) {
|
|
933
|
+
const filled = baselines.find((row) => row.name === item.name);
|
|
934
|
+
if (filled) item.treeSha = filled.treeSha;
|
|
935
|
+
}
|
|
936
|
+
|
|
535
937
|
logger('install', { source: sourceArg, skills: names, installed: installed.length });
|
|
536
938
|
return {
|
|
537
939
|
ok: true,
|
|
@@ -605,26 +1007,148 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
605
1007
|
});
|
|
606
1008
|
},
|
|
607
1009
|
|
|
1010
|
+
/** 只读探测来源里的 SKILL.md,供「添加技能」多选;不写 state、不建订阅。 */
|
|
1011
|
+
async probeSource({ source, ref, subpath }) {
|
|
1012
|
+
const parsed = parseSource(source);
|
|
1013
|
+
if (!parsed) throw new ServiceError('error.skills.bad-source', '只接受 owner/repo 或 GitHub URL(公开源)');
|
|
1014
|
+
const canonicalSource = `${parsed.owner}/${parsed.repo}`;
|
|
1015
|
+
const requestedRef = typeof ref === 'string' && ref.trim() ? ref.trim() : null;
|
|
1016
|
+
const requestedSubpath = typeof subpath === 'string' && subpath.trim()
|
|
1017
|
+
? subpath.trim().replace(/^\/+|\/+$/g, '') || null
|
|
1018
|
+
: null;
|
|
1019
|
+
const key = `${canonicalSource}@${requestedRef || ''}#${requestedSubpath || ''}`;
|
|
1020
|
+
const cached = probeCache.get(key);
|
|
1021
|
+
if (cached && (cached.pending || Date.now() - cached.at < PROBE_TTL_MS)) {
|
|
1022
|
+
return cached.pending || cached.value;
|
|
1023
|
+
}
|
|
1024
|
+
const pending = (async () => {
|
|
1025
|
+
// 优先 git(零配额);不启用 / 不可用 / 失败都回落到原 REST 路径
|
|
1026
|
+
const viaGit = await probeViaGit({
|
|
1027
|
+
source: canonicalSource,
|
|
1028
|
+
ref: requestedRef,
|
|
1029
|
+
subpath: requestedSubpath,
|
|
1030
|
+
});
|
|
1031
|
+
const probe =
|
|
1032
|
+
viaGit ||
|
|
1033
|
+
(await readSnapshot(
|
|
1034
|
+
{
|
|
1035
|
+
id: null,
|
|
1036
|
+
source: canonicalSource,
|
|
1037
|
+
ref: requestedRef,
|
|
1038
|
+
subpath: requestedSubpath,
|
|
1039
|
+
snapshot: null,
|
|
1040
|
+
},
|
|
1041
|
+
{ force: true, withCommit: false },
|
|
1042
|
+
));
|
|
1043
|
+
// 描述优先按 commit 抓(不可变、可缓存),没有 commit 再退回分支名
|
|
1044
|
+
const enrichRef = probe.snapshot?.commit || probe.branch;
|
|
1045
|
+
const enriched = await enrichSkillEntries({
|
|
1046
|
+
source: canonicalSource,
|
|
1047
|
+
ref: enrichRef,
|
|
1048
|
+
entries: probe.snapshot.entries,
|
|
1049
|
+
token: tokenOf,
|
|
1050
|
+
fetchImpl,
|
|
1051
|
+
});
|
|
1052
|
+
const skills = enriched
|
|
1053
|
+
.filter((skill) => skill.valid)
|
|
1054
|
+
.map(({ name, description, skillPath }) => ({ name, description, skillPath }));
|
|
1055
|
+
return {
|
|
1056
|
+
ok: true,
|
|
1057
|
+
source: canonicalSource,
|
|
1058
|
+
ref: probe.branch,
|
|
1059
|
+
via: probe.via || 'rest',
|
|
1060
|
+
skills,
|
|
1061
|
+
skipped: enriched.length - skills.length,
|
|
1062
|
+
};
|
|
1063
|
+
})();
|
|
1064
|
+
probeCache.set(key, { pending, at: Date.now() });
|
|
1065
|
+
try {
|
|
1066
|
+
const value = await pending;
|
|
1067
|
+
probeCache.set(key, { value, at: Date.now() });
|
|
1068
|
+
for (const oldest of probeCache.keys()) {
|
|
1069
|
+
if (probeCache.size <= PROBE_MAX) break;
|
|
1070
|
+
probeCache.delete(oldest);
|
|
1071
|
+
}
|
|
1072
|
+
return value;
|
|
1073
|
+
} catch (error) {
|
|
1074
|
+
// 失败不留缓存,用户改一下来源或重试就能重新探测
|
|
1075
|
+
probeCache.delete(key);
|
|
1076
|
+
throw error;
|
|
1077
|
+
}
|
|
1078
|
+
},
|
|
1079
|
+
|
|
608
1080
|
async addSubscription({ source, ref, subpath, skills = '*' }) {
|
|
609
1081
|
assertWritable();
|
|
610
1082
|
const parsed = parseSource(source);
|
|
611
1083
|
if (!parsed) throw new ServiceError('error.skills.bad-source', '订阅只接受 owner/repo 或 GitHub URL');
|
|
612
|
-
const
|
|
613
|
-
const
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
1084
|
+
const canonicalSource = `${parsed.owner}/${parsed.repo}`;
|
|
1085
|
+
const requestedRef = typeof ref === 'string' && ref.trim() ? ref.trim() : null;
|
|
1086
|
+
const requestedSubpath = typeof subpath === 'string' && subpath.trim()
|
|
1087
|
+
? subpath.trim().replace(/^\/+|\/+$/g, '') || null
|
|
1088
|
+
: null;
|
|
1089
|
+
const id = snapshotId({ source: canonicalSource, ref: requestedRef, subpath: requestedSubpath });
|
|
1090
|
+
// 老记录可能用 URL / 原始字符串算过 id:按来源+ref+子目录再找一遍,避免同一仓库出现两条订阅
|
|
1091
|
+
const existing =
|
|
1092
|
+
state().subscriptions.find((item) => item.id === id) ||
|
|
1093
|
+
state().subscriptions.find(
|
|
1094
|
+
(item) =>
|
|
1095
|
+
item.source === canonicalSource &&
|
|
1096
|
+
(item.ref || null) === requestedRef &&
|
|
1097
|
+
(item.subpath || null) === requestedSubpath,
|
|
1098
|
+
);
|
|
1099
|
+
// 已有快照的重复添加直接返回;空壳(旧版本失败留下的)要走一次真实拉取,成功后再补全
|
|
1100
|
+
if (existing?.snapshot) return { ok: true, subscription: existing, existing: true };
|
|
1101
|
+
|
|
1102
|
+
const candidate = {
|
|
1103
|
+
id: existing?.id || id,
|
|
1104
|
+
source: canonicalSource,
|
|
1105
|
+
ref: requestedRef || existing?.ref || null,
|
|
1106
|
+
subpath: requestedSubpath,
|
|
620
1107
|
skills,
|
|
621
1108
|
lastCheck: null,
|
|
622
1109
|
snapshot: null,
|
|
623
1110
|
};
|
|
1111
|
+
|
|
1112
|
+
// 先读快照再落盘:上游失败时 state 里不能留下订阅,接口也必须如实报错
|
|
1113
|
+
const probe = await readSnapshot(candidate, { force: true });
|
|
1114
|
+
// 一条 skill 都没有 = 订阅了也没东西可装(多半是 subpath/ref 写错),同样不能算添加成功
|
|
1115
|
+
if (probe.snapshot.entries.length === 0) {
|
|
1116
|
+
throw new ServiceError(
|
|
1117
|
+
'error.skills.no-skills',
|
|
1118
|
+
`${canonicalSource}@${probe.branch}${requestedSubpath ? `/${requestedSubpath}` : ''} 里没有找到 SKILL.md`,
|
|
1119
|
+
{ source: canonicalSource, ref: probe.branch, subpath: requestedSubpath },
|
|
1120
|
+
);
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
const record = {
|
|
1124
|
+
...candidate,
|
|
1125
|
+
...(existing?.adopted ? { adopted: true } : {}),
|
|
1126
|
+
ref: probe.branch,
|
|
1127
|
+
snapshot: probe.snapshot,
|
|
1128
|
+
lastCheck: {
|
|
1129
|
+
at: nowIso(),
|
|
1130
|
+
commit: probe.snapshot.commit,
|
|
1131
|
+
treeEtag: probe.snapshot.etag,
|
|
1132
|
+
status: 'changed',
|
|
1133
|
+
},
|
|
1134
|
+
};
|
|
624
1135
|
store.update((draft) => {
|
|
625
|
-
draft.subscriptions.
|
|
1136
|
+
const index = draft.subscriptions.findIndex((item) => item.id === record.id);
|
|
1137
|
+
if (index >= 0) draft.subscriptions[index] = { ...draft.subscriptions[index], ...record };
|
|
1138
|
+
else draft.subscriptions.push(record);
|
|
1139
|
+
});
|
|
1140
|
+
logger('subscription.add', {
|
|
1141
|
+
id: record.id,
|
|
1142
|
+
source: record.source,
|
|
1143
|
+
ref: record.ref,
|
|
1144
|
+
entries: record.snapshot.entries.length,
|
|
626
1145
|
});
|
|
627
|
-
return {
|
|
1146
|
+
return {
|
|
1147
|
+
ok: true,
|
|
1148
|
+
changed: true,
|
|
1149
|
+
entries: record.snapshot.entries.length,
|
|
1150
|
+
subscription: state().subscriptions.find((item) => item.id === record.id),
|
|
1151
|
+
};
|
|
628
1152
|
},
|
|
629
1153
|
|
|
630
1154
|
async removeSubscription({ id, keepInstalled = true }) {
|
|
@@ -646,55 +1170,56 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
646
1170
|
assertWritable();
|
|
647
1171
|
const subscription = state().subscriptions.find((item) => item.id === id);
|
|
648
1172
|
if (!subscription) throw new ServiceError('error.skills.no-subscription', '订阅不存在');
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
source: subscription.source,
|
|
653
|
-
ref,
|
|
654
|
-
etag: force ? null : previous,
|
|
655
|
-
token: tokenOf,
|
|
656
|
-
fetchImpl,
|
|
657
|
-
});
|
|
658
|
-
if (result.error) {
|
|
1173
|
+
// T4 短路:最常见的情况是「什么都没变」。先用 ls-remote 比 commit,一致就一次 REST 都不发。
|
|
1174
|
+
if (await remoteCommitUnchanged(subscription)) {
|
|
1175
|
+
const at = nowIso();
|
|
659
1176
|
store.update((draft) => {
|
|
660
1177
|
const row = draft.subscriptions.find((item) => item.id === id);
|
|
661
|
-
if (row) row.lastCheck = {
|
|
1178
|
+
if (row) row.lastCheck = { ...(row.lastCheck || {}), at, status: 'unchanged' };
|
|
662
1179
|
});
|
|
663
|
-
|
|
1180
|
+
logger('subscription.refresh', { id, notModified: true, via: 'git' });
|
|
1181
|
+
return {
|
|
1182
|
+
ok: true,
|
|
1183
|
+
changed: false,
|
|
1184
|
+
notModified: true,
|
|
1185
|
+
via: 'git',
|
|
1186
|
+
subscription: state().subscriptions.find((item) => item.id === id),
|
|
1187
|
+
};
|
|
664
1188
|
}
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
1189
|
+
let probe;
|
|
1190
|
+
try {
|
|
1191
|
+
probe = await readSnapshot(subscription, { force });
|
|
1192
|
+
} catch (error) {
|
|
1193
|
+
// 失败留痕(清单里能看到 lastCheck),但接口必须报错,不能把失败伪装成成功
|
|
1194
|
+
if (!store.readOnly) {
|
|
1195
|
+
store.update((draft) => {
|
|
1196
|
+
const row = draft.subscriptions.find((item) => item.id === id);
|
|
1197
|
+
if (row) row.lastCheck = { at: nowIso(), status: 'unknown', detail: error.message };
|
|
1198
|
+
});
|
|
1199
|
+
}
|
|
1200
|
+
throw error;
|
|
672
1201
|
}
|
|
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);
|
|
1202
|
+
const at = nowIso();
|
|
690
1203
|
store.update((draft) => {
|
|
691
1204
|
const row = draft.subscriptions.find((item) => item.id === id);
|
|
692
1205
|
if (!row) return;
|
|
693
|
-
row.
|
|
694
|
-
|
|
1206
|
+
if (!row.ref && probe.branch) row.ref = probe.branch;
|
|
1207
|
+
if (probe.notModified) {
|
|
1208
|
+
// 304 只刷新时间,绝不把 entries 置空
|
|
1209
|
+
row.lastCheck = { ...(row.lastCheck || {}), at, status: 'unchanged' };
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
row.snapshot = probe.snapshot;
|
|
1213
|
+
row.lastCheck = { at, commit: probe.snapshot.commit, treeEtag: probe.snapshot.etag, status: probe.changed ? 'changed' : 'unchanged' };
|
|
695
1214
|
});
|
|
696
|
-
logger('subscription.refresh', { id, entries: entries.length, changed });
|
|
697
|
-
return {
|
|
1215
|
+
if (!probe.notModified) logger('subscription.refresh', { id, entries: probe.snapshot.entries.length, changed: probe.changed });
|
|
1216
|
+
return {
|
|
1217
|
+
ok: true,
|
|
1218
|
+
changed: !probe.notModified && probe.changed,
|
|
1219
|
+
...(probe.notModified ? { notModified: true } : {}),
|
|
1220
|
+
via: probe.via || 'rest',
|
|
1221
|
+
subscription: state().subscriptions.find((item) => item.id === id),
|
|
1222
|
+
};
|
|
698
1223
|
},
|
|
699
1224
|
|
|
700
1225
|
async checkUpdates({ names, subscriptionId } = {}) {
|
|
@@ -708,27 +1233,31 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
708
1233
|
for (const install of installs) {
|
|
709
1234
|
const subscription = state().subscriptions.find((item) => item.id === install.subscriptionId);
|
|
710
1235
|
if (!subscription) continue;
|
|
711
|
-
|
|
712
|
-
if (!groups.has(
|
|
713
|
-
groups.get(
|
|
1236
|
+
// 按订阅(id 已含 source/ref/subpath)分组:不能用 `ref || 'main'` 当键,否则 null-ref 与 main 会撞车
|
|
1237
|
+
if (!groups.has(subscription.id)) groups.set(subscription.id, { subscription, installs: [] });
|
|
1238
|
+
groups.get(subscription.id).installs.push(install);
|
|
714
1239
|
}
|
|
715
1240
|
|
|
716
1241
|
const results = [];
|
|
717
1242
|
for (const { subscription, installs: groupInstalls } of groups.values()) {
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
});
|
|
725
|
-
if (tree.error) {
|
|
1243
|
+
// T4:与 readSnapshot 同一套语义 —— 限流 / 404 时先试 git,都失败才逐个标 unknown
|
|
1244
|
+
// withCommit: false:判定只需要树,别为 check 多付一次 /commits 请求
|
|
1245
|
+
let probe;
|
|
1246
|
+
try {
|
|
1247
|
+
probe = await readSnapshot(subscription, { withCommit: false, persistRef: true });
|
|
1248
|
+
} catch (error) {
|
|
726
1249
|
for (const install of groupInstalls) {
|
|
727
|
-
results.push({
|
|
1250
|
+
results.push({
|
|
1251
|
+
name: install.name,
|
|
1252
|
+
status: 'unknown',
|
|
1253
|
+
detail: error.message,
|
|
1254
|
+
code: error.code || null,
|
|
1255
|
+
checkedAt: nowIso(),
|
|
1256
|
+
});
|
|
728
1257
|
}
|
|
729
1258
|
continue;
|
|
730
1259
|
}
|
|
731
|
-
if (
|
|
1260
|
+
if (probe.notModified) {
|
|
732
1261
|
for (const install of groupInstalls) {
|
|
733
1262
|
const previousCheck = state().checks?.[install.name];
|
|
734
1263
|
results.push({
|
|
@@ -742,16 +1271,17 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
742
1271
|
}
|
|
743
1272
|
for (const install of groupInstalls) {
|
|
744
1273
|
const skillPath = lock.skills?.[install.name]?.skillPath || install.skillPath;
|
|
745
|
-
const entry =
|
|
1274
|
+
const entry = probe.tree.some((item) => item.type === 'blob' && item.path === skillPath)
|
|
746
1275
|
? { skillPath }
|
|
747
1276
|
: null;
|
|
748
1277
|
const verdict = checkSkill({
|
|
749
1278
|
lockHash: lock.skills?.[install.name]?.skillFolderHash || null,
|
|
1279
|
+
recordedTreeSha: install.treeSha ?? null,
|
|
750
1280
|
snapshotEntry: entry,
|
|
751
|
-
tree:
|
|
752
|
-
rootSha:
|
|
1281
|
+
tree: probe.tree,
|
|
1282
|
+
rootSha: probe.rootSha,
|
|
753
1283
|
});
|
|
754
|
-
results.push({ name: install.name, ...verdict, checkedAt: nowIso() });
|
|
1284
|
+
results.push({ name: install.name, ...verdict, via: probe.via, checkedAt: nowIso() });
|
|
755
1285
|
}
|
|
756
1286
|
}
|
|
757
1287
|
|
|
@@ -779,19 +1309,15 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
779
1309
|
if (!install) throw new ServiceError('error.skills.not-managed', `${name} 没有安装记录`);
|
|
780
1310
|
const subscription = state().subscriptions.find((item) => item.id === install.subscriptionId);
|
|
781
1311
|
if (!subscription) throw new ServiceError('error.skills.no-subscription', '该技能不属于任何订阅,无法生成更新计划');
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
token: tokenOf,
|
|
786
|
-
fetchImpl,
|
|
787
|
-
});
|
|
788
|
-
if (tree.error) throw new ServiceError('error.skills.upstream-failed', tree.error);
|
|
1312
|
+
// 与 checkUpdates 共用一套取数语义:限流时同样能靠 git 出计划(否则「查得到更新却更不了」)
|
|
1313
|
+
const probe = await readSnapshot(subscription, { force: true });
|
|
1314
|
+
const ref = probe.branch;
|
|
789
1315
|
const lock = readLock(paths);
|
|
790
1316
|
const skillPath = lock.skills?.[name]?.skillPath || install.skillPath;
|
|
791
|
-
const entry =
|
|
1317
|
+
const entry = probe.tree.find((item) => item.type === 'blob' && item.path === skillPath);
|
|
792
1318
|
if (!entry) throw new ServiceError('error.skills.upstream-deleted', '上游已移除该技能');
|
|
793
|
-
const targetTreeSha = skillTreeSha({ tree:
|
|
794
|
-
const targetCommit =
|
|
1319
|
+
const targetTreeSha = skillTreeSha({ tree: probe.tree, skillPath, rootSha: probe.rootSha });
|
|
1320
|
+
const targetCommit = probe.snapshot?.commit ?? null;
|
|
795
1321
|
const token = planToken({
|
|
796
1322
|
subscriptionId: subscription.id,
|
|
797
1323
|
from: install.commit || null,
|
|
@@ -806,6 +1332,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
806
1332
|
token,
|
|
807
1333
|
fromCommit: install.commit || null,
|
|
808
1334
|
toCommit: targetCommit,
|
|
1335
|
+
ref,
|
|
809
1336
|
targetTreeSha,
|
|
810
1337
|
skillPath,
|
|
811
1338
|
localModified: Boolean(install.installedHash) && contentHash(path.join(paths.canonical, name)) !== install.installedHash,
|
|
@@ -833,7 +1360,9 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
833
1360
|
? createBackup({ paths, skill: name, fromDir: canonicalPath, hash: install.installedHash })
|
|
834
1361
|
: null;
|
|
835
1362
|
|
|
836
|
-
const sourceArg = plan.toCommit
|
|
1363
|
+
const sourceArg = plan.toCommit
|
|
1364
|
+
? `${subscription.source}#${plan.toCommit}`
|
|
1365
|
+
: `${subscription.source}#${plan.ref || subscription.ref || 'main'}`;
|
|
837
1366
|
const result = await cli.add({ source: sourceArg, skills: [name] });
|
|
838
1367
|
const lock = readLock(paths);
|
|
839
1368
|
const lockHash = lock.skills?.[name]?.skillFolderHash || null;
|
|
@@ -857,9 +1386,11 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
857
1386
|
const row = draft.installs.find((item) => item.name === name);
|
|
858
1387
|
if (!row) return;
|
|
859
1388
|
row.commit = plan.toCommit;
|
|
1389
|
+
// 更新后基线跟着走:lock 若仍是内容哈希形态,下一次判定要用新的 tree SHA
|
|
1390
|
+
row.treeSha = plan.targetTreeSha || row.treeSha || null;
|
|
860
1391
|
row.installedHash = installedHash;
|
|
861
1392
|
row.updatedAt = nowIso();
|
|
862
|
-
row.backup = backup ? { key: backup.key, hash: install.installedHash || null } : row.backup;
|
|
1393
|
+
row.backup = backup ? { key: backup.key, hash: install.installedHash || null, treeSha: install.treeSha ?? null } : row.backup;
|
|
863
1394
|
draft.checks[name] = { name, status: 'current', detail: null, wouldCommit: null, checkedAt: nowIso(), installCommit: plan.toCommit };
|
|
864
1395
|
});
|
|
865
1396
|
|
|
@@ -886,6 +1417,8 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
886
1417
|
row.installedHash = installedHash;
|
|
887
1418
|
row.updatedAt = nowIso();
|
|
888
1419
|
row.commit = null;
|
|
1420
|
+
// 回滚回去的是备份那一刻的内容,tree 基线也得跟着退回去(不知道就置空,绝不沿用新版)
|
|
1421
|
+
row.treeSha = install.backup.treeSha ?? null;
|
|
889
1422
|
draft.checks[name] = { name, status: 'unknown', detail: '已回滚,需重新检查', checkedAt: nowIso() };
|
|
890
1423
|
});
|
|
891
1424
|
logger('updates.rollback', { name, backup: install.backup.key });
|