@morehao/dsh-skills-console 0.0.0 → 0.2.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/LICENSE +21 -0
- package/README.md +112 -0
- package/client.js +1246 -0
- package/cordis.patch.yml +8 -0
- package/icon.svg +15 -0
- package/lib/agent-table.json +331 -0
- package/lib/core/agents.js +124 -0
- package/lib/core/cli.js +232 -0
- package/lib/core/hash.js +46 -0
- package/lib/core/inventory.js +174 -0
- package/lib/core/links.js +104 -0
- package/lib/core/log.js +23 -0
- package/lib/core/paths.js +54 -0
- package/lib/core/service.js +897 -0
- package/lib/core/state.js +149 -0
- package/lib/core/trash.js +142 -0
- package/lib/core/updates.js +138 -0
- package/lib/index.js +77 -0
- package/lib/routes.js +163 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +68 -1
|
@@ -0,0 +1,897 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { StateReadOnlyError } from './state.js';
|
|
4
|
+
import { contentHash } from './hash.js';
|
|
5
|
+
import { ensureLink, inspectTarget, removeLink } from './links.js';
|
|
6
|
+
import { buildLedger, readLock, scanCanonical } from './inventory.js';
|
|
7
|
+
import { createBackup, readBackup, restoreBackup, trashSkill } from './trash.js';
|
|
8
|
+
import {
|
|
9
|
+
checkSkill,
|
|
10
|
+
fetchTree,
|
|
11
|
+
listSkillsFromTree,
|
|
12
|
+
parseSource,
|
|
13
|
+
planToken,
|
|
14
|
+
resolveCommit,
|
|
15
|
+
skillTreeSha,
|
|
16
|
+
snapshotId,
|
|
17
|
+
} from './updates.js';
|
|
18
|
+
|
|
19
|
+
const QUEUE_LIMIT = 8;
|
|
20
|
+
|
|
21
|
+
export class ServiceError extends Error {
|
|
22
|
+
constructor(code, message, detail) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.code = code;
|
|
25
|
+
this.detail = detail;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function nowIso() {
|
|
30
|
+
return new Date().toISOString();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 写操作全局单写者队列:`add` 会同时改 canonical 与 lock,按技能分桶没有意义(设计 §14.3)。 */
|
|
34
|
+
function createQueue(logger) {
|
|
35
|
+
let chain = Promise.resolve();
|
|
36
|
+
let pending = 0;
|
|
37
|
+
const enqueue = function enqueue(label, task) {
|
|
38
|
+
if (pending >= QUEUE_LIMIT) {
|
|
39
|
+
throw new ServiceError('error.skills.queue-full', '写操作排队过多,请稍后重试');
|
|
40
|
+
}
|
|
41
|
+
pending += 1;
|
|
42
|
+
const run = chain.then(
|
|
43
|
+
() => task(),
|
|
44
|
+
() => task(),
|
|
45
|
+
);
|
|
46
|
+
chain = run.then(
|
|
47
|
+
() => {
|
|
48
|
+
pending -= 1;
|
|
49
|
+
},
|
|
50
|
+
() => {
|
|
51
|
+
pending -= 1;
|
|
52
|
+
},
|
|
53
|
+
);
|
|
54
|
+
logger('queue.run', { label, pending });
|
|
55
|
+
return run;
|
|
56
|
+
};
|
|
57
|
+
enqueue.pending = () => pending;
|
|
58
|
+
return enqueue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
export function createService({ paths, store, registry, cli, logger, env = process.env, fetchImpl = fetch, visibleProvider = async () => ({ skills: [], complete: true }), workspacesProvider = async () => [] }) {
|
|
64
|
+
const enqueue = createQueue(logger);
|
|
65
|
+
const tokenOf = (env.GITHUB_TOKEN || env.GH_TOKEN) || null;
|
|
66
|
+
|
|
67
|
+
function state() {
|
|
68
|
+
return store.state;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function assertWritable() {
|
|
72
|
+
if (store.readOnly) throw new StateReadOnlyError(store.problem);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function detectedTargets() {
|
|
76
|
+
return registry.detected();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function defaultTargets(current) {
|
|
80
|
+
const targets = ['dsh-global'];
|
|
81
|
+
// null = 老语义「所有探测到的 Agent」;数组 = 用户在「自动链接」里勾选的那些
|
|
82
|
+
const selected = current.settings.autoLinkTargets ?? null;
|
|
83
|
+
for (const target of detectedTargets()) {
|
|
84
|
+
if (target.kind !== 'agent') continue;
|
|
85
|
+
if (selected === null || selected.includes(target.target)) targets.push(target.target);
|
|
86
|
+
}
|
|
87
|
+
return targets;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 建链:逐条幂等,冲突跳过不覆盖,失败收集错误继续。 */
|
|
91
|
+
function linkSkill(name, targets, { scopeProjects = [], record = true } = {}) {
|
|
92
|
+
const created = [];
|
|
93
|
+
const skipped = [];
|
|
94
|
+
const failed = [];
|
|
95
|
+
const canonicalPath = path.join(paths.canonical, name);
|
|
96
|
+
if (!fs.existsSync(canonicalPath)) {
|
|
97
|
+
throw new ServiceError('error.skills.not-installed', `${name} 不在 canonical 里`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const resolved = [];
|
|
101
|
+
for (const target of targets) {
|
|
102
|
+
try {
|
|
103
|
+
resolved.push(registry.resolve(target));
|
|
104
|
+
} catch (error) {
|
|
105
|
+
failed.push({ target, reason: error.message });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// 作用范围非空 = 只写项目链,不写全局链(否则项目链没有额外效果,见 §4.6)
|
|
109
|
+
for (const root of scopeProjects) {
|
|
110
|
+
resolved.push(registry.resolve(`project:${root}`));
|
|
111
|
+
}
|
|
112
|
+
const effective = scopeProjects.length > 0 ? resolved.filter((item) => item.target !== 'dsh-global') : resolved;
|
|
113
|
+
|
|
114
|
+
for (const target of effective) {
|
|
115
|
+
const linkPath = path.join(target.dir, name);
|
|
116
|
+
const result = ensureLink({ linkPath, canonicalPath });
|
|
117
|
+
if (result.created) created.push({ target: target.target, linkPath, mode: result.mode });
|
|
118
|
+
else if (result.skipped) skipped.push({ target: target.target, linkPath, reason: result.state });
|
|
119
|
+
if (record) recordLink(name, target, linkPath, result.mode || 'relative', result.created);
|
|
120
|
+
}
|
|
121
|
+
return { created, skipped, failed };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function recordLink(name, target, linkPath, mode, created) {
|
|
125
|
+
if (store.readOnly) return;
|
|
126
|
+
const existing = state().links.find((item) => item.skill === name && item.target === target.target);
|
|
127
|
+
if (existing) return;
|
|
128
|
+
if (!created) return;
|
|
129
|
+
store.update((draft) => {
|
|
130
|
+
draft.links.push({
|
|
131
|
+
skill: name,
|
|
132
|
+
target: target.target,
|
|
133
|
+
agentDir: target.dir,
|
|
134
|
+
linkPath,
|
|
135
|
+
mode,
|
|
136
|
+
createdAt: nowIso(),
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function visibleSkills(cwd) {
|
|
142
|
+
try {
|
|
143
|
+
const snapshot = await visibleProvider(cwd);
|
|
144
|
+
return snapshot;
|
|
145
|
+
} catch (error) {
|
|
146
|
+
logger('visible.failed', { error: error.message });
|
|
147
|
+
return { skills: [], complete: false, error: error.message };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function workspaceList() {
|
|
152
|
+
try {
|
|
153
|
+
return await workspacesProvider();
|
|
154
|
+
} catch (error) {
|
|
155
|
+
logger('workspaces.failed', { error: error.message });
|
|
156
|
+
return [];
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* 一条「接管」记录:只算数据,不写盘。canonical 里没有来源也能接管,
|
|
162
|
+
* 只是没有订阅、检测不到更新(等于仅能启停 / 接入 / 删除)。
|
|
163
|
+
*/
|
|
164
|
+
function adoptionRecord(name, lockEntry) {
|
|
165
|
+
const canonicalPath = path.join(paths.canonical, name);
|
|
166
|
+
const parsed = lockEntry?.source ? parseSource(lockEntry.source) : null;
|
|
167
|
+
const source = parsed ? `${parsed.owner}/${parsed.repo}` : null;
|
|
168
|
+
const ref = lockEntry?.ref || null;
|
|
169
|
+
const subscriptionId = source ? snapshotId({ source, ref, subpath: null }) : null;
|
|
170
|
+
const installedHash = fs.statSync(canonicalPath).isDirectory() ? contentHash(canonicalPath) : null;
|
|
171
|
+
const dshLink = path.join(paths.dshGlobalSkills, name);
|
|
172
|
+
const live = inspectTarget({ linkPath: dshLink, canonicalPath });
|
|
173
|
+
return {
|
|
174
|
+
name,
|
|
175
|
+
subscriptionId,
|
|
176
|
+
source,
|
|
177
|
+
ref,
|
|
178
|
+
skillPath: lockEntry?.skillPath || `${name}/SKILL.md`,
|
|
179
|
+
installedHash,
|
|
180
|
+
// 之前由 CLI/别人建的 DSH 链,接管后要记成自己的(否则账本里一直是 managed-external)
|
|
181
|
+
link: live.state === 'managed-external' ? { target: 'dsh-global', agentDir: paths.dshGlobalSkills, linkPath: dshLink } : null,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function applyAdoptions(draft, records) {
|
|
186
|
+
for (const record of records) {
|
|
187
|
+
if (record.subscriptionId && !draft.subscriptions.some((item) => item.id === record.subscriptionId)) {
|
|
188
|
+
draft.subscriptions.push({
|
|
189
|
+
id: record.subscriptionId,
|
|
190
|
+
source: record.source,
|
|
191
|
+
ref: record.ref,
|
|
192
|
+
subpath: null,
|
|
193
|
+
skills: '*',
|
|
194
|
+
lastCheck: null,
|
|
195
|
+
snapshot: null,
|
|
196
|
+
adopted: true,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
if (!draft.installs.some((item) => item.name === record.name)) {
|
|
200
|
+
draft.installs.push({
|
|
201
|
+
name: record.name,
|
|
202
|
+
subscriptionId: record.subscriptionId,
|
|
203
|
+
skillPath: record.skillPath,
|
|
204
|
+
ref: record.ref,
|
|
205
|
+
commit: null,
|
|
206
|
+
installedHash: record.installedHash,
|
|
207
|
+
installedAt: nowIso(),
|
|
208
|
+
updatedAt: nowIso(),
|
|
209
|
+
mode: 'on',
|
|
210
|
+
scopeProjects: [],
|
|
211
|
+
backup: null,
|
|
212
|
+
adopted: true,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
if (record.link && !draft.links.some((item) => item.skill === record.name && item.target === 'dsh-global')) {
|
|
216
|
+
draft.links.push({
|
|
217
|
+
skill: record.name,
|
|
218
|
+
target: record.link.target,
|
|
219
|
+
agentDir: record.link.agentDir,
|
|
220
|
+
linkPath: record.link.linkPath,
|
|
221
|
+
mode: 'relative',
|
|
222
|
+
createdAt: nowIso(),
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* 自动接管:canonical 里存在、账本里还没有的技能,一次补齐。
|
|
230
|
+
*
|
|
231
|
+
* canonical 本身就是"已安装"的权威来源;装的时候不一定经过本插件(终端里跑 CLI、
|
|
232
|
+
* 升级前的老数据),要求用户逐个点「接管」没有意义。这里只补记账,不碰任何文件。
|
|
233
|
+
* 有 lock 来源的顺带建订阅,之后更新检测就能工作。
|
|
234
|
+
*/
|
|
235
|
+
function reconcileTracking(lock) {
|
|
236
|
+
if (store.readOnly || enqueue.pending() > 0) return [];
|
|
237
|
+
const known = new Set(state().installs.map((item) => item.name));
|
|
238
|
+
const records = [];
|
|
239
|
+
for (const entry of scanCanonical(paths)) {
|
|
240
|
+
// 只对目录型技能记账:平铺 .md / 悬空链接无法按目录建链与启停
|
|
241
|
+
if (entry.kind !== 'dir' || known.has(entry.name)) continue;
|
|
242
|
+
records.push(adoptionRecord(entry.name, lock.skills?.[entry.name] || null));
|
|
243
|
+
}
|
|
244
|
+
if (records.length === 0) return [];
|
|
245
|
+
store.update((draft) => applyAdoptions(draft, records));
|
|
246
|
+
logger('skills.reconcile', { adopted: records.map((item) => item.name) });
|
|
247
|
+
return records;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
paths,
|
|
252
|
+
store,
|
|
253
|
+
registry,
|
|
254
|
+
|
|
255
|
+
/** 每次请求前从磁盘重读状态:别的进程(或手工编辑)改过也要立刻看见。 */
|
|
256
|
+
reloadState() {
|
|
257
|
+
// 有写操作在跑时不要重读:否则会把队列里刚写完但尚未落盘的状态冲掉
|
|
258
|
+
if (enqueue.pending() > 0) return store.state;
|
|
259
|
+
store.load();
|
|
260
|
+
return store.state;
|
|
261
|
+
},
|
|
262
|
+
|
|
263
|
+
async getState({ cwd, workspace } = {}) {
|
|
264
|
+
const lock = readLock(paths);
|
|
265
|
+
reconcileTracking(lock);
|
|
266
|
+
const visible = await visibleSkills(cwd);
|
|
267
|
+
const visibleNames = new Map(visible.skills.map((item) => [item.name, item.source || item.provider || 'unknown']));
|
|
268
|
+
const ledger = buildLedger({ paths, state: state(), registry, lock, visibleNames });
|
|
269
|
+
const capabilities = await cli.capabilities();
|
|
270
|
+
return {
|
|
271
|
+
ok: true,
|
|
272
|
+
readOnly: store.readOnly,
|
|
273
|
+
readOnlyReason: store.problem,
|
|
274
|
+
settings: state().settings,
|
|
275
|
+
agents: { all: registry.all(), detected: detectedTargets() },
|
|
276
|
+
subscriptions: state().subscriptions,
|
|
277
|
+
workspaces: await workspaceList(),
|
|
278
|
+
installs: ledger.rows,
|
|
279
|
+
visible: visible.skills,
|
|
280
|
+
visibleComplete: visible.complete !== false,
|
|
281
|
+
visibleOnly: ledger.visibleOnly,
|
|
282
|
+
checks: state().checks,
|
|
283
|
+
cli: capabilities,
|
|
284
|
+
paths: {
|
|
285
|
+
canonical: paths.canonical,
|
|
286
|
+
lock: paths.lockFile,
|
|
287
|
+
dshGlobal: paths.dshGlobalSkills,
|
|
288
|
+
state: paths.stateFile,
|
|
289
|
+
},
|
|
290
|
+
cwd: cwd || null,
|
|
291
|
+
workspace: workspace || null,
|
|
292
|
+
scannedAt: ledger.scannedAt,
|
|
293
|
+
};
|
|
294
|
+
},
|
|
295
|
+
|
|
296
|
+
async diagnostics() {
|
|
297
|
+
const capabilities = await cli.capabilities({ force: true });
|
|
298
|
+
const lock = readLock(paths);
|
|
299
|
+
reconcileTracking(lock);
|
|
300
|
+
const rows = buildLedger({ paths, state: state(), registry, lock, visibleNames: new Map() }).rows;
|
|
301
|
+
const mismatched = rows
|
|
302
|
+
.filter((row) => row.managed && row.lockHash)
|
|
303
|
+
.map((row) => ({
|
|
304
|
+
name: row.name,
|
|
305
|
+
sourceType: row.sourceType,
|
|
306
|
+
hash: row.lockHash,
|
|
307
|
+
paired: row.sourceType === 'github' ? /^[0-9a-f]{40}$/.test(row.lockHash) : true,
|
|
308
|
+
}))
|
|
309
|
+
.filter((item) => !item.paired);
|
|
310
|
+
return {
|
|
311
|
+
ok: true,
|
|
312
|
+
cli: capabilities,
|
|
313
|
+
lock: { present: lock.present, version: lock.version, entries: Object.keys(lock.skills || {}).length, problem: lock.problem },
|
|
314
|
+
canonical: { path: paths.canonical, entries: rows.length },
|
|
315
|
+
paths,
|
|
316
|
+
hashMismatch: mismatched,
|
|
317
|
+
installs: state().installs.length,
|
|
318
|
+
links: state().links.length,
|
|
319
|
+
subscriptions: state().subscriptions.length,
|
|
320
|
+
backups: state().installs.filter((item) => item.backup).length,
|
|
321
|
+
};
|
|
322
|
+
},
|
|
323
|
+
|
|
324
|
+
async setSettings(patch) {
|
|
325
|
+
assertWritable();
|
|
326
|
+
const detected = new Set(detectedTargets().filter((target) => target.kind === 'agent').map((target) => target.target));
|
|
327
|
+
store.update((draft) => {
|
|
328
|
+
if (patch.autoLinkTargets === null) {
|
|
329
|
+
draft.settings.autoLinkTargets = null;
|
|
330
|
+
} else if (Array.isArray(patch.autoLinkTargets)) {
|
|
331
|
+
// 只接受当前探测到的 Agent,避免把任意 target 字符串写进设置
|
|
332
|
+
draft.settings.autoLinkTargets = [
|
|
333
|
+
...new Set(patch.autoLinkTargets.filter((target) => typeof target === 'string' && detected.has(target))),
|
|
334
|
+
];
|
|
335
|
+
} else if (typeof patch.autoLinkAgents === 'boolean') {
|
|
336
|
+
// 旧客户端兼容:true = 所有探测到的 Agent
|
|
337
|
+
draft.settings.autoLinkTargets = patch.autoLinkAgents ? null : [];
|
|
338
|
+
}
|
|
339
|
+
if (patch.placement === 'settings' || patch.placement === 'sidebar') {
|
|
340
|
+
draft.settings.placement = patch.placement;
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
return { ok: true, settings: state().settings };
|
|
344
|
+
},
|
|
345
|
+
|
|
346
|
+
async setLinks({ skill, targets = [], action = 'add', scopeProjects = [] }) {
|
|
347
|
+
assertWritable();
|
|
348
|
+
const install = store.findInstall(skill);
|
|
349
|
+
if (!install && action !== 'add') throw new ServiceError('error.skills.not-managed', `${skill} 没有安装记录`);
|
|
350
|
+
if (action === 'remove') {
|
|
351
|
+
const removed = [];
|
|
352
|
+
for (const target of targets) {
|
|
353
|
+
const resolved = registry.resolve(target);
|
|
354
|
+
const result = removeLink({ linkPath: path.join(resolved.dir, skill), canonicalPath: path.join(paths.canonical, skill) });
|
|
355
|
+
if (result.removed) removed.push({ target, linkPath: path.join(resolved.dir, skill) });
|
|
356
|
+
}
|
|
357
|
+
if (!store.readOnly) {
|
|
358
|
+
store.update((draft) => {
|
|
359
|
+
draft.links = draft.links.filter((link) => !(link.skill === skill && targets.includes(link.target)));
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
return { ok: true, removed };
|
|
363
|
+
}
|
|
364
|
+
return { ok: true, ...linkSkill(skill, targets, { scopeProjects }) };
|
|
365
|
+
},
|
|
366
|
+
|
|
367
|
+
async listSkillBody({ name }) {
|
|
368
|
+
const candidateDirs = [path.join(paths.canonical, name), path.join(paths.canonical, `${name}.md`)];
|
|
369
|
+
for (const candidate of candidateDirs) {
|
|
370
|
+
try {
|
|
371
|
+
const stat = fs.statSync(candidate);
|
|
372
|
+
if (stat.isDirectory()) {
|
|
373
|
+
const body = await fs.promises.readFile(path.join(candidate, 'SKILL.md'), 'utf8');
|
|
374
|
+
return { ok: true, name, content: body, path: path.join(candidate, 'SKILL.md') };
|
|
375
|
+
}
|
|
376
|
+
const body = await fs.promises.readFile(candidate, 'utf8');
|
|
377
|
+
return { ok: true, name, content: body, path: candidate };
|
|
378
|
+
} catch {
|
|
379
|
+
// 继续找下一种形态
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
throw new ServiceError('error.skills.not-found', `${name} 不在 canonical 里`);
|
|
383
|
+
},
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* 接管:把 CLI/别人装的技能纳入追踪(补一条 installs 记录)。
|
|
387
|
+
* 有 lock 来源时顺带建/复用一个订阅,否则这个技能永远不会出现在更新检测里。
|
|
388
|
+
*/
|
|
389
|
+
async adopt({ name }) {
|
|
390
|
+
assertWritable();
|
|
391
|
+
const canonicalPath = path.join(paths.canonical, name);
|
|
392
|
+
if (!fs.existsSync(canonicalPath)) {
|
|
393
|
+
throw new ServiceError('error.skills.not-found', `${name} 不在 canonical 里`);
|
|
394
|
+
}
|
|
395
|
+
if (store.findInstall(name)) return { ok: true, name, already: true };
|
|
396
|
+
const lock = readLock(paths);
|
|
397
|
+
const lockEntry = lock.skills?.[name] || null;
|
|
398
|
+
const record = adoptionRecord(name, lockEntry);
|
|
399
|
+
store.update((draft) => applyAdoptions(draft, [record]));
|
|
400
|
+
logger('skill.adopt', { name, subscriptionId: record.subscriptionId, lockSource: lockEntry?.source || null });
|
|
401
|
+
return {
|
|
402
|
+
ok: true,
|
|
403
|
+
name,
|
|
404
|
+
subscriptionId: record.subscriptionId,
|
|
405
|
+
tracked: Boolean(record.subscriptionId),
|
|
406
|
+
installedHash: record.installedHash,
|
|
407
|
+
note: record.subscriptionId ? null : '没有可识别的远端来源,只能追踪本地改动,不能检测更新',
|
|
408
|
+
};
|
|
409
|
+
},
|
|
410
|
+
|
|
411
|
+
async setMode({ name, mode }) {
|
|
412
|
+
assertWritable();
|
|
413
|
+
if (mode !== 'on' && mode !== 'off') throw new ServiceError('error.skills.bad-mode', 'mode 只能是 on / off');
|
|
414
|
+
const install = store.findInstall(name);
|
|
415
|
+
if (!install) throw new ServiceError('error.skills.not-managed', `${name} 没有安装记录`);
|
|
416
|
+
return enqueue(`mode:${name}`, async () => {
|
|
417
|
+
const scopeProjects = install.scopeProjects || [];
|
|
418
|
+
if (mode === 'off') {
|
|
419
|
+
// 只摘 DSH 侧链接(全局链 + 项目链),其他 Agent 与文件一律不动
|
|
420
|
+
const dshTargets = ['dsh-global', ...scopeProjects.map((root) => `project:${root}`)];
|
|
421
|
+
const removed = [];
|
|
422
|
+
for (const target of dshTargets) {
|
|
423
|
+
const resolved = registry.resolve(target);
|
|
424
|
+
const result = removeLink({
|
|
425
|
+
linkPath: path.join(resolved.dir, name),
|
|
426
|
+
canonicalPath: path.join(paths.canonical, name),
|
|
427
|
+
force: true,
|
|
428
|
+
});
|
|
429
|
+
if (result.removed) removed.push(target);
|
|
430
|
+
}
|
|
431
|
+
store.update((draft) => {
|
|
432
|
+
const row = draft.installs.find((item) => item.name === name);
|
|
433
|
+
if (row) row.mode = 'off';
|
|
434
|
+
draft.links = draft.links.filter(
|
|
435
|
+
(link) => !(link.skill === name && dshTargets.includes(link.target)),
|
|
436
|
+
);
|
|
437
|
+
});
|
|
438
|
+
logger('mode.off', { name, removed });
|
|
439
|
+
return { ok: true, mode: 'off', removed };
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const result = linkSkill(name, ['dsh-global'], { scopeProjects });
|
|
443
|
+
store.update((draft) => {
|
|
444
|
+
const row = draft.installs.find((item) => item.name === name);
|
|
445
|
+
if (row) row.mode = 'on';
|
|
446
|
+
});
|
|
447
|
+
logger('mode.on', { name, created: result.created.length });
|
|
448
|
+
return { ok: true, mode: 'on', ...result };
|
|
449
|
+
});
|
|
450
|
+
},
|
|
451
|
+
|
|
452
|
+
async install({ source, subscriptionId, skills = [], commit, targets, scopeProjects = [] }) {
|
|
453
|
+
assertWritable();
|
|
454
|
+
const capabilities = await cli.capabilities();
|
|
455
|
+
if (!capabilities.usable) {
|
|
456
|
+
throw new ServiceError('error.skills.cli-missing', 'skills CLI 不可用或不支持 --json/--full-depth,写操作已禁用', capabilities);
|
|
457
|
+
}
|
|
458
|
+
const names = (skills || []).filter(Boolean);
|
|
459
|
+
if (names.length === 0) throw new ServiceError('error.skills.bad-request', 'skills 不能为空');
|
|
460
|
+
|
|
461
|
+
return enqueue('install', async () => {
|
|
462
|
+
const subscription = subscriptionId
|
|
463
|
+
? state().subscriptions.find((item) => item.id === subscriptionId)
|
|
464
|
+
: null;
|
|
465
|
+
if (subscriptionId && !subscription) throw new ServiceError('error.skills.no-subscription', '订阅不存在');
|
|
466
|
+
const resolvedSource = subscription ? subscription.source : source;
|
|
467
|
+
if (!parseSource(resolvedSource)) throw new ServiceError('error.skills.bad-source', '只接受 owner/repo 或 GitHub URL(公开源)');
|
|
468
|
+
|
|
469
|
+
const snapshot = subscription?.snapshot || null;
|
|
470
|
+
const pin = commit || snapshot?.commit || subscription?.ref || null;
|
|
471
|
+
const sourceArg = pin ? `${resolvedSource}#${pin}` : resolvedSource;
|
|
472
|
+
|
|
473
|
+
const result = await cli.add({ source: sourceArg, skills: names });
|
|
474
|
+
if (result.timedOut) throw new ServiceError('error.skills.cli-timeout', 'skills add 超时');
|
|
475
|
+
if (result.code !== 0) {
|
|
476
|
+
throw new ServiceError('error.skills.cli-failed', 'skills add 失败', {
|
|
477
|
+
code: result.code,
|
|
478
|
+
stderr: result.stderr.slice(-2000),
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const lock = readLock(paths);
|
|
483
|
+
const installed = [];
|
|
484
|
+
const warnings = [];
|
|
485
|
+
for (const name of names) {
|
|
486
|
+
const canonicalPath = path.join(paths.canonical, name);
|
|
487
|
+
if (!fs.existsSync(canonicalPath)) {
|
|
488
|
+
warnings.push({ name, warning: 'add 成功但 canonical 里没有该目录(可能被目录发现提前终止)' });
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
const lockEntry = lock.skills?.[name];
|
|
492
|
+
if (!lockEntry) {
|
|
493
|
+
warnings.push({ name, warning: 'lock 里没有该技能的记录(来源形态不支持记账)' });
|
|
494
|
+
}
|
|
495
|
+
const expected = snapshot?.entries?.find((entry) => entry.name === name);
|
|
496
|
+
if (expected?.treeSha && lockEntry?.skillFolderHash && lockEntry.skillFolderHash !== expected.treeSha) {
|
|
497
|
+
warnings.push({
|
|
498
|
+
name,
|
|
499
|
+
warning: 'lock 的 skillFolderHash 与快照 treeSha 不一致(可能装到了别的 commit)',
|
|
500
|
+
expected: expected.treeSha,
|
|
501
|
+
actual: lockEntry.skillFolderHash,
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
const installedHash = fs.statSync(canonicalPath).isDirectory() ? contentHash(canonicalPath) : null;
|
|
505
|
+
const record = {
|
|
506
|
+
name,
|
|
507
|
+
subscriptionId: subscription ? subscription.id : null,
|
|
508
|
+
skillPath: lockEntry?.skillPath || expected?.skillPath || `${name}/SKILL.md`,
|
|
509
|
+
ref: pin,
|
|
510
|
+
commit: snapshot?.commit || null,
|
|
511
|
+
installedHash,
|
|
512
|
+
installedAt: nowIso(),
|
|
513
|
+
updatedAt: nowIso(),
|
|
514
|
+
mode: 'on',
|
|
515
|
+
scopeProjects: scopeProjects || [],
|
|
516
|
+
backup: null,
|
|
517
|
+
};
|
|
518
|
+
store.update((draft) => {
|
|
519
|
+
const index = draft.installs.findIndex((item) => item.name === name);
|
|
520
|
+
if (index >= 0) draft.installs[index] = { ...draft.installs[index], ...record, installedAt: draft.installs[index].installedAt };
|
|
521
|
+
else draft.installs.push(record);
|
|
522
|
+
});
|
|
523
|
+
installed.push({ name, lockHash: lockEntry?.skillFolderHash || null, installedHash });
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const linkTargets = Array.isArray(targets) && targets.length > 0 ? targets : defaultTargets(state());
|
|
527
|
+
const linkResult = { created: [], skipped: [], failed: [] };
|
|
528
|
+
for (const item of installed) {
|
|
529
|
+
const one = linkSkill(item.name, linkTargets, { scopeProjects });
|
|
530
|
+
linkResult.created.push(...one.created);
|
|
531
|
+
linkResult.skipped.push(...one.skipped);
|
|
532
|
+
linkResult.failed.push(...one.failed);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
logger('install', { source: sourceArg, skills: names, installed: installed.length });
|
|
536
|
+
return {
|
|
537
|
+
ok: true,
|
|
538
|
+
installed,
|
|
539
|
+
links: linkResult,
|
|
540
|
+
warnings,
|
|
541
|
+
source: sourceArg,
|
|
542
|
+
};
|
|
543
|
+
});
|
|
544
|
+
},
|
|
545
|
+
|
|
546
|
+
async remove({ name }) {
|
|
547
|
+
assertWritable();
|
|
548
|
+
return enqueue(`remove:${name}`, async () => {
|
|
549
|
+
const canonicalPath = path.join(paths.canonical, name);
|
|
550
|
+
if (!fs.existsSync(canonicalPath)) {
|
|
551
|
+
throw new ServiceError('error.skills.not-found', `${name} 不在 canonical 里`);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// ① 体检:CLI 的 remove 会把所有 agent 目录下的同名条目 rm -rf,不区分是谁建的
|
|
555
|
+
const conflicts = [];
|
|
556
|
+
for (const target of detectedTargets()) {
|
|
557
|
+
const linkPath = path.join(target.dir, name);
|
|
558
|
+
const live = inspectTarget({ linkPath, canonicalPath });
|
|
559
|
+
if (live.state === 'real') conflicts.push({ target: target.target, label: target.label, linkPath, reason: 'real' });
|
|
560
|
+
if (live.state === 'foreign-link' && !live.broken) {
|
|
561
|
+
conflicts.push({ target: target.target, label: target.label, linkPath, reason: 'foreign-link' });
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
if (conflicts.length > 0) {
|
|
565
|
+
throw new ServiceError(
|
|
566
|
+
'error.skills.remove-conflict',
|
|
567
|
+
`${conflicts.map((item) => item.label).join('、')} 里有不是本插件创建的 ${name},继续会连带删掉它`,
|
|
568
|
+
{ conflicts },
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// ② 快照进回收站(默认开,保留 7 天)
|
|
573
|
+
const hash = fs.statSync(canonicalPath).isDirectory() ? contentHash(canonicalPath) : null;
|
|
574
|
+
const trash = trashSkill({ paths, skill: name, fromDir: canonicalPath, hash });
|
|
575
|
+
|
|
576
|
+
// ③ 调 CLI:绝不带 -a
|
|
577
|
+
const result = await cli.remove({ names: [name] });
|
|
578
|
+
const cliOk = result.code === 0 && !result.timedOut;
|
|
579
|
+
|
|
580
|
+
// ④ 清自家链接
|
|
581
|
+
const cleaned = [];
|
|
582
|
+
for (const link of state().links.filter((item) => item.skill === name)) {
|
|
583
|
+
const outcome = removeLink({ linkPath: link.linkPath, canonicalPath, force: true });
|
|
584
|
+
if (outcome.removed) cleaned.push(link.linkPath);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// ⑤ 清记账
|
|
588
|
+
store.update((draft) => {
|
|
589
|
+
draft.installs = draft.installs.filter((item) => item.name !== name);
|
|
590
|
+
draft.links = draft.links.filter((item) => item.skill !== name);
|
|
591
|
+
delete draft.checks[name];
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
logger('remove', { name, cliOk, trash: trash.key });
|
|
595
|
+
if (!cliOk) {
|
|
596
|
+
return {
|
|
597
|
+
ok: false,
|
|
598
|
+
code: 'error.skills.cli-failed',
|
|
599
|
+
message: 'skills remove 失败;已断开本插件建的链接,真身可能仍在 canonical',
|
|
600
|
+
detail: { code: result.code, stderr: result.stderr.slice(-2000) },
|
|
601
|
+
trash: trash.key,
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
return { ok: true, name, trash: trash.key, removedLinks: cleaned };
|
|
605
|
+
});
|
|
606
|
+
},
|
|
607
|
+
|
|
608
|
+
async addSubscription({ source, ref, subpath, skills = '*' }) {
|
|
609
|
+
assertWritable();
|
|
610
|
+
const parsed = parseSource(source);
|
|
611
|
+
if (!parsed) throw new ServiceError('error.skills.bad-source', '订阅只接受 owner/repo 或 GitHub URL');
|
|
612
|
+
const id = snapshotId({ source, ref, subpath });
|
|
613
|
+
const existing = state().subscriptions.find((item) => item.id === id);
|
|
614
|
+
if (existing) return { ok: true, subscription: existing, existing: true };
|
|
615
|
+
const record = {
|
|
616
|
+
id,
|
|
617
|
+
source: `${parsed.owner}/${parsed.repo}`,
|
|
618
|
+
ref: ref || null,
|
|
619
|
+
subpath: subpath || null,
|
|
620
|
+
skills,
|
|
621
|
+
lastCheck: null,
|
|
622
|
+
snapshot: null,
|
|
623
|
+
};
|
|
624
|
+
store.update((draft) => {
|
|
625
|
+
draft.subscriptions.push(record);
|
|
626
|
+
});
|
|
627
|
+
return { ok: true, subscription: record, ...(await this.refreshSubscription({ id, force: true })) };
|
|
628
|
+
},
|
|
629
|
+
|
|
630
|
+
async removeSubscription({ id, keepInstalled = true }) {
|
|
631
|
+
assertWritable();
|
|
632
|
+
const subscription = state().subscriptions.find((item) => item.id === id);
|
|
633
|
+
if (!subscription) throw new ServiceError('error.skills.no-subscription', '订阅不存在');
|
|
634
|
+
store.update((draft) => {
|
|
635
|
+
draft.subscriptions = draft.subscriptions.filter((item) => item.id !== id);
|
|
636
|
+
if (!keepInstalled) {
|
|
637
|
+
for (const install of draft.installs) {
|
|
638
|
+
if (install.subscriptionId === id) install.subscriptionId = null;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
});
|
|
642
|
+
return { ok: true, id, keptInstalled: keepInstalled };
|
|
643
|
+
},
|
|
644
|
+
|
|
645
|
+
async refreshSubscription({ id, force = false }) {
|
|
646
|
+
assertWritable();
|
|
647
|
+
const subscription = state().subscriptions.find((item) => item.id === id);
|
|
648
|
+
if (!subscription) throw new ServiceError('error.skills.no-subscription', '订阅不存在');
|
|
649
|
+
const ref = subscription.ref || 'main';
|
|
650
|
+
const previous = subscription.snapshot?.etag || null;
|
|
651
|
+
const result = await fetchTree({
|
|
652
|
+
source: subscription.source,
|
|
653
|
+
ref,
|
|
654
|
+
etag: force ? null : previous,
|
|
655
|
+
token: tokenOf,
|
|
656
|
+
fetchImpl,
|
|
657
|
+
});
|
|
658
|
+
if (result.error) {
|
|
659
|
+
store.update((draft) => {
|
|
660
|
+
const row = draft.subscriptions.find((item) => item.id === id);
|
|
661
|
+
if (row) row.lastCheck = { at: nowIso(), status: 'unknown', detail: result.error };
|
|
662
|
+
});
|
|
663
|
+
return { ok: false, code: 'error.skills.upstream-failed', message: result.error };
|
|
664
|
+
}
|
|
665
|
+
if (result.notModified) {
|
|
666
|
+
store.update((draft) => {
|
|
667
|
+
const row = draft.subscriptions.find((item) => item.id === id);
|
|
668
|
+
// 304 只刷新时间,绝不把 entries 置空
|
|
669
|
+
if (row) row.lastCheck = { ...(row.lastCheck || {}), at: nowIso(), status: 'unchanged' };
|
|
670
|
+
});
|
|
671
|
+
return { ok: true, notModified: true, subscription: state().subscriptions.find((item) => item.id === id) };
|
|
672
|
+
}
|
|
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);
|
|
690
|
+
store.update((draft) => {
|
|
691
|
+
const row = draft.subscriptions.find((item) => item.id === id);
|
|
692
|
+
if (!row) return;
|
|
693
|
+
row.snapshot = { commit, rootSha: result.rootSha, entries, fetchedAt: nowIso(), etag: result.etag };
|
|
694
|
+
row.lastCheck = { at: nowIso(), commit, treeEtag: result.etag, status: changed ? 'changed' : 'unchanged' };
|
|
695
|
+
});
|
|
696
|
+
logger('subscription.refresh', { id, entries: entries.length, changed });
|
|
697
|
+
return { ok: true, changed, subscription: state().subscriptions.find((item) => item.id === id) };
|
|
698
|
+
},
|
|
699
|
+
|
|
700
|
+
async checkUpdates({ names, subscriptionId } = {}) {
|
|
701
|
+
const installs = state().installs.filter((install) => {
|
|
702
|
+
if (names && names.length > 0 && !names.includes(install.name)) return false;
|
|
703
|
+
if (subscriptionId && install.subscriptionId !== subscriptionId) return false;
|
|
704
|
+
return true;
|
|
705
|
+
});
|
|
706
|
+
const lock = readLock(paths);
|
|
707
|
+
const groups = new Map();
|
|
708
|
+
for (const install of installs) {
|
|
709
|
+
const subscription = state().subscriptions.find((item) => item.id === install.subscriptionId);
|
|
710
|
+
if (!subscription) continue;
|
|
711
|
+
const key = `${subscription.source}#${subscription.ref || 'main'}`;
|
|
712
|
+
if (!groups.has(key)) groups.set(key, { subscription, installs: [] });
|
|
713
|
+
groups.get(key).installs.push(install);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const results = [];
|
|
717
|
+
for (const { subscription, installs: groupInstalls } of groups.values()) {
|
|
718
|
+
const tree = await fetchTree({
|
|
719
|
+
source: subscription.source,
|
|
720
|
+
ref: subscription.ref || 'main',
|
|
721
|
+
etag: subscription.snapshot?.etag || null,
|
|
722
|
+
token: tokenOf,
|
|
723
|
+
fetchImpl,
|
|
724
|
+
});
|
|
725
|
+
if (tree.error) {
|
|
726
|
+
for (const install of groupInstalls) {
|
|
727
|
+
results.push({ name: install.name, status: 'unknown', detail: tree.error, checkedAt: nowIso() });
|
|
728
|
+
}
|
|
729
|
+
continue;
|
|
730
|
+
}
|
|
731
|
+
if (tree.notModified) {
|
|
732
|
+
for (const install of groupInstalls) {
|
|
733
|
+
const previousCheck = state().checks?.[install.name];
|
|
734
|
+
results.push({
|
|
735
|
+
name: install.name,
|
|
736
|
+
status: previousCheck?.status || 'current',
|
|
737
|
+
detail: 'ETag 未变,沿用上次结论',
|
|
738
|
+
checkedAt: nowIso(),
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
continue;
|
|
742
|
+
}
|
|
743
|
+
for (const install of groupInstalls) {
|
|
744
|
+
const skillPath = lock.skills?.[install.name]?.skillPath || install.skillPath;
|
|
745
|
+
const entry = tree.tree.some((item) => item.type === 'blob' && item.path === skillPath)
|
|
746
|
+
? { skillPath }
|
|
747
|
+
: null;
|
|
748
|
+
const verdict = checkSkill({
|
|
749
|
+
lockHash: lock.skills?.[install.name]?.skillFolderHash || null,
|
|
750
|
+
snapshotEntry: entry,
|
|
751
|
+
tree: tree.tree,
|
|
752
|
+
rootSha: tree.rootSha,
|
|
753
|
+
});
|
|
754
|
+
results.push({ name: install.name, ...verdict, checkedAt: nowIso() });
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
if (!store.readOnly) {
|
|
759
|
+
store.update((draft) => {
|
|
760
|
+
for (const item of results) {
|
|
761
|
+
const install = draft.installs.find((row) => row.name === item.name);
|
|
762
|
+
draft.checks[item.name] = {
|
|
763
|
+
name: item.name,
|
|
764
|
+
status: item.status,
|
|
765
|
+
detail: item.detail || null,
|
|
766
|
+
wouldCommit: null,
|
|
767
|
+
checkedAt: item.checkedAt,
|
|
768
|
+
installCommit: install?.commit || null,
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
logger('updates.check', { count: results.length });
|
|
774
|
+
return { ok: true, checks: results };
|
|
775
|
+
},
|
|
776
|
+
|
|
777
|
+
async planUpdate({ name }) {
|
|
778
|
+
const install = store.findInstall(name);
|
|
779
|
+
if (!install) throw new ServiceError('error.skills.not-managed', `${name} 没有安装记录`);
|
|
780
|
+
const subscription = state().subscriptions.find((item) => item.id === install.subscriptionId);
|
|
781
|
+
if (!subscription) throw new ServiceError('error.skills.no-subscription', '该技能不属于任何订阅,无法生成更新计划');
|
|
782
|
+
const tree = await fetchTree({
|
|
783
|
+
source: subscription.source,
|
|
784
|
+
ref: subscription.ref || 'main',
|
|
785
|
+
token: tokenOf,
|
|
786
|
+
fetchImpl,
|
|
787
|
+
});
|
|
788
|
+
if (tree.error) throw new ServiceError('error.skills.upstream-failed', tree.error);
|
|
789
|
+
const lock = readLock(paths);
|
|
790
|
+
const skillPath = lock.skills?.[name]?.skillPath || install.skillPath;
|
|
791
|
+
const entry = tree.tree.find((item) => item.type === 'blob' && item.path === skillPath);
|
|
792
|
+
if (!entry) throw new ServiceError('error.skills.upstream-deleted', '上游已移除该技能');
|
|
793
|
+
const targetTreeSha = skillTreeSha({ tree: tree.tree, skillPath, rootSha: tree.rootSha });
|
|
794
|
+
const targetCommit = await resolveCommit({ source: subscription.source, ref: subscription.ref || 'main', token: tokenOf, fetchImpl });
|
|
795
|
+
const token = planToken({
|
|
796
|
+
subscriptionId: subscription.id,
|
|
797
|
+
from: install.commit || null,
|
|
798
|
+
to: targetCommit,
|
|
799
|
+
installedHash: install.installedHash || null,
|
|
800
|
+
targetTreeSha,
|
|
801
|
+
});
|
|
802
|
+
logger('updates.plan', { name, targetCommit, targetTreeSha });
|
|
803
|
+
return {
|
|
804
|
+
ok: true,
|
|
805
|
+
name,
|
|
806
|
+
token,
|
|
807
|
+
fromCommit: install.commit || null,
|
|
808
|
+
toCommit: targetCommit,
|
|
809
|
+
targetTreeSha,
|
|
810
|
+
skillPath,
|
|
811
|
+
localModified: Boolean(install.installedHash) && contentHash(path.join(paths.canonical, name)) !== install.installedHash,
|
|
812
|
+
};
|
|
813
|
+
},
|
|
814
|
+
|
|
815
|
+
async applyUpdate({ name, token, overwrite = false }) {
|
|
816
|
+
assertWritable();
|
|
817
|
+
return enqueue(`apply:${name}`, async () => {
|
|
818
|
+
const plan = await this.planUpdate({ name });
|
|
819
|
+
if (token && token !== plan.token) {
|
|
820
|
+
throw new ServiceError('error.skills.stale-plan', '计划已失效(磁盘或上游被改动过),请重新检查');
|
|
821
|
+
}
|
|
822
|
+
const install = store.findInstall(name);
|
|
823
|
+
if (!install) throw new ServiceError('error.skills.not-managed', `${name} 没有安装记录`);
|
|
824
|
+
const canonicalPath = path.join(paths.canonical, name);
|
|
825
|
+
if (plan.localModified && !overwrite) {
|
|
826
|
+
throw new ServiceError('error.skills.locally-modified', '本地有改动,更新会覆盖它们;确认后带 overwrite 重试', {
|
|
827
|
+
plan,
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const subscription = state().subscriptions.find((item) => item.id === install.subscriptionId);
|
|
832
|
+
const backup = fs.existsSync(canonicalPath)
|
|
833
|
+
? createBackup({ paths, skill: name, fromDir: canonicalPath, hash: install.installedHash })
|
|
834
|
+
: null;
|
|
835
|
+
|
|
836
|
+
const sourceArg = plan.toCommit ? `${subscription.source}#${plan.toCommit}` : `${subscription.source}#${subscription.ref || 'main'}`;
|
|
837
|
+
const result = await cli.add({ source: sourceArg, skills: [name] });
|
|
838
|
+
const lock = readLock(paths);
|
|
839
|
+
const lockHash = lock.skills?.[name]?.skillFolderHash || null;
|
|
840
|
+
const verified = Boolean(lockHash) && lockHash === plan.targetTreeSha;
|
|
841
|
+
const present = fs.existsSync(canonicalPath);
|
|
842
|
+
|
|
843
|
+
if (result.code !== 0 || !present || !verified) {
|
|
844
|
+
if (backup) restoreBackup({ backup, toDir: canonicalPath });
|
|
845
|
+
logger('updates.apply.failed', { name, code: result.code, verified, present });
|
|
846
|
+
return {
|
|
847
|
+
ok: false,
|
|
848
|
+
code: 'error.skills.update-failed',
|
|
849
|
+
message: backup ? '更新失败,已回滚到上一版' : '更新失败,没有可回滚的备份',
|
|
850
|
+
detail: { cliCode: result.code, verified, present, stderr: result.stderr.slice(-2000) },
|
|
851
|
+
backup: backup?.key || null,
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
const installedHash = contentHash(canonicalPath);
|
|
856
|
+
store.update((draft) => {
|
|
857
|
+
const row = draft.installs.find((item) => item.name === name);
|
|
858
|
+
if (!row) return;
|
|
859
|
+
row.commit = plan.toCommit;
|
|
860
|
+
row.installedHash = installedHash;
|
|
861
|
+
row.updatedAt = nowIso();
|
|
862
|
+
row.backup = backup ? { key: backup.key, hash: install.installedHash || null } : row.backup;
|
|
863
|
+
draft.checks[name] = { name, status: 'current', detail: null, wouldCommit: null, checkedAt: nowIso(), installCommit: plan.toCommit };
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
// 复验软链:更新重建的是目录,链接理论上不受影响,断一条就补一条
|
|
867
|
+
const relinked = linkSkill(name, ['dsh-global'], { scopeProjects: install.scopeProjects || [] });
|
|
868
|
+
logger('updates.apply', { name, from: plan.fromCommit, to: plan.toCommit, backup: backup?.key });
|
|
869
|
+
return { ok: true, name, fromCommit: plan.fromCommit, toCommit: plan.toCommit, backup: backup?.key || null, relinked };
|
|
870
|
+
});
|
|
871
|
+
},
|
|
872
|
+
|
|
873
|
+
async rollback({ name }) {
|
|
874
|
+
assertWritable();
|
|
875
|
+
return enqueue(`rollback:${name}`, async () => {
|
|
876
|
+
const install = store.findInstall(name);
|
|
877
|
+
if (!install?.backup?.key) throw new ServiceError('error.skills.no-backup', `${name} 没有可回滚的备份`);
|
|
878
|
+
const backup = readBackup({ paths, key: install.backup.key });
|
|
879
|
+
if (!backup) throw new ServiceError('error.skills.no-backup', '备份目录已不存在');
|
|
880
|
+
const canonicalPath = path.join(paths.canonical, name);
|
|
881
|
+
restoreBackup({ backup, toDir: canonicalPath });
|
|
882
|
+
const installedHash = contentHash(canonicalPath);
|
|
883
|
+
store.update((draft) => {
|
|
884
|
+
const row = draft.installs.find((item) => item.name === name);
|
|
885
|
+
if (!row) return;
|
|
886
|
+
row.installedHash = installedHash;
|
|
887
|
+
row.updatedAt = nowIso();
|
|
888
|
+
row.commit = null;
|
|
889
|
+
draft.checks[name] = { name, status: 'unknown', detail: '已回滚,需重新检查', checkedAt: nowIso() };
|
|
890
|
+
});
|
|
891
|
+
logger('updates.rollback', { name, backup: install.backup.key });
|
|
892
|
+
return { ok: true, name, restoredFrom: install.backup.key, installedHash };
|
|
893
|
+
});
|
|
894
|
+
},
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
|