@morehao/dsh-skills-console 0.2.4 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -19
- package/client.js +43 -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/remote.js +332 -0
- package/lib/core/service.js +348 -354
- package/lib/core/skill-md.js +49 -0
- package/lib/core/trash.js +2 -2
- package/lib/index.js +3 -3
- package/package.json +2 -2
- package/lib/core/cli.js +0 -256
- 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,25 +35,46 @@ function nowIso() {
|
|
|
33
35
|
return new Date().toISOString();
|
|
34
36
|
}
|
|
35
37
|
|
|
36
|
-
|
|
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
|
+
}
|
|
38
54
|
|
|
39
|
-
/**
|
|
40
|
-
function
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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 });
|
|
53
73
|
}
|
|
54
|
-
|
|
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);
|
|
55
78
|
}
|
|
56
79
|
|
|
57
80
|
/** 本机时区的 `HH:mm`。手写而不是 `toLocaleTimeString`:不受 ICU / locale 差异影响。 */
|
|
@@ -89,51 +112,6 @@ function upstreamError(source, failure) {
|
|
|
89
112
|
return new ServiceError('error.skills.network', '无法连接 GitHub(网络或代理问题)', detail);
|
|
90
113
|
}
|
|
91
114
|
|
|
92
|
-
/** `skills add --json` 的输出:数组或 `{ installed: [...] }`,只取 name 并去重。 */
|
|
93
|
-
function installedNamesFrom(stdout) {
|
|
94
|
-
try {
|
|
95
|
-
const parsed = JSON.parse(stdout);
|
|
96
|
-
const rows = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.installed) ? parsed.installed : [];
|
|
97
|
-
const names = rows.map((row) => (typeof row === 'string' ? row : row?.name)).filter(Boolean);
|
|
98
|
-
return [...new Set(names)];
|
|
99
|
-
} catch {
|
|
100
|
-
return [];
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/** 极简 frontmatter 解析:只取 CLI 安装必需的两项(name / description)。 */
|
|
105
|
-
function parseSkillFrontmatter(text) {
|
|
106
|
-
const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(String(text || ''));
|
|
107
|
-
if (!match) return {};
|
|
108
|
-
const lines = match[1].split(/\r?\n/);
|
|
109
|
-
const fields = {};
|
|
110
|
-
for (let index = 0; index < lines.length; index += 1) {
|
|
111
|
-
const line = lines[index];
|
|
112
|
-
if (!line || /^\s/.test(line)) continue;
|
|
113
|
-
const colon = line.indexOf(':');
|
|
114
|
-
if (colon <= 0) continue;
|
|
115
|
-
const key = line.slice(0, colon).trim();
|
|
116
|
-
if (key !== 'name' && key !== 'description') continue;
|
|
117
|
-
let value = line.slice(colon + 1).trim();
|
|
118
|
-
const block = /^([|>])[+-]?$/.exec(value);
|
|
119
|
-
if (block) {
|
|
120
|
-
const folded = block[1] === '>';
|
|
121
|
-
const rows = [];
|
|
122
|
-
while (index + 1 < lines.length && /^\s+/.test(lines[index + 1])) {
|
|
123
|
-
rows.push(lines[index + 1].replace(/^\s+/, ''));
|
|
124
|
-
index += 1;
|
|
125
|
-
}
|
|
126
|
-
value = folded ? rows.join(' ') : rows.join('\n');
|
|
127
|
-
} else if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
128
|
-
const quote = value[0];
|
|
129
|
-
value = value.slice(1, -1);
|
|
130
|
-
if (quote === '"') value = value.replace(/\\"/g, '"').replace(/\\n/g, '\n');
|
|
131
|
-
}
|
|
132
|
-
fields[key] = value.trim();
|
|
133
|
-
}
|
|
134
|
-
return fields;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
115
|
/** 用 raw 内容补 frontmatter 里的真实技能名/描述(CLI 按 name 安装);取不到就回落目录名。 */
|
|
138
116
|
async function enrichSkillEntries({ source, ref, entries, token, fetchImpl, concurrency = 8 }) {
|
|
139
117
|
const results = new Array(entries.length);
|
|
@@ -196,10 +174,19 @@ function createQueue(logger) {
|
|
|
196
174
|
|
|
197
175
|
|
|
198
176
|
|
|
199
|
-
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
|
+
}) {
|
|
200
188
|
const enqueue = createQueue(logger);
|
|
201
189
|
const tokenOf = (env.GITHUB_TOKEN || env.GH_TOKEN) || null;
|
|
202
|
-
const git = createGit({ env, logger });
|
|
203
190
|
// 探测缓存:同一来源短时间内重复检测直接复用;并发的相同请求合并成一次(不重复烧 API 配额)
|
|
204
191
|
const probeCache = new Map();
|
|
205
192
|
const PROBE_TTL_MS = 60_000;
|
|
@@ -213,19 +200,34 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
213
200
|
if (store.readOnly) throw new StateReadOnlyError(store.problem);
|
|
214
201
|
}
|
|
215
202
|
|
|
216
|
-
/**
|
|
217
|
-
|
|
218
|
-
const capabilities =
|
|
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
219
|
if (!capabilities.usable) {
|
|
220
220
|
throw new ServiceError(
|
|
221
|
-
'error.skills.
|
|
222
|
-
'
|
|
221
|
+
'error.skills.installer-unavailable',
|
|
222
|
+
'本地安装器不可用(lock 版本/权限异常),写操作已禁用',
|
|
223
223
|
capabilities,
|
|
224
224
|
);
|
|
225
225
|
}
|
|
226
226
|
return capabilities;
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
+
|
|
230
|
+
|
|
229
231
|
function detectedTargets() {
|
|
230
232
|
return registry.detected();
|
|
231
233
|
}
|
|
@@ -308,16 +310,23 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
308
310
|
* 上游失败一律抛 ServiceError —— 添加订阅必须先读到快照再记账,
|
|
309
311
|
* 否则会留下「订阅存在但一个 skill 都没有」的空壳(上游 404 时还会显示添加成功)。
|
|
310
312
|
*
|
|
311
|
-
*
|
|
313
|
+
* 数据源:REST 优先;403/429 限流时用 Atom 取 commit,再下 codeload tarball 本地扫描,
|
|
314
|
+
* 不启动任何外部进程。`allowUnchanged=false` 供更新计划使用:即使 commit 没变也要拿完整树。
|
|
312
315
|
*/
|
|
313
|
-
async function readSnapshot(
|
|
316
|
+
async function readSnapshot(
|
|
317
|
+
subscription,
|
|
318
|
+
{ force = false, withCommit = true, persistRef = false, allowUnchanged = true } = {},
|
|
319
|
+
) {
|
|
314
320
|
const source = subscription.source;
|
|
315
321
|
try {
|
|
316
322
|
const { branch, failure } = await branchOf(subscription, { persist: persistRef });
|
|
317
323
|
if (!branch) {
|
|
318
|
-
if (failure?.rateLimited
|
|
319
|
-
|
|
320
|
-
|
|
324
|
+
if (failure?.rateLimited) {
|
|
325
|
+
return await atomSnapshot(subscription, {
|
|
326
|
+
ref: subscription.ref || null,
|
|
327
|
+
allowUnchanged,
|
|
328
|
+
rateLimit: failure,
|
|
329
|
+
});
|
|
321
330
|
}
|
|
322
331
|
throw upstreamError(source, failure);
|
|
323
332
|
}
|
|
@@ -329,16 +338,9 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
329
338
|
fetchImpl,
|
|
330
339
|
});
|
|
331
340
|
if (result.error) {
|
|
332
|
-
// 元数据已经通了,树再 404 说明是 ref
|
|
341
|
+
// 元数据已经通了,树再 404 说明是 ref 没了(不是仓库不存在);只有限流降级
|
|
333
342
|
if (result.rateLimited) {
|
|
334
|
-
|
|
335
|
-
if (viaGit) return viaGit;
|
|
336
|
-
throw upstreamError(source, {
|
|
337
|
-
status: result.status ?? null,
|
|
338
|
-
rateLimited: true,
|
|
339
|
-
resetAt: result.resetAt ?? null,
|
|
340
|
-
error: result.error,
|
|
341
|
-
});
|
|
343
|
+
return await atomSnapshot(subscription, { ref: branch, allowUnchanged, rateLimit: result });
|
|
342
344
|
}
|
|
343
345
|
throw new ServiceError('error.skills.upstream-failed', `读取 ${source}@${branch} 失败:${result.error}`, {
|
|
344
346
|
source,
|
|
@@ -346,7 +348,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
346
348
|
status: result.status ?? null,
|
|
347
349
|
});
|
|
348
350
|
}
|
|
349
|
-
if (result.notModified) return { notModified: true, branch };
|
|
351
|
+
if (result.notModified) return { notModified: true, branch, via: 'rest' };
|
|
350
352
|
const entries = listSkillsFromTree({
|
|
351
353
|
tree: result.tree,
|
|
352
354
|
rootSha: result.rootSha,
|
|
@@ -381,161 +383,91 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
381
383
|
}
|
|
382
384
|
|
|
383
385
|
/**
|
|
384
|
-
*
|
|
385
|
-
*
|
|
386
|
+
* 限流兜底:Atom 取 commit,与上次一致就短路;否则 codeload tarball 解压扫描。
|
|
387
|
+
* 失败统一翻成 T1 的四类错误(限流保留原始 reset 信息)。
|
|
386
388
|
*/
|
|
387
|
-
async function
|
|
389
|
+
async function atomSnapshot(subscription, { ref = null, allowUnchanged = true, rateLimit = null } = {}) {
|
|
388
390
|
const source = subscription.source;
|
|
389
|
-
const event = purpose === 'probe' ? 'git.probe' : 'git.snapshot';
|
|
390
|
-
if (!git.enabled()) return null;
|
|
391
391
|
const startedAt = Date.now();
|
|
392
|
-
let
|
|
392
|
+
let atom;
|
|
393
393
|
try {
|
|
394
|
-
|
|
394
|
+
atom = await fetchAtomCommit({ source, ref: ref || null, token: tokenOf, fetchImpl });
|
|
395
395
|
} catch (error) {
|
|
396
|
-
logger('
|
|
397
|
-
|
|
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
|
+
});
|
|
398
403
|
}
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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 };
|
|
402
409
|
}
|
|
410
|
+
let scan;
|
|
403
411
|
try {
|
|
404
|
-
|
|
405
|
-
if (!branch) {
|
|
406
|
-
const head = await git.lsRemote({ url });
|
|
407
|
-
branch = head.branch || 'main';
|
|
408
|
-
}
|
|
409
|
-
const tree = await git.tree({ url, ref: branch });
|
|
410
|
-
const entries = listSkillsFromTree({
|
|
411
|
-
tree: tree.entries,
|
|
412
|
-
rootSha: tree.rootSha,
|
|
413
|
-
subpath: subscription.subpath,
|
|
414
|
-
repoName: source.split('/').pop(),
|
|
415
|
-
});
|
|
416
|
-
const changed =
|
|
417
|
-
!subscription.snapshot ||
|
|
418
|
-
subscription.snapshot.rootSha !== tree.rootSha ||
|
|
419
|
-
JSON.stringify(entries) !== JSON.stringify(subscription.snapshot.entries);
|
|
420
|
-
logger(event, {
|
|
412
|
+
scan = await snapshotFromTarball({
|
|
421
413
|
source,
|
|
414
|
+
commit: atom.commit,
|
|
422
415
|
ref: branch,
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
ms: Date.now() - startedAt,
|
|
416
|
+
subpath: subscription.subpath,
|
|
417
|
+
token: tokenOf,
|
|
418
|
+
fetchImpl,
|
|
427
419
|
});
|
|
428
|
-
return {
|
|
429
|
-
branch,
|
|
430
|
-
changed,
|
|
431
|
-
via: 'git',
|
|
432
|
-
tree: tree.entries,
|
|
433
|
-
rootSha: tree.rootSha,
|
|
434
|
-
snapshot: { commit: tree.commit, rootSha: tree.rootSha, entries, fetchedAt: nowIso(), etag: null },
|
|
435
|
-
};
|
|
436
420
|
} catch (error) {
|
|
437
|
-
logger('
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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,
|
|
443
427
|
});
|
|
444
|
-
return null;
|
|
445
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 };
|
|
446
442
|
}
|
|
447
443
|
|
|
448
444
|
/**
|
|
449
|
-
*
|
|
450
|
-
*
|
|
445
|
+
* commit 短路:Atom feed 的 HEAD 与订阅快照一致 → 没变化,不请求 REST。
|
|
446
|
+
* Atom 不可用时返回 false,照常走 REST(只增不减)。
|
|
451
447
|
*/
|
|
452
448
|
async function remoteCommitUnchanged(subscription) {
|
|
453
449
|
const commit = subscription.snapshot?.commit;
|
|
454
|
-
if (!commit
|
|
455
|
-
if (!(await git.isAvailable())) return false;
|
|
450
|
+
if (!commit) return false;
|
|
456
451
|
try {
|
|
457
|
-
const
|
|
458
|
-
|
|
459
|
-
|
|
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;
|
|
460
459
|
} catch (error) {
|
|
461
|
-
logger('
|
|
460
|
+
logger('atom.failed', {
|
|
462
461
|
source: subscription.source,
|
|
463
|
-
kind: error.
|
|
462
|
+
kind: error.code || 'atom-failed',
|
|
464
463
|
purpose: 'refresh',
|
|
465
464
|
});
|
|
466
465
|
return false;
|
|
467
466
|
}
|
|
468
467
|
}
|
|
469
468
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
* 不占 GitHub REST 配额,也不需要 token / gh —— 出口 IP 匿名配额被耗尽时这条路仍然通。
|
|
473
|
-
* 失败一律返回 `null`,由调用方回退现有 REST 路径(默认开 = 只增不减)。
|
|
474
|
-
*/
|
|
475
|
-
async function probeViaGit({ source, ref, subpath }) {
|
|
476
|
-
const result = await gitSnapshot({ source, subpath, snapshot: null }, { ref, purpose: 'probe' });
|
|
477
|
-
if (!result) return null;
|
|
478
|
-
return { branch: result.branch, via: 'git', snapshot: result.snapshot };
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
/**
|
|
482
|
-
* T3.7 安装后基线补录。CLI 在拿不到树时把 64 位内容哈希写进 lock(`add.ts:2254`),
|
|
483
|
-
* 「手填技能名」这条安装路径又没有快照可依 —— 装上之后用 git 把目录 tree SHA 补进
|
|
484
|
-
* `installs[].treeSha`,让 T2 的判定对这条路径同样生效。
|
|
485
|
-
*
|
|
486
|
-
* best effort:只读临时目录;任何失败只记日志,**不改安装结果**,`treeSha` 留 null。
|
|
487
|
-
*/
|
|
488
|
-
async function backfillBaselines({ names, source, lock, ref }) {
|
|
489
|
-
if (!git.enabled() || names.length === 0) return [];
|
|
490
|
-
const targets = [];
|
|
491
|
-
for (const name of names) {
|
|
492
|
-
const lockEntry = lock.skills?.[name];
|
|
493
|
-
if (hashShape(lockEntry?.skillFolderHash || null) !== 'content') continue;
|
|
494
|
-
const install = store.findInstall(name);
|
|
495
|
-
if (!install || install.treeSha) continue;
|
|
496
|
-
targets.push({ name, skillPath: lockEntry?.skillPath || install.skillPath || null });
|
|
497
|
-
}
|
|
498
|
-
if (targets.length === 0) return [];
|
|
499
|
-
if (!(await git.isAvailable())) {
|
|
500
|
-
logger('git.error', { source, kind: 'git-missing', purpose: 'baseline' });
|
|
501
|
-
return [];
|
|
502
|
-
}
|
|
503
|
-
const startedAt = Date.now();
|
|
504
|
-
try {
|
|
505
|
-
const url = git.url(source);
|
|
506
|
-
let useRef = ref || null;
|
|
507
|
-
if (!useRef) useRef = (await git.lsRemote({ url })).commit;
|
|
508
|
-
// 与探测共用进程内缓存:同一 (url, ref) 只 clone 一次
|
|
509
|
-
const tree = await git.tree({ url, ref: useRef });
|
|
510
|
-
const updated = [];
|
|
511
|
-
for (const target of targets) {
|
|
512
|
-
const treeSha = target.skillPath
|
|
513
|
-
? skillTreeSha({ tree: tree.entries, skillPath: target.skillPath, rootSha: tree.rootSha })
|
|
514
|
-
: null;
|
|
515
|
-
if (!treeSha) {
|
|
516
|
-
logger('git.error', { name: target.name, kind: 'no-tree-entry', purpose: 'baseline' });
|
|
517
|
-
continue;
|
|
518
|
-
}
|
|
519
|
-
store.update((draft) => {
|
|
520
|
-
const row = draft.installs.find((item) => item.name === target.name);
|
|
521
|
-
if (row && !row.treeSha) row.treeSha = treeSha;
|
|
522
|
-
});
|
|
523
|
-
updated.push({ name: target.name, treeSha });
|
|
524
|
-
}
|
|
525
|
-
if (updated.length > 0) logger('git.baseline', { source, ref: useRef, updated, ms: Date.now() - startedAt });
|
|
526
|
-
return updated;
|
|
527
|
-
} catch (error) {
|
|
528
|
-
logger('git.error', {
|
|
529
|
-
source,
|
|
530
|
-
kind: error.kind || 'clone-failed',
|
|
531
|
-
message: error.message,
|
|
532
|
-
purpose: 'baseline',
|
|
533
|
-
});
|
|
534
|
-
return [];
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
function recordLink(name, target, linkPath, mode, created) { if (store.readOnly) return;
|
|
469
|
+
function recordLink(name, target, linkPath, mode, created) {
|
|
470
|
+
if (store.readOnly) return;
|
|
539
471
|
const existing = state().links.find((item) => item.skill === name && item.target === target.target);
|
|
540
472
|
if (existing) return;
|
|
541
473
|
if (!created) return;
|
|
@@ -680,7 +612,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
680
612
|
const visible = await visibleSkills(cwd);
|
|
681
613
|
const visibleNames = new Map(visible.skills.map((item) => [item.name, item.source || item.provider || 'unknown']));
|
|
682
614
|
const ledger = buildLedger({ paths, state: state(), registry, lock, visibleNames });
|
|
683
|
-
const capabilities =
|
|
615
|
+
const capabilities = nativeCapabilities();
|
|
684
616
|
return {
|
|
685
617
|
ok: true,
|
|
686
618
|
readOnly: store.readOnly,
|
|
@@ -708,7 +640,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
708
640
|
},
|
|
709
641
|
|
|
710
642
|
async diagnostics() {
|
|
711
|
-
const capabilities =
|
|
643
|
+
const capabilities = nativeCapabilities();
|
|
712
644
|
const lock = readLock(paths);
|
|
713
645
|
reconcileTracking(lock);
|
|
714
646
|
const rows = buildLedger({ paths, state: state(), registry, lock, visibleNames: new Map() }).rows;
|
|
@@ -823,6 +755,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
823
755
|
},
|
|
824
756
|
|
|
825
757
|
async setMode({ name, mode }) {
|
|
758
|
+
assertSafeSkillName(name);
|
|
826
759
|
assertWritable();
|
|
827
760
|
if (mode !== 'on' && mode !== 'off') throw new ServiceError('error.skills.bad-mode', 'mode 只能是 on / off');
|
|
828
761
|
const install = store.findInstall(name);
|
|
@@ -865,9 +798,9 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
865
798
|
|
|
866
799
|
async install({ source, subscriptionId, skills = [], commit, targets, scopeProjects = [] }) {
|
|
867
800
|
assertWritable();
|
|
868
|
-
await
|
|
869
|
-
// 技能名可留空:有订阅快照就装快照里的条目,否则交给 CLI 的 `--skill '*'`(整仓全部)
|
|
801
|
+
await requireInstaller();
|
|
870
802
|
const requested = (skills || []).filter(Boolean);
|
|
803
|
+
for (const name of requested) assertSafeSkillName(name);
|
|
871
804
|
|
|
872
805
|
return enqueue('install', async () => {
|
|
873
806
|
const subscription = subscriptionId
|
|
@@ -875,72 +808,94 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
875
808
|
: null;
|
|
876
809
|
if (subscriptionId && !subscription) throw new ServiceError('error.skills.no-subscription', '订阅不存在');
|
|
877
810
|
const resolvedSource = subscription ? subscription.source : source;
|
|
878
|
-
if (!parseSource(resolvedSource))
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
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,
|
|
893
829
|
});
|
|
894
830
|
}
|
|
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,
|
|
843
|
+
});
|
|
844
|
+
} catch (error) {
|
|
845
|
+
logger('install.failed', { source: sourceArg, code: error?.code || null });
|
|
846
|
+
throw installerFailure(error, resolvedSource, '安装');
|
|
847
|
+
}
|
|
895
848
|
|
|
896
|
-
const lock = readLock(paths);
|
|
897
849
|
const installed = [];
|
|
898
|
-
const warnings = [];
|
|
899
|
-
|
|
900
|
-
|
|
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);
|
|
901
854
|
if (!fs.existsSync(canonicalPath)) {
|
|
902
|
-
warnings.push({ name, warning: '
|
|
855
|
+
warnings.push({ name: item.name, warning: '安装成功但 canonical 里没有该目录,未能记账' });
|
|
903
856
|
continue;
|
|
904
857
|
}
|
|
905
|
-
const
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
}
|
|
909
|
-
const expected = snapshot?.entries?.find((entry) => entry.name === name);
|
|
910
|
-
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;
|
|
911
861
|
const shape = hashShape(lockHash);
|
|
912
|
-
// 只有两边都是 40 位 tree SHA 才有可比性。限流安装时 CLI 写的是 64 位内容哈希
|
|
913
|
-
// (add.ts:2254),拿它比 tree SHA 必然不等 —— 那是形态问题,不是装错了。
|
|
914
|
-
if (expected?.treeSha && shape === 'tree' && hashShape(expected.treeSha) === 'tree' && lockHash !== expected.treeSha) {
|
|
915
|
-
warnings.push({
|
|
916
|
-
name,
|
|
917
|
-
warning: 'lock 的 skillFolderHash 与快照 treeSha 不一致(可能装到了别的 commit)',
|
|
918
|
-
expected: expected.treeSha,
|
|
919
|
-
actual: lockHash,
|
|
920
|
-
});
|
|
921
|
-
}
|
|
922
|
-
const installedHash = fs.statSync(canonicalPath).isDirectory() ? contentHash(canonicalPath) : null;
|
|
923
862
|
const record = {
|
|
924
|
-
name,
|
|
863
|
+
name: item.name,
|
|
925
864
|
subscriptionId: subscription ? subscription.id : null,
|
|
926
|
-
skillPath:
|
|
865
|
+
skillPath: item.skillPath,
|
|
927
866
|
ref: pin,
|
|
928
|
-
commit: snapshot?.commit || null,
|
|
929
|
-
//
|
|
930
|
-
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,
|
|
931
870
|
installedHash,
|
|
932
|
-
installedAt:
|
|
933
|
-
updatedAt:
|
|
871
|
+
installedAt: recordedAt,
|
|
872
|
+
updatedAt: recordedAt,
|
|
934
873
|
mode: 'on',
|
|
935
874
|
scopeProjects: scopeProjects || [],
|
|
936
|
-
backup:
|
|
875
|
+
backup: item.backup
|
|
876
|
+
? { key: item.backup, hash: item.backupHash || null, treeSha: item.backupTreeSha || null }
|
|
877
|
+
: null,
|
|
937
878
|
};
|
|
938
879
|
store.update((draft) => {
|
|
939
|
-
const index = draft.installs.findIndex((
|
|
940
|
-
if (index >= 0)
|
|
941
|
-
|
|
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,
|
|
942
898
|
});
|
|
943
|
-
installed.push({ name, lockHash, lockHashShape: shape, installedHash });
|
|
944
899
|
}
|
|
945
900
|
|
|
946
901
|
const linkTargets = Array.isArray(targets) && targets.length > 0 ? targets : defaultTargets(state());
|
|
@@ -952,15 +907,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
952
907
|
linkResult.failed.push(...one.failed);
|
|
953
908
|
}
|
|
954
909
|
|
|
955
|
-
|
|
956
|
-
// 否则「手填技能名」装出来的技能在更新检查里只能是 unknown。
|
|
957
|
-
const baselines = await backfillBaselines({ names, source: resolvedSource, lock, ref: pin });
|
|
958
|
-
for (const item of installed) {
|
|
959
|
-
const filled = baselines.find((row) => row.name === item.name);
|
|
960
|
-
if (filled) item.treeSha = filled.treeSha;
|
|
961
|
-
}
|
|
962
|
-
|
|
963
|
-
logger('install', { source: sourceArg, skills: names, installed: installed.length });
|
|
910
|
+
logger('install', { source: sourceArg, skills: installed.map((item) => item.name), installed: installed.length });
|
|
964
911
|
return {
|
|
965
912
|
ok: true,
|
|
966
913
|
installed,
|
|
@@ -972,15 +919,16 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
972
919
|
},
|
|
973
920
|
|
|
974
921
|
async remove({ name }) {
|
|
922
|
+
assertSafeSkillName(name);
|
|
975
923
|
assertWritable();
|
|
976
|
-
await
|
|
924
|
+
await requireInstaller(); // 快照与体检之前就拒绝,避免留下孤儿 trash
|
|
977
925
|
return enqueue(`remove:${name}`, async () => {
|
|
978
926
|
const canonicalPath = path.join(paths.canonical, name);
|
|
979
927
|
if (!fs.existsSync(canonicalPath)) {
|
|
980
928
|
throw new ServiceError('error.skills.not-found', `${name} 不在 canonical 里`);
|
|
981
929
|
}
|
|
982
930
|
|
|
983
|
-
// ①
|
|
931
|
+
// ① 体检:只动自己建的链接;真实目录/外来链接先拦下(前端弹窗依赖 detail.conflicts)
|
|
984
932
|
const conflicts = [];
|
|
985
933
|
for (const target of detectedTargets()) {
|
|
986
934
|
const linkPath = path.join(target.dir, name);
|
|
@@ -998,13 +946,35 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
998
946
|
);
|
|
999
947
|
}
|
|
1000
948
|
|
|
1001
|
-
// ②
|
|
949
|
+
// ② 快照进回收站;lock 写失败时用它把 canonical 恢复回来
|
|
1002
950
|
const hash = fs.statSync(canonicalPath).isDirectory() ? contentHash(canonicalPath) : null;
|
|
1003
951
|
const trash = trashSkill({ paths, skill: name, fromDir: canonicalPath, hash });
|
|
1004
952
|
|
|
1005
|
-
// ③
|
|
1006
|
-
|
|
1007
|
-
|
|
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
|
+
}
|
|
1008
978
|
|
|
1009
979
|
// ④ 清自家链接
|
|
1010
980
|
const cleaned = [];
|
|
@@ -1020,16 +990,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
1020
990
|
delete draft.checks[name];
|
|
1021
991
|
});
|
|
1022
992
|
|
|
1023
|
-
logger('remove', { name,
|
|
1024
|
-
if (!cliOk) {
|
|
1025
|
-
return {
|
|
1026
|
-
ok: false,
|
|
1027
|
-
code: 'error.skills.cli-failed',
|
|
1028
|
-
message: 'skills remove 失败;已断开本插件建立的软链,真身可能仍在 canonical',
|
|
1029
|
-
detail: { code: result.code, stderr: result.stderr.slice(-2000) },
|
|
1030
|
-
trash: trash.key,
|
|
1031
|
-
};
|
|
1032
|
-
}
|
|
993
|
+
logger('remove', { name, trash: trash.key });
|
|
1033
994
|
return { ok: true, name, trash: trash.key, removedLinks: cleaned };
|
|
1034
995
|
});
|
|
1035
996
|
},
|
|
@@ -1049,24 +1010,17 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
1049
1010
|
return cached.pending || cached.value;
|
|
1050
1011
|
}
|
|
1051
1012
|
const pending = (async () => {
|
|
1052
|
-
//
|
|
1053
|
-
const
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
source: canonicalSource,
|
|
1064
|
-
ref: requestedRef,
|
|
1065
|
-
subpath: requestedSubpath,
|
|
1066
|
-
snapshot: null,
|
|
1067
|
-
},
|
|
1068
|
-
{ force: true, withCommit: false },
|
|
1069
|
-
));
|
|
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
|
+
);
|
|
1070
1024
|
// 描述优先按 commit 抓(不可变、可缓存),没有 commit 再退回分支名
|
|
1071
1025
|
const enrichRef = probe.snapshot?.commit || probe.branch;
|
|
1072
1026
|
const enriched = await enrichSkillEntries({
|
|
@@ -1197,19 +1151,19 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
1197
1151
|
assertWritable();
|
|
1198
1152
|
const subscription = state().subscriptions.find((item) => item.id === id);
|
|
1199
1153
|
if (!subscription) throw new ServiceError('error.skills.no-subscription', '订阅不存在');
|
|
1200
|
-
//
|
|
1154
|
+
// 短路:最常见的情况是「什么都没变」。先用 Atom 比 commit,一致就一次 REST 都不发。
|
|
1201
1155
|
if (await remoteCommitUnchanged(subscription)) {
|
|
1202
1156
|
const at = nowIso();
|
|
1203
1157
|
store.update((draft) => {
|
|
1204
1158
|
const row = draft.subscriptions.find((item) => item.id === id);
|
|
1205
1159
|
if (row) row.lastCheck = { ...(row.lastCheck || {}), at, status: 'unchanged' };
|
|
1206
1160
|
});
|
|
1207
|
-
logger('subscription.refresh', { id, notModified: true, via: '
|
|
1161
|
+
logger('subscription.refresh', { id, notModified: true, via: 'atom' });
|
|
1208
1162
|
return {
|
|
1209
1163
|
ok: true,
|
|
1210
1164
|
changed: false,
|
|
1211
1165
|
notModified: true,
|
|
1212
|
-
via: '
|
|
1166
|
+
via: 'atom',
|
|
1213
1167
|
subscription: state().subscriptions.find((item) => item.id === id),
|
|
1214
1168
|
};
|
|
1215
1169
|
}
|
|
@@ -1267,11 +1221,11 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
1267
1221
|
|
|
1268
1222
|
const results = [];
|
|
1269
1223
|
for (const { subscription, installs: groupInstalls } of groups.values()) {
|
|
1270
|
-
//
|
|
1224
|
+
// 与 readSnapshot 同一套语义:限流时 Atom/codeload 兜底,失败才逐个标 unknown
|
|
1271
1225
|
// withCommit: false:判定只需要树,别为 check 多付一次 /commits 请求
|
|
1272
1226
|
let probe;
|
|
1273
1227
|
try {
|
|
1274
|
-
probe = await readSnapshot(subscription, { withCommit: false, persistRef: true });
|
|
1228
|
+
probe = await readSnapshot(subscription, { withCommit: false, persistRef: true, allowUnchanged: false });
|
|
1275
1229
|
} catch (error) {
|
|
1276
1230
|
for (const install of groupInstalls) {
|
|
1277
1231
|
results.push({
|
|
@@ -1336,8 +1290,8 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
1336
1290
|
if (!install) throw new ServiceError('error.skills.not-managed', `${name} 没有安装记录`);
|
|
1337
1291
|
const subscription = state().subscriptions.find((item) => item.id === install.subscriptionId);
|
|
1338
1292
|
if (!subscription) throw new ServiceError('error.skills.no-subscription', '该技能不属于任何订阅,无法生成更新计划');
|
|
1339
|
-
// 与 checkUpdates
|
|
1340
|
-
const probe = await readSnapshot(subscription, { force: true });
|
|
1293
|
+
// 与 checkUpdates 共用一套取数语义;计划必须拿到完整树,所以不允许 commit 相同就短路
|
|
1294
|
+
const probe = await readSnapshot(subscription, { force: true, allowUnchanged: false });
|
|
1341
1295
|
const ref = probe.branch;
|
|
1342
1296
|
const lock = readLock(paths);
|
|
1343
1297
|
const skillPath = lock.skills?.[name]?.skillPath || install.skillPath;
|
|
@@ -1368,7 +1322,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
1368
1322
|
|
|
1369
1323
|
async applyUpdate({ name, token, overwrite = false }) {
|
|
1370
1324
|
assertWritable();
|
|
1371
|
-
await
|
|
1325
|
+
await requireInstaller();
|
|
1372
1326
|
return enqueue(`apply:${name}`, async () => {
|
|
1373
1327
|
const plan = await this.planUpdate({ name });
|
|
1374
1328
|
if (token && token !== plan.token) {
|
|
@@ -1376,7 +1330,6 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
1376
1330
|
}
|
|
1377
1331
|
const install = store.findInstall(name);
|
|
1378
1332
|
if (!install) throw new ServiceError('error.skills.not-managed', `${name} 没有安装记录`);
|
|
1379
|
-
const canonicalPath = path.join(paths.canonical, name);
|
|
1380
1333
|
if (plan.localModified && !overwrite) {
|
|
1381
1334
|
throw new ServiceError('error.skills.locally-modified', '本地有改动,更新会覆盖它们;确认后带 overwrite 重试', {
|
|
1382
1335
|
plan,
|
|
@@ -1384,57 +1337,79 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
1384
1337
|
}
|
|
1385
1338
|
|
|
1386
1339
|
const subscription = state().subscriptions.find((item) => item.id === install.subscriptionId);
|
|
1387
|
-
|
|
1388
|
-
? createBackup({ paths, skill: name, fromDir: canonicalPath, hash: install.installedHash })
|
|
1389
|
-
: null;
|
|
1340
|
+
if (!subscription) throw new ServiceError('error.skills.no-subscription', '该技能不属于任何订阅,无法更新');
|
|
1390
1341
|
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
logger('updates.apply.failed', { name, code: result.code, verified, present });
|
|
1405
|
-
const suffix = backup ? ';已回滚到上一版' : ';没有可回滚的备份';
|
|
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 });
|
|
1406
1355
|
return {
|
|
1407
1356
|
ok: false,
|
|
1408
|
-
code:
|
|
1409
|
-
message:
|
|
1410
|
-
detail: {
|
|
1411
|
-
|
|
1412
|
-
verified,
|
|
1413
|
-
present,
|
|
1414
|
-
stderr: (result.stderr || '').slice(-2000),
|
|
1415
|
-
...(failure?.detail || {}),
|
|
1416
|
-
},
|
|
1417
|
-
backup: backup?.key || null,
|
|
1357
|
+
code: 'error.skills.update-failed',
|
|
1358
|
+
message: '更新失败;安装器已回滚到操作前状态',
|
|
1359
|
+
detail: { reason: error?.code || null, status: error?.status ?? null },
|
|
1360
|
+
backup: null,
|
|
1418
1361
|
};
|
|
1419
1362
|
}
|
|
1420
1363
|
|
|
1421
|
-
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));
|
|
1422
1377
|
store.update((draft) => {
|
|
1423
|
-
const row = draft.installs.find((
|
|
1378
|
+
const row = draft.installs.find((entry) => entry.name === name);
|
|
1424
1379
|
if (!row) return;
|
|
1425
|
-
row.commit = plan.toCommit;
|
|
1426
|
-
|
|
1427
|
-
row.treeSha = plan.targetTreeSha || row.treeSha || null;
|
|
1380
|
+
row.commit = plan.toCommit || null;
|
|
1381
|
+
row.treeSha = item.treeSha || plan.targetTreeSha || null;
|
|
1428
1382
|
row.installedHash = installedHash;
|
|
1429
1383
|
row.updatedAt = nowIso();
|
|
1430
|
-
row.backup =
|
|
1431
|
-
|
|
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
|
+
};
|
|
1432
1400
|
});
|
|
1433
1401
|
|
|
1434
1402
|
// 复验软链:更新重建的是目录,链接理论上不受影响,断一条就补一条
|
|
1435
1403
|
const relinked = linkSkill(name, ['dsh-global'], { scopeProjects: install.scopeProjects || [] });
|
|
1436
|
-
logger('updates.apply', { name, from: plan.fromCommit, to: plan.toCommit, backup: backup
|
|
1437
|
-
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
|
+
};
|
|
1438
1413
|
});
|
|
1439
1414
|
},
|
|
1440
1415
|
|
|
@@ -1446,7 +1421,26 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
1446
1421
|
const backup = readBackup({ paths, key: install.backup.key });
|
|
1447
1422
|
if (!backup) throw new ServiceError('error.skills.no-backup', '备份目录已不存在');
|
|
1448
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;
|
|
1449
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
|
+
}
|
|
1450
1444
|
const installedHash = contentHash(canonicalPath);
|
|
1451
1445
|
store.update((draft) => {
|
|
1452
1446
|
const row = draft.installs.find((item) => item.name === name);
|