@morehao/dsh-skills-console 0.2.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -17
- package/lib/core/archive.js +246 -0
- package/lib/core/discovery.js +148 -0
- package/lib/core/git-tree.js +132 -0
- package/lib/core/installer.js +351 -0
- package/lib/core/lock.js +310 -0
- package/lib/core/paths.js +6 -2
- package/lib/core/remote.js +332 -0
- package/lib/core/service.js +357 -326
- package/lib/core/skill-md.js +49 -0
- package/lib/core/trash.js +2 -2
- package/lib/index.js +3 -3
- package/package.json +4 -1
- package/lib/core/cli.js +0 -234
- package/lib/core/git.js +0 -282
package/lib/core/service.js
CHANGED
|
@@ -2,10 +2,11 @@ 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';
|
|
6
5
|
import { ensureLink, inspectTarget, removeLink } from './links.js';
|
|
7
6
|
import { buildLedger, readLock, scanCanonical } from './inventory.js';
|
|
8
|
-
import {
|
|
7
|
+
import { readLockFile, updateLock } from './lock.js';
|
|
8
|
+
import { readBackup, restoreBackup, trashSkill } from './trash.js';
|
|
9
|
+
import { parseSkillFrontmatter } from './skill-md.js';
|
|
9
10
|
import {
|
|
10
11
|
checkSkill,
|
|
11
12
|
fetchDefaultBranch,
|
|
@@ -18,6 +19,7 @@ import {
|
|
|
18
19
|
skillTreeSha,
|
|
19
20
|
snapshotId,
|
|
20
21
|
} from './updates.js';
|
|
22
|
+
import { fetchAtomCommit, snapshotFromTarball } from './remote.js';
|
|
21
23
|
|
|
22
24
|
const QUEUE_LIMIT = 8;
|
|
23
25
|
|
|
@@ -33,6 +35,48 @@ function nowIso() {
|
|
|
33
35
|
return new Date().toISOString();
|
|
34
36
|
}
|
|
35
37
|
|
|
38
|
+
/** 路由参数里的技能名:在拼任何路径之前先校验,拒绝穿越/分隔符/控制字符。 */
|
|
39
|
+
function assertSafeSkillName(name) {
|
|
40
|
+
if (
|
|
41
|
+
typeof name !== 'string' ||
|
|
42
|
+
name.length === 0 ||
|
|
43
|
+
name.length > 120 ||
|
|
44
|
+
name.includes('/') ||
|
|
45
|
+
name.includes('\\') ||
|
|
46
|
+
name.includes('..') ||
|
|
47
|
+
// eslint-disable-next-line no-control-regex
|
|
48
|
+
/[\u0000-\u001f\u007f]/.test(name)
|
|
49
|
+
) {
|
|
50
|
+
throw new ServiceError('error.skills.not-found', `技能名非法: ${String(name)}`);
|
|
51
|
+
}
|
|
52
|
+
return name;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Native installer 的失败 → 现有通用错误码(不新增前端交互,只换 toast 文案)。 */
|
|
56
|
+
function installerFailure(error, source, action = '安装') {
|
|
57
|
+
const code = error?.code || null;
|
|
58
|
+
const status = error?.status ?? null;
|
|
59
|
+
const detail = { reason: code, status, ...(error?.available ? { available: error.available } : {}) };
|
|
60
|
+
if (code === 'bad-source') return new ServiceError('error.skills.bad-source', '只接受 owner/repo 或 GitHub URL(公开源)');
|
|
61
|
+
if (code === 'no-skills') return new ServiceError('error.skills.no-skills', error.message, detail);
|
|
62
|
+
if (code === 'skill-not-found') {
|
|
63
|
+
return new ServiceError('error.skills.no-skills', error.message, detail);
|
|
64
|
+
}
|
|
65
|
+
if (code === 'timeout') {
|
|
66
|
+
return new ServiceError('error.skills.network', '下载技能包超时,请检查网络或代理后重试', detail);
|
|
67
|
+
}
|
|
68
|
+
if (code === 'tarball-too-large') {
|
|
69
|
+
return new ServiceError('error.skills.install-failed', '技能包超过大小上限,已中止安装', detail);
|
|
70
|
+
}
|
|
71
|
+
if (code === 'http-error' && status) {
|
|
72
|
+
return upstreamError(source, { status, error: error.message });
|
|
73
|
+
}
|
|
74
|
+
if (['lock-unsupported', 'lock-corrupt', 'lock-unreadable', 'lock-conflict', 'lock-write-failed'].includes(code)) {
|
|
75
|
+
return new ServiceError('error.skills.install-failed', `${action}失败:lock 不可写或已被其他进程修改(${code})`, detail);
|
|
76
|
+
}
|
|
77
|
+
return new ServiceError('error.skills.install-failed', `${action}失败:${error?.message || error}`, detail);
|
|
78
|
+
}
|
|
79
|
+
|
|
36
80
|
/** 本机时区的 `HH:mm`。手写而不是 `toLocaleTimeString`:不受 ICU / locale 差异影响。 */
|
|
37
81
|
function formatClock(ms) {
|
|
38
82
|
const at = new Date(ms);
|
|
@@ -68,51 +112,6 @@ function upstreamError(source, failure) {
|
|
|
68
112
|
return new ServiceError('error.skills.network', '无法连接 GitHub(网络或代理问题)', detail);
|
|
69
113
|
}
|
|
70
114
|
|
|
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
115
|
/** 用 raw 内容补 frontmatter 里的真实技能名/描述(CLI 按 name 安装);取不到就回落目录名。 */
|
|
117
116
|
async function enrichSkillEntries({ source, ref, entries, token, fetchImpl, concurrency = 8 }) {
|
|
118
117
|
const results = new Array(entries.length);
|
|
@@ -175,10 +174,19 @@ function createQueue(logger) {
|
|
|
175
174
|
|
|
176
175
|
|
|
177
176
|
|
|
178
|
-
export function createService({
|
|
177
|
+
export function createService({
|
|
178
|
+
paths,
|
|
179
|
+
store,
|
|
180
|
+
registry,
|
|
181
|
+
installer = null,
|
|
182
|
+
logger,
|
|
183
|
+
env = process.env,
|
|
184
|
+
fetchImpl = fetch,
|
|
185
|
+
visibleProvider = async () => ({ skills: [], complete: true }),
|
|
186
|
+
workspacesProvider = async () => [],
|
|
187
|
+
}) {
|
|
179
188
|
const enqueue = createQueue(logger);
|
|
180
189
|
const tokenOf = (env.GITHUB_TOKEN || env.GH_TOKEN) || null;
|
|
181
|
-
const git = createGit({ env, logger });
|
|
182
190
|
// 探测缓存:同一来源短时间内重复检测直接复用;并发的相同请求合并成一次(不重复烧 API 配额)
|
|
183
191
|
const probeCache = new Map();
|
|
184
192
|
const PROBE_TTL_MS = 60_000;
|
|
@@ -192,6 +200,34 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
192
200
|
if (store.readOnly) throw new StateReadOnlyError(store.problem);
|
|
193
201
|
}
|
|
194
202
|
|
|
203
|
+
/** `/state.cli` 兼容字段:`usable` 由 native installer 与 lock 的可写性决定。 */
|
|
204
|
+
function nativeCapabilities() {
|
|
205
|
+
const capabilities = installer?.readiness?.() || {
|
|
206
|
+
usable: false,
|
|
207
|
+
source: 'missing',
|
|
208
|
+
version: null,
|
|
209
|
+
json: false,
|
|
210
|
+
fullDepth: false,
|
|
211
|
+
problem: 'installer-missing',
|
|
212
|
+
};
|
|
213
|
+
return capabilities;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** 安装/更新/删除写操作的就绪门禁:只看 native installer 与 lock 的可写性。 */
|
|
217
|
+
async function requireInstaller() {
|
|
218
|
+
const capabilities = nativeCapabilities();
|
|
219
|
+
if (!capabilities.usable) {
|
|
220
|
+
throw new ServiceError(
|
|
221
|
+
'error.skills.installer-unavailable',
|
|
222
|
+
'本地安装器不可用(lock 版本/权限异常),写操作已禁用',
|
|
223
|
+
capabilities,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
return capabilities;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
|
|
195
231
|
function detectedTargets() {
|
|
196
232
|
return registry.detected();
|
|
197
233
|
}
|
|
@@ -274,16 +310,23 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
274
310
|
* 上游失败一律抛 ServiceError —— 添加订阅必须先读到快照再记账,
|
|
275
311
|
* 否则会留下「订阅存在但一个 skill 都没有」的空壳(上游 404 时还会显示添加成功)。
|
|
276
312
|
*
|
|
277
|
-
*
|
|
313
|
+
* 数据源:REST 优先;403/429 限流时用 Atom 取 commit,再下 codeload tarball 本地扫描,
|
|
314
|
+
* 不启动任何外部进程。`allowUnchanged=false` 供更新计划使用:即使 commit 没变也要拿完整树。
|
|
278
315
|
*/
|
|
279
|
-
async function readSnapshot(
|
|
316
|
+
async function readSnapshot(
|
|
317
|
+
subscription,
|
|
318
|
+
{ force = false, withCommit = true, persistRef = false, allowUnchanged = true } = {},
|
|
319
|
+
) {
|
|
280
320
|
const source = subscription.source;
|
|
281
321
|
try {
|
|
282
322
|
const { branch, failure } = await branchOf(subscription, { persist: persistRef });
|
|
283
323
|
if (!branch) {
|
|
284
|
-
if (failure?.rateLimited
|
|
285
|
-
|
|
286
|
-
|
|
324
|
+
if (failure?.rateLimited) {
|
|
325
|
+
return await atomSnapshot(subscription, {
|
|
326
|
+
ref: subscription.ref || null,
|
|
327
|
+
allowUnchanged,
|
|
328
|
+
rateLimit: failure,
|
|
329
|
+
});
|
|
287
330
|
}
|
|
288
331
|
throw upstreamError(source, failure);
|
|
289
332
|
}
|
|
@@ -295,16 +338,9 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
295
338
|
fetchImpl,
|
|
296
339
|
});
|
|
297
340
|
if (result.error) {
|
|
298
|
-
// 元数据已经通了,树再 404 说明是 ref
|
|
341
|
+
// 元数据已经通了,树再 404 说明是 ref 没了(不是仓库不存在);只有限流降级
|
|
299
342
|
if (result.rateLimited) {
|
|
300
|
-
|
|
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
|
-
});
|
|
343
|
+
return await atomSnapshot(subscription, { ref: branch, allowUnchanged, rateLimit: result });
|
|
308
344
|
}
|
|
309
345
|
throw new ServiceError('error.skills.upstream-failed', `读取 ${source}@${branch} 失败:${result.error}`, {
|
|
310
346
|
source,
|
|
@@ -312,7 +348,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
312
348
|
status: result.status ?? null,
|
|
313
349
|
});
|
|
314
350
|
}
|
|
315
|
-
if (result.notModified) return { notModified: true, branch };
|
|
351
|
+
if (result.notModified) return { notModified: true, branch, via: 'rest' };
|
|
316
352
|
const entries = listSkillsFromTree({
|
|
317
353
|
tree: result.tree,
|
|
318
354
|
rootSha: result.rootSha,
|
|
@@ -347,161 +383,91 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
347
383
|
}
|
|
348
384
|
|
|
349
385
|
/**
|
|
350
|
-
*
|
|
351
|
-
*
|
|
386
|
+
* 限流兜底:Atom 取 commit,与上次一致就短路;否则 codeload tarball 解压扫描。
|
|
387
|
+
* 失败统一翻成 T1 的四类错误(限流保留原始 reset 信息)。
|
|
352
388
|
*/
|
|
353
|
-
async function
|
|
389
|
+
async function atomSnapshot(subscription, { ref = null, allowUnchanged = true, rateLimit = null } = {}) {
|
|
354
390
|
const source = subscription.source;
|
|
355
|
-
const event = purpose === 'probe' ? 'git.probe' : 'git.snapshot';
|
|
356
|
-
if (!git.enabled()) return null;
|
|
357
391
|
const startedAt = Date.now();
|
|
358
|
-
let
|
|
392
|
+
let atom;
|
|
359
393
|
try {
|
|
360
|
-
|
|
394
|
+
atom = await fetchAtomCommit({ source, ref: ref || null, token: tokenOf, fetchImpl });
|
|
361
395
|
} catch (error) {
|
|
362
|
-
logger('
|
|
363
|
-
|
|
396
|
+
logger('atom.failed', { source, kind: error.code || 'atom-failed', message: error.message });
|
|
397
|
+
throw upstreamError(source, {
|
|
398
|
+
status: rateLimit?.status ?? error.status ?? null,
|
|
399
|
+
rateLimited: Boolean(rateLimit?.rateLimited),
|
|
400
|
+
resetAt: rateLimit?.resetAt ?? null,
|
|
401
|
+
error: error.message,
|
|
402
|
+
});
|
|
364
403
|
}
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
404
|
+
const branch = ref || subscription.ref || null;
|
|
405
|
+
const previous = subscription.snapshot || null;
|
|
406
|
+
if (allowUnchanged && previous?.commit && previous.commit === atom.commit && Array.isArray(previous.entries)) {
|
|
407
|
+
logger('atom.unchanged', { source, commit: atom.commit, ms: Date.now() - startedAt });
|
|
408
|
+
return { branch, changed: false, notModified: true, via: 'atom', snapshot: previous };
|
|
368
409
|
}
|
|
410
|
+
let scan;
|
|
369
411
|
try {
|
|
370
|
-
|
|
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, {
|
|
412
|
+
scan = await snapshotFromTarball({
|
|
387
413
|
source,
|
|
414
|
+
commit: atom.commit,
|
|
388
415
|
ref: branch,
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
ms: Date.now() - startedAt,
|
|
416
|
+
subpath: subscription.subpath,
|
|
417
|
+
token: tokenOf,
|
|
418
|
+
fetchImpl,
|
|
393
419
|
});
|
|
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
420
|
} catch (error) {
|
|
403
|
-
logger('
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
421
|
+
logger('codeload.failed', { source, kind: error.code || 'scan-failed', message: error.message });
|
|
422
|
+
throw upstreamError(source, {
|
|
423
|
+
status: rateLimit?.status ?? error.status ?? null,
|
|
424
|
+
rateLimited: Boolean(rateLimit?.rateLimited),
|
|
425
|
+
resetAt: rateLimit?.resetAt ?? null,
|
|
426
|
+
error: error.message,
|
|
409
427
|
});
|
|
410
|
-
return null;
|
|
411
428
|
}
|
|
429
|
+
const changed =
|
|
430
|
+
!previous ||
|
|
431
|
+
previous.rootSha !== scan.rootSha ||
|
|
432
|
+
JSON.stringify(previous.entries) !== JSON.stringify(scan.snapshot.entries);
|
|
433
|
+
logger('atom.snapshot', {
|
|
434
|
+
source,
|
|
435
|
+
ref: branch,
|
|
436
|
+
commit: atom.commit,
|
|
437
|
+
entries: scan.snapshot.entries.length,
|
|
438
|
+
changed,
|
|
439
|
+
ms: Date.now() - startedAt,
|
|
440
|
+
});
|
|
441
|
+
return { ...scan, branch, changed };
|
|
412
442
|
}
|
|
413
443
|
|
|
414
444
|
/**
|
|
415
|
-
*
|
|
416
|
-
*
|
|
445
|
+
* commit 短路:Atom feed 的 HEAD 与订阅快照一致 → 没变化,不请求 REST。
|
|
446
|
+
* Atom 不可用时返回 false,照常走 REST(只增不减)。
|
|
417
447
|
*/
|
|
418
448
|
async function remoteCommitUnchanged(subscription) {
|
|
419
449
|
const commit = subscription.snapshot?.commit;
|
|
420
|
-
if (!commit
|
|
421
|
-
if (!(await git.isAvailable())) return false;
|
|
450
|
+
if (!commit) return false;
|
|
422
451
|
try {
|
|
423
|
-
const
|
|
424
|
-
|
|
425
|
-
|
|
452
|
+
const atom = await fetchAtomCommit({
|
|
453
|
+
source: subscription.source,
|
|
454
|
+
ref: subscription.ref || null,
|
|
455
|
+
token: tokenOf,
|
|
456
|
+
fetchImpl,
|
|
457
|
+
});
|
|
458
|
+
return atom.commit === commit;
|
|
426
459
|
} catch (error) {
|
|
427
|
-
logger('
|
|
460
|
+
logger('atom.failed', {
|
|
428
461
|
source: subscription.source,
|
|
429
|
-
kind: error.
|
|
462
|
+
kind: error.code || 'atom-failed',
|
|
430
463
|
purpose: 'refresh',
|
|
431
464
|
});
|
|
432
465
|
return false;
|
|
433
466
|
}
|
|
434
467
|
}
|
|
435
468
|
|
|
436
|
-
|
|
437
|
-
|
|
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;
|
|
469
|
+
function recordLink(name, target, linkPath, mode, created) {
|
|
470
|
+
if (store.readOnly) return;
|
|
505
471
|
const existing = state().links.find((item) => item.skill === name && item.target === target.target);
|
|
506
472
|
if (existing) return;
|
|
507
473
|
if (!created) return;
|
|
@@ -646,7 +612,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
646
612
|
const visible = await visibleSkills(cwd);
|
|
647
613
|
const visibleNames = new Map(visible.skills.map((item) => [item.name, item.source || item.provider || 'unknown']));
|
|
648
614
|
const ledger = buildLedger({ paths, state: state(), registry, lock, visibleNames });
|
|
649
|
-
const capabilities =
|
|
615
|
+
const capabilities = nativeCapabilities();
|
|
650
616
|
return {
|
|
651
617
|
ok: true,
|
|
652
618
|
readOnly: store.readOnly,
|
|
@@ -674,7 +640,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
674
640
|
},
|
|
675
641
|
|
|
676
642
|
async diagnostics() {
|
|
677
|
-
const capabilities =
|
|
643
|
+
const capabilities = nativeCapabilities();
|
|
678
644
|
const lock = readLock(paths);
|
|
679
645
|
reconcileTracking(lock);
|
|
680
646
|
const rows = buildLedger({ paths, state: state(), registry, lock, visibleNames: new Map() }).rows;
|
|
@@ -789,6 +755,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
789
755
|
},
|
|
790
756
|
|
|
791
757
|
async setMode({ name, mode }) {
|
|
758
|
+
assertSafeSkillName(name);
|
|
792
759
|
assertWritable();
|
|
793
760
|
if (mode !== 'on' && mode !== 'off') throw new ServiceError('error.skills.bad-mode', 'mode 只能是 on / off');
|
|
794
761
|
const install = store.findInstall(name);
|
|
@@ -831,12 +798,9 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
831
798
|
|
|
832
799
|
async install({ source, subscriptionId, skills = [], commit, targets, scopeProjects = [] }) {
|
|
833
800
|
assertWritable();
|
|
834
|
-
|
|
835
|
-
if (!capabilities.usable) {
|
|
836
|
-
throw new ServiceError('error.skills.cli-missing', 'skills CLI 不可用或不支持 --json/--full-depth,写操作已禁用', capabilities);
|
|
837
|
-
}
|
|
838
|
-
// 技能名可留空:有订阅快照就装快照里的条目,否则交给 CLI 的 `--skill '*'`(整仓全部)
|
|
801
|
+
await requireInstaller();
|
|
839
802
|
const requested = (skills || []).filter(Boolean);
|
|
803
|
+
for (const name of requested) assertSafeSkillName(name);
|
|
840
804
|
|
|
841
805
|
return enqueue('install', async () => {
|
|
842
806
|
const subscription = subscriptionId
|
|
@@ -844,77 +808,94 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
844
808
|
: null;
|
|
845
809
|
if (subscriptionId && !subscription) throw new ServiceError('error.skills.no-subscription', '订阅不存在');
|
|
846
810
|
const resolvedSource = subscription ? subscription.source : source;
|
|
847
|
-
if (!parseSource(resolvedSource))
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
811
|
+
if (!parseSource(resolvedSource)) {
|
|
812
|
+
throw new ServiceError('error.skills.bad-source', '只接受 owner/repo 或 GitHub URL(公开源)');
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// 有快照就沿用(含 commit/entries);否则现场解析默认分支、commit 与技能清单。
|
|
816
|
+
let snapshot = subscription?.snapshot || null;
|
|
817
|
+
let branch = subscription?.ref || null;
|
|
818
|
+
if (!commit && !snapshot?.commit) {
|
|
819
|
+
const subject = subscription || { source: resolvedSource, ref: null, subpath: null, snapshot: null };
|
|
820
|
+
const read = await readSnapshot(subject, { withCommit: true, force: true });
|
|
821
|
+
snapshot = read.snapshot;
|
|
822
|
+
branch = read.branch;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
const pin = commit || snapshot?.commit || branch || subscription?.ref || null;
|
|
826
|
+
if (!pin) {
|
|
827
|
+
throw new ServiceError('error.skills.upstream-failed', `无法解析 ${resolvedSource} 的 commit,请稍后重试`, {
|
|
828
|
+
source: resolvedSource,
|
|
860
829
|
});
|
|
861
830
|
}
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
831
|
+
const sourceArg = `${resolvedSource}#${pin}`;
|
|
832
|
+
|
|
833
|
+
let result;
|
|
834
|
+
try {
|
|
835
|
+
result = await installer.install({
|
|
836
|
+
source: resolvedSource,
|
|
837
|
+
// pin 是 commit 时按 commit 下载;只有分支时让 installer 用分支
|
|
838
|
+
commit: commit || snapshot?.commit || null,
|
|
839
|
+
ref: pin,
|
|
840
|
+
subpath: subscription?.subpath || null,
|
|
841
|
+
names: requested,
|
|
842
|
+
expectedEntries: snapshot?.entries || null,
|
|
867
843
|
});
|
|
844
|
+
} catch (error) {
|
|
845
|
+
logger('install.failed', { source: sourceArg, code: error?.code || null });
|
|
846
|
+
throw installerFailure(error, resolvedSource, '安装');
|
|
868
847
|
}
|
|
869
848
|
|
|
870
|
-
const lock = readLock(paths);
|
|
871
849
|
const installed = [];
|
|
872
|
-
const warnings = [];
|
|
873
|
-
|
|
874
|
-
|
|
850
|
+
const warnings = [...(result.warnings || [])];
|
|
851
|
+
const recordedAt = nowIso();
|
|
852
|
+
for (const item of result.installed) {
|
|
853
|
+
const canonicalPath = path.join(paths.canonical, item.name);
|
|
875
854
|
if (!fs.existsSync(canonicalPath)) {
|
|
876
|
-
warnings.push({ name, warning: '
|
|
855
|
+
warnings.push({ name: item.name, warning: '安装成功但 canonical 里没有该目录,未能记账' });
|
|
877
856
|
continue;
|
|
878
857
|
}
|
|
879
|
-
const
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
}
|
|
883
|
-
const expected = snapshot?.entries?.find((entry) => entry.name === name);
|
|
884
|
-
const lockHash = lockEntry?.skillFolderHash || null;
|
|
858
|
+
const installedHash = item.contentHash || contentHash(canonicalPath);
|
|
859
|
+
const expected = snapshot?.entries?.find((entry) => entry.name === item.name) || null;
|
|
860
|
+
const lockHash = item.lockHash ?? item.treeSha ?? null;
|
|
885
861
|
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) {
|
|
889
|
-
warnings.push({
|
|
890
|
-
name,
|
|
891
|
-
warning: 'lock 的 skillFolderHash 与快照 treeSha 不一致(可能装到了别的 commit)',
|
|
892
|
-
expected: expected.treeSha,
|
|
893
|
-
actual: lockHash,
|
|
894
|
-
});
|
|
895
|
-
}
|
|
896
|
-
const installedHash = fs.statSync(canonicalPath).isDirectory() ? contentHash(canonicalPath) : null;
|
|
897
862
|
const record = {
|
|
898
|
-
name,
|
|
863
|
+
name: item.name,
|
|
899
864
|
subscriptionId: subscription ? subscription.id : null,
|
|
900
|
-
skillPath:
|
|
865
|
+
skillPath: item.skillPath,
|
|
901
866
|
ref: pin,
|
|
902
|
-
commit: snapshot?.commit || null,
|
|
903
|
-
//
|
|
904
|
-
treeSha: expected?.treeSha || null,
|
|
867
|
+
commit: commit || snapshot?.commit || null,
|
|
868
|
+
// native installer 只写 40 位 tree SHA;旧 lock 的 64 位形态仍由 checkSkill 兼容
|
|
869
|
+
treeSha: item.treeSha || expected?.treeSha || null,
|
|
905
870
|
installedHash,
|
|
906
|
-
installedAt:
|
|
907
|
-
updatedAt:
|
|
871
|
+
installedAt: recordedAt,
|
|
872
|
+
updatedAt: recordedAt,
|
|
908
873
|
mode: 'on',
|
|
909
874
|
scopeProjects: scopeProjects || [],
|
|
910
|
-
backup:
|
|
875
|
+
backup: item.backup
|
|
876
|
+
? { key: item.backup, hash: item.backupHash || null, treeSha: item.backupTreeSha || null }
|
|
877
|
+
: null,
|
|
911
878
|
};
|
|
912
879
|
store.update((draft) => {
|
|
913
|
-
const index = draft.installs.findIndex((
|
|
914
|
-
if (index >= 0)
|
|
915
|
-
|
|
880
|
+
const index = draft.installs.findIndex((row) => row.name === item.name);
|
|
881
|
+
if (index >= 0) {
|
|
882
|
+
draft.installs[index] = {
|
|
883
|
+
...draft.installs[index],
|
|
884
|
+
...record,
|
|
885
|
+
installedAt: draft.installs[index].installedAt || record.installedAt,
|
|
886
|
+
};
|
|
887
|
+
} else {
|
|
888
|
+
draft.installs.push(record);
|
|
889
|
+
}
|
|
890
|
+
});
|
|
891
|
+
installed.push({
|
|
892
|
+
name: item.name,
|
|
893
|
+
lockHash,
|
|
894
|
+
lockHashShape: shape,
|
|
895
|
+
treeSha: record.treeSha,
|
|
896
|
+
installedHash,
|
|
897
|
+
backup: item.backup || null,
|
|
916
898
|
});
|
|
917
|
-
installed.push({ name, lockHash, lockHashShape: shape, installedHash });
|
|
918
899
|
}
|
|
919
900
|
|
|
920
901
|
const linkTargets = Array.isArray(targets) && targets.length > 0 ? targets : defaultTargets(state());
|
|
@@ -926,15 +907,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
926
907
|
linkResult.failed.push(...one.failed);
|
|
927
908
|
}
|
|
928
909
|
|
|
929
|
-
|
|
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
|
-
|
|
937
|
-
logger('install', { source: sourceArg, skills: names, installed: installed.length });
|
|
910
|
+
logger('install', { source: sourceArg, skills: installed.map((item) => item.name), installed: installed.length });
|
|
938
911
|
return {
|
|
939
912
|
ok: true,
|
|
940
913
|
installed,
|
|
@@ -946,14 +919,16 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
946
919
|
},
|
|
947
920
|
|
|
948
921
|
async remove({ name }) {
|
|
922
|
+
assertSafeSkillName(name);
|
|
949
923
|
assertWritable();
|
|
924
|
+
await requireInstaller(); // 快照与体检之前就拒绝,避免留下孤儿 trash
|
|
950
925
|
return enqueue(`remove:${name}`, async () => {
|
|
951
926
|
const canonicalPath = path.join(paths.canonical, name);
|
|
952
927
|
if (!fs.existsSync(canonicalPath)) {
|
|
953
928
|
throw new ServiceError('error.skills.not-found', `${name} 不在 canonical 里`);
|
|
954
929
|
}
|
|
955
930
|
|
|
956
|
-
// ①
|
|
931
|
+
// ① 体检:只动自己建的链接;真实目录/外来链接先拦下(前端弹窗依赖 detail.conflicts)
|
|
957
932
|
const conflicts = [];
|
|
958
933
|
for (const target of detectedTargets()) {
|
|
959
934
|
const linkPath = path.join(target.dir, name);
|
|
@@ -971,13 +946,35 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
971
946
|
);
|
|
972
947
|
}
|
|
973
948
|
|
|
974
|
-
// ②
|
|
949
|
+
// ② 快照进回收站;lock 写失败时用它把 canonical 恢复回来
|
|
975
950
|
const hash = fs.statSync(canonicalPath).isDirectory() ? contentHash(canonicalPath) : null;
|
|
976
951
|
const trash = trashSkill({ paths, skill: name, fromDir: canonicalPath, hash });
|
|
977
952
|
|
|
978
|
-
// ③
|
|
979
|
-
|
|
980
|
-
|
|
953
|
+
// ③ installer 删 lock 条目 + canonical;失败时不留“目录没了 lock 还在”的半状态
|
|
954
|
+
try {
|
|
955
|
+
await installer.remove({ name });
|
|
956
|
+
} catch (error) {
|
|
957
|
+
if (!fs.existsSync(canonicalPath)) {
|
|
958
|
+
try {
|
|
959
|
+
restoreBackup({
|
|
960
|
+
backup: { contentDir: path.join(trash.dir, 'content') },
|
|
961
|
+
toDir: canonicalPath,
|
|
962
|
+
});
|
|
963
|
+
} catch (restoreError) {
|
|
964
|
+
logger('remove.restore.failed', { name, message: restoreError.message });
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
const failure = installerFailure(error, name, '删除');
|
|
968
|
+
logger('remove.failed', { name, code: error?.code || null });
|
|
969
|
+
const kept = fs.existsSync(canonicalPath);
|
|
970
|
+
return {
|
|
971
|
+
ok: false,
|
|
972
|
+
code: failure.code,
|
|
973
|
+
message: `${failure.message};${kept ? '真身仍在 canonical,回收站已留快照' : '已从回收站恢复真身'}`,
|
|
974
|
+
detail: failure.detail,
|
|
975
|
+
trash: trash.key,
|
|
976
|
+
};
|
|
977
|
+
}
|
|
981
978
|
|
|
982
979
|
// ④ 清自家链接
|
|
983
980
|
const cleaned = [];
|
|
@@ -993,16 +990,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
993
990
|
delete draft.checks[name];
|
|
994
991
|
});
|
|
995
992
|
|
|
996
|
-
logger('remove', { name,
|
|
997
|
-
if (!cliOk) {
|
|
998
|
-
return {
|
|
999
|
-
ok: false,
|
|
1000
|
-
code: 'error.skills.cli-failed',
|
|
1001
|
-
message: 'skills remove 失败;已断开本插件建立的软链,真身可能仍在 canonical',
|
|
1002
|
-
detail: { code: result.code, stderr: result.stderr.slice(-2000) },
|
|
1003
|
-
trash: trash.key,
|
|
1004
|
-
};
|
|
1005
|
-
}
|
|
993
|
+
logger('remove', { name, trash: trash.key });
|
|
1006
994
|
return { ok: true, name, trash: trash.key, removedLinks: cleaned };
|
|
1007
995
|
});
|
|
1008
996
|
},
|
|
@@ -1022,24 +1010,17 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
1022
1010
|
return cached.pending || cached.value;
|
|
1023
1011
|
}
|
|
1024
1012
|
const pending = (async () => {
|
|
1025
|
-
//
|
|
1026
|
-
const
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
source: canonicalSource,
|
|
1037
|
-
ref: requestedRef,
|
|
1038
|
-
subpath: requestedSubpath,
|
|
1039
|
-
snapshot: null,
|
|
1040
|
-
},
|
|
1041
|
-
{ force: true, withCommit: false },
|
|
1042
|
-
));
|
|
1013
|
+
// REST 优先;限流时 readSnapshot 内部用 Atom + codeload 兜底
|
|
1014
|
+
const probe = await readSnapshot(
|
|
1015
|
+
{
|
|
1016
|
+
id: null,
|
|
1017
|
+
source: canonicalSource,
|
|
1018
|
+
ref: requestedRef,
|
|
1019
|
+
subpath: requestedSubpath,
|
|
1020
|
+
snapshot: null,
|
|
1021
|
+
},
|
|
1022
|
+
{ force: true, withCommit: false },
|
|
1023
|
+
);
|
|
1043
1024
|
// 描述优先按 commit 抓(不可变、可缓存),没有 commit 再退回分支名
|
|
1044
1025
|
const enrichRef = probe.snapshot?.commit || probe.branch;
|
|
1045
1026
|
const enriched = await enrichSkillEntries({
|
|
@@ -1170,19 +1151,19 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
1170
1151
|
assertWritable();
|
|
1171
1152
|
const subscription = state().subscriptions.find((item) => item.id === id);
|
|
1172
1153
|
if (!subscription) throw new ServiceError('error.skills.no-subscription', '订阅不存在');
|
|
1173
|
-
//
|
|
1154
|
+
// 短路:最常见的情况是「什么都没变」。先用 Atom 比 commit,一致就一次 REST 都不发。
|
|
1174
1155
|
if (await remoteCommitUnchanged(subscription)) {
|
|
1175
1156
|
const at = nowIso();
|
|
1176
1157
|
store.update((draft) => {
|
|
1177
1158
|
const row = draft.subscriptions.find((item) => item.id === id);
|
|
1178
1159
|
if (row) row.lastCheck = { ...(row.lastCheck || {}), at, status: 'unchanged' };
|
|
1179
1160
|
});
|
|
1180
|
-
logger('subscription.refresh', { id, notModified: true, via: '
|
|
1161
|
+
logger('subscription.refresh', { id, notModified: true, via: 'atom' });
|
|
1181
1162
|
return {
|
|
1182
1163
|
ok: true,
|
|
1183
1164
|
changed: false,
|
|
1184
1165
|
notModified: true,
|
|
1185
|
-
via: '
|
|
1166
|
+
via: 'atom',
|
|
1186
1167
|
subscription: state().subscriptions.find((item) => item.id === id),
|
|
1187
1168
|
};
|
|
1188
1169
|
}
|
|
@@ -1240,11 +1221,11 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
1240
1221
|
|
|
1241
1222
|
const results = [];
|
|
1242
1223
|
for (const { subscription, installs: groupInstalls } of groups.values()) {
|
|
1243
|
-
//
|
|
1224
|
+
// 与 readSnapshot 同一套语义:限流时 Atom/codeload 兜底,失败才逐个标 unknown
|
|
1244
1225
|
// withCommit: false:判定只需要树,别为 check 多付一次 /commits 请求
|
|
1245
1226
|
let probe;
|
|
1246
1227
|
try {
|
|
1247
|
-
probe = await readSnapshot(subscription, { withCommit: false, persistRef: true });
|
|
1228
|
+
probe = await readSnapshot(subscription, { withCommit: false, persistRef: true, allowUnchanged: false });
|
|
1248
1229
|
} catch (error) {
|
|
1249
1230
|
for (const install of groupInstalls) {
|
|
1250
1231
|
results.push({
|
|
@@ -1309,8 +1290,8 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
1309
1290
|
if (!install) throw new ServiceError('error.skills.not-managed', `${name} 没有安装记录`);
|
|
1310
1291
|
const subscription = state().subscriptions.find((item) => item.id === install.subscriptionId);
|
|
1311
1292
|
if (!subscription) throw new ServiceError('error.skills.no-subscription', '该技能不属于任何订阅,无法生成更新计划');
|
|
1312
|
-
// 与 checkUpdates
|
|
1313
|
-
const probe = await readSnapshot(subscription, { force: true });
|
|
1293
|
+
// 与 checkUpdates 共用一套取数语义;计划必须拿到完整树,所以不允许 commit 相同就短路
|
|
1294
|
+
const probe = await readSnapshot(subscription, { force: true, allowUnchanged: false });
|
|
1314
1295
|
const ref = probe.branch;
|
|
1315
1296
|
const lock = readLock(paths);
|
|
1316
1297
|
const skillPath = lock.skills?.[name]?.skillPath || install.skillPath;
|
|
@@ -1341,6 +1322,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
1341
1322
|
|
|
1342
1323
|
async applyUpdate({ name, token, overwrite = false }) {
|
|
1343
1324
|
assertWritable();
|
|
1325
|
+
await requireInstaller();
|
|
1344
1326
|
return enqueue(`apply:${name}`, async () => {
|
|
1345
1327
|
const plan = await this.planUpdate({ name });
|
|
1346
1328
|
if (token && token !== plan.token) {
|
|
@@ -1348,7 +1330,6 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
1348
1330
|
}
|
|
1349
1331
|
const install = store.findInstall(name);
|
|
1350
1332
|
if (!install) throw new ServiceError('error.skills.not-managed', `${name} 没有安装记录`);
|
|
1351
|
-
const canonicalPath = path.join(paths.canonical, name);
|
|
1352
1333
|
if (plan.localModified && !overwrite) {
|
|
1353
1334
|
throw new ServiceError('error.skills.locally-modified', '本地有改动,更新会覆盖它们;确认后带 overwrite 重试', {
|
|
1354
1335
|
plan,
|
|
@@ -1356,48 +1337,79 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
1356
1337
|
}
|
|
1357
1338
|
|
|
1358
1339
|
const subscription = state().subscriptions.find((item) => item.id === install.subscriptionId);
|
|
1359
|
-
|
|
1360
|
-
? createBackup({ paths, skill: name, fromDir: canonicalPath, hash: install.installedHash })
|
|
1361
|
-
: null;
|
|
1340
|
+
if (!subscription) throw new ServiceError('error.skills.no-subscription', '该技能不属于任何订阅,无法更新');
|
|
1362
1341
|
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1342
|
+
let result;
|
|
1343
|
+
try {
|
|
1344
|
+
result = await installer.install({
|
|
1345
|
+
source: subscription.source,
|
|
1346
|
+
commit: plan.toCommit || null,
|
|
1347
|
+
ref: plan.toCommit || plan.ref || subscription.ref || null,
|
|
1348
|
+
subpath: subscription.subpath,
|
|
1349
|
+
names: [name],
|
|
1350
|
+
expectedEntries: plan.targetTreeSha ? [{ name, treeSha: plan.targetTreeSha }] : [],
|
|
1351
|
+
strict: true,
|
|
1352
|
+
});
|
|
1353
|
+
} catch (error) {
|
|
1354
|
+
logger('updates.apply.failed', { name, reason: error?.code || null });
|
|
1375
1355
|
return {
|
|
1376
1356
|
ok: false,
|
|
1377
1357
|
code: 'error.skills.update-failed',
|
|
1378
|
-
message:
|
|
1379
|
-
detail: {
|
|
1380
|
-
backup:
|
|
1358
|
+
message: '更新失败;安装器已回滚到操作前状态',
|
|
1359
|
+
detail: { reason: error?.code || null, status: error?.status ?? null },
|
|
1360
|
+
backup: null,
|
|
1381
1361
|
};
|
|
1382
1362
|
}
|
|
1383
1363
|
|
|
1384
|
-
const
|
|
1364
|
+
const item = result.installed?.find((row) => row.name === name);
|
|
1365
|
+
if (!item) {
|
|
1366
|
+
// 理论不可达(names 已选中且 discovery 成功);绝不把「没装上」报成成功
|
|
1367
|
+
logger('updates.apply.failed', { name, reason: 'missing-installed-item' });
|
|
1368
|
+
return {
|
|
1369
|
+
ok: false,
|
|
1370
|
+
code: 'error.skills.update-failed',
|
|
1371
|
+
message: '更新失败;安装器没有返回该技能的结果',
|
|
1372
|
+
detail: { reason: 'missing-installed-item' },
|
|
1373
|
+
backup: null,
|
|
1374
|
+
};
|
|
1375
|
+
}
|
|
1376
|
+
const installedHash = item.contentHash || contentHash(path.join(paths.canonical, name));
|
|
1385
1377
|
store.update((draft) => {
|
|
1386
|
-
const row = draft.installs.find((
|
|
1378
|
+
const row = draft.installs.find((entry) => entry.name === name);
|
|
1387
1379
|
if (!row) return;
|
|
1388
|
-
row.commit = plan.toCommit;
|
|
1389
|
-
|
|
1390
|
-
row.treeSha = plan.targetTreeSha || row.treeSha || null;
|
|
1380
|
+
row.commit = plan.toCommit || null;
|
|
1381
|
+
row.treeSha = item.treeSha || plan.targetTreeSha || null;
|
|
1391
1382
|
row.installedHash = installedHash;
|
|
1392
1383
|
row.updatedAt = nowIso();
|
|
1393
|
-
row.backup =
|
|
1394
|
-
|
|
1384
|
+
row.backup = item.backup
|
|
1385
|
+
? {
|
|
1386
|
+
key: item.backup,
|
|
1387
|
+
hash: item.backupHash || null,
|
|
1388
|
+
treeSha: item.backupTreeSha || null,
|
|
1389
|
+
commit: install.commit || null,
|
|
1390
|
+
}
|
|
1391
|
+
: row.backup;
|
|
1392
|
+
draft.checks[name] = {
|
|
1393
|
+
name,
|
|
1394
|
+
status: 'current',
|
|
1395
|
+
detail: null,
|
|
1396
|
+
wouldCommit: null,
|
|
1397
|
+
checkedAt: nowIso(),
|
|
1398
|
+
installCommit: plan.toCommit || null,
|
|
1399
|
+
};
|
|
1395
1400
|
});
|
|
1396
1401
|
|
|
1397
1402
|
// 复验软链:更新重建的是目录,链接理论上不受影响,断一条就补一条
|
|
1398
1403
|
const relinked = linkSkill(name, ['dsh-global'], { scopeProjects: install.scopeProjects || [] });
|
|
1399
|
-
logger('updates.apply', { name, from: plan.fromCommit, to: plan.toCommit, backup: backup
|
|
1400
|
-
return {
|
|
1404
|
+
logger('updates.apply', { name, from: plan.fromCommit, to: plan.toCommit, backup: item.backup || null });
|
|
1405
|
+
return {
|
|
1406
|
+
ok: true,
|
|
1407
|
+
name,
|
|
1408
|
+
fromCommit: plan.fromCommit,
|
|
1409
|
+
toCommit: plan.toCommit || null,
|
|
1410
|
+
backup: item.backup || null,
|
|
1411
|
+
relinked,
|
|
1412
|
+
};
|
|
1401
1413
|
});
|
|
1402
1414
|
},
|
|
1403
1415
|
|
|
@@ -1409,7 +1421,26 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
1409
1421
|
const backup = readBackup({ paths, key: install.backup.key });
|
|
1410
1422
|
if (!backup) throw new ServiceError('error.skills.no-backup', '备份目录已不存在');
|
|
1411
1423
|
const canonicalPath = path.join(paths.canonical, name);
|
|
1424
|
+
// 先读 lock 再动盘:lock 损坏时直接失败,避免只回滚一半
|
|
1425
|
+
const lockBefore = readLockFile(paths.lockFile);
|
|
1426
|
+
const lockEntry = lockBefore.data?.skills?.[name] || null;
|
|
1412
1427
|
restoreBackup({ backup, toDir: canonicalPath });
|
|
1428
|
+
if (lockEntry) {
|
|
1429
|
+
updateLock({
|
|
1430
|
+
lockPath: paths.lockFile,
|
|
1431
|
+
expectedDigest: lockBefore.digest,
|
|
1432
|
+
mutate: (draft) => {
|
|
1433
|
+
const previous = draft.skills[name];
|
|
1434
|
+
if (!previous) return;
|
|
1435
|
+
draft.skills[name] = {
|
|
1436
|
+
...previous,
|
|
1437
|
+
...(install.backup.treeSha ? { skillFolderHash: install.backup.treeSha } : {}),
|
|
1438
|
+
...(install.backup.commit ? { ref: install.backup.commit } : {}),
|
|
1439
|
+
updatedAt: nowIso(),
|
|
1440
|
+
};
|
|
1441
|
+
},
|
|
1442
|
+
});
|
|
1443
|
+
}
|
|
1413
1444
|
const installedHash = contentHash(canonicalPath);
|
|
1414
1445
|
store.update((draft) => {
|
|
1415
1446
|
const row = draft.installs.find((item) => item.name === name);
|