@morehao/dsh-skills-console 0.2.4 → 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 +14 -19
- 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
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { extractTarball } from './archive.js';
|
|
6
|
+
import { contentHash } from './hash.js';
|
|
7
|
+
import { discoverSkills } from './discovery.js';
|
|
8
|
+
import { gitTreeSha } from './git-tree.js';
|
|
9
|
+
import { createBackup, pruneBackups, readBackup, restoreBackup } from './trash.js';
|
|
10
|
+
import { makeLockEntry, readLockFile, updateLock } from './lock.js';
|
|
11
|
+
import { parseSource } from './updates.js';
|
|
12
|
+
import { downloadTarball } from './remote.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Native Installer:canonical(`~/.agents/skills`)与 v3 lock 的**唯一写入者**。
|
|
16
|
+
*
|
|
17
|
+
* 事务边界(设计 §核心流程/安装):
|
|
18
|
+
* 1. 下载 + 安全解压 + 发现全部在临时目录完成;此时 canonical/lock 一个字节都没动。
|
|
19
|
+
* 2. 校验 lock 可写(版本/损坏在下载前就失败)。
|
|
20
|
+
* 3. 需要覆盖的旧目录先 `createBackup`,再把 staging 复制到 canonical 下的同盘临时目录,
|
|
21
|
+
* 逐个 `rename` 到最终位置(同文件系统,单目录原子替换)。
|
|
22
|
+
* 4. 所有目录就位后**一次** `updateLock` 写全部条目(`expectedDigest` 防外部并发)。
|
|
23
|
+
* 5. 任一步失败:反向删除已 promote 的目录、从备份恢复旧目录;lock 未写或保持原样。
|
|
24
|
+
*
|
|
25
|
+
* 只负责文件与 lock,不碰 state / 链接 / 回收站(那些仍是 Service 的职责)。
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export class InstallerError extends Error {
|
|
29
|
+
constructor(code, message, extra = {}) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = 'InstallerError';
|
|
32
|
+
this.code = code;
|
|
33
|
+
Object.assign(this, extra);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function nearestExisting(dir) {
|
|
38
|
+
let current = path.resolve(dir);
|
|
39
|
+
for (;;) {
|
|
40
|
+
if (fs.existsSync(current)) return current;
|
|
41
|
+
const parent = path.dirname(current);
|
|
42
|
+
if (parent === current) return current;
|
|
43
|
+
current = parent;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function assertSafeSkillName(name) {
|
|
48
|
+
if (typeof name !== 'string' || name.length === 0 || name.length > 120) {
|
|
49
|
+
throw new InstallerError('invalid-name', `技能名非法: ${String(name)}`);
|
|
50
|
+
}
|
|
51
|
+
if (name.includes('/') || name.includes('\\') || name.includes('..')) {
|
|
52
|
+
throw new InstallerError('invalid-name', `技能名不能含路径分隔符: ${name}`);
|
|
53
|
+
}
|
|
54
|
+
// eslint-disable-next-line no-control-regex
|
|
55
|
+
if (/[\u0000-\u001f\u007f]/.test(name)) {
|
|
56
|
+
throw new InstallerError('invalid-name', `技能名含控制字符: ${JSON.stringify(name)}`);
|
|
57
|
+
}
|
|
58
|
+
return name;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function copyTree(from, to) {
|
|
62
|
+
fs.cpSync(from, to, { recursive: true, dereference: false, preserveTimestamps: true });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function validIsoOrUndefined(value) {
|
|
66
|
+
return typeof value === 'string' && Number.isFinite(Date.parse(value)) ? value : undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function normalizeError(error) {
|
|
70
|
+
if (error instanceof InstallerError) return error;
|
|
71
|
+
const code = error?.code || 'install-failed';
|
|
72
|
+
return new InstallerError(code, error?.message || String(error), {
|
|
73
|
+
cause: error,
|
|
74
|
+
status: error?.status ?? null,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function createInstaller({
|
|
79
|
+
paths,
|
|
80
|
+
env = process.env,
|
|
81
|
+
fetchImpl = fetch,
|
|
82
|
+
token,
|
|
83
|
+
logger = () => {},
|
|
84
|
+
} = {}) {
|
|
85
|
+
const authToken = token ?? env.GITHUB_TOKEN ?? env.GH_TOKEN ?? null;
|
|
86
|
+
|
|
87
|
+
/** 与旧 `/state.cli` 兼容的就绪状态:只有 lock 与 canonical 都可写时 usable 才为 true。 */
|
|
88
|
+
function readiness() {
|
|
89
|
+
let problem = null;
|
|
90
|
+
try {
|
|
91
|
+
readLockFile(paths.lockFile);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
problem = error?.code || 'lock-unreadable';
|
|
94
|
+
}
|
|
95
|
+
let writable = false;
|
|
96
|
+
try {
|
|
97
|
+
fs.accessSync(nearestExisting(paths.canonical), fs.constants.W_OK);
|
|
98
|
+
writable = true;
|
|
99
|
+
} catch {
|
|
100
|
+
writable = false;
|
|
101
|
+
}
|
|
102
|
+
const usable = !problem && writable;
|
|
103
|
+
return {
|
|
104
|
+
usable,
|
|
105
|
+
source: 'native',
|
|
106
|
+
version: null,
|
|
107
|
+
json: true,
|
|
108
|
+
fullDepth: true,
|
|
109
|
+
problem: problem || (writable ? null : 'canonical-not-writable'),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 安装/重装一组技能。
|
|
115
|
+
* @param {{source:string, ref?:string|null, commit?:string|null, subpath?:string|null,
|
|
116
|
+
* names?:string[], expectedEntries?:Array<{name:string, treeSha?:string|null}>}} options
|
|
117
|
+
*/
|
|
118
|
+
async function install({ source, ref = null, commit = null, subpath = null, names = [], expectedEntries = null, strict = false } = {}) {
|
|
119
|
+
const parsed = parseSource(source);
|
|
120
|
+
if (!parsed) throw new InstallerError('bad-source', `只接受 owner/repo 或 GitHub URL: ${source}`);
|
|
121
|
+
const canonicalSource = `${parsed.owner}/${parsed.repo}`;
|
|
122
|
+
const pin = commit || ref;
|
|
123
|
+
if (typeof pin !== 'string' || pin.length === 0) {
|
|
124
|
+
throw new InstallerError('missing-ref', '安装需要 commit 或 ref');
|
|
125
|
+
}
|
|
126
|
+
const requested = (Array.isArray(names) ? names : []).filter(Boolean);
|
|
127
|
+
if (new Set(requested).size !== requested.length) {
|
|
128
|
+
throw new InstallerError('duplicate-selection', '选择列表里有重复技能名');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 下载前先确认 lock 可写:坏 lock / 版本过高不应该先花一次网络请求
|
|
132
|
+
const lockBefore = readLockFile(paths.lockFile);
|
|
133
|
+
|
|
134
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'dshsc-install-'));
|
|
135
|
+
const tarball = path.join(tempRoot, 'repo.tar.gz');
|
|
136
|
+
const staging = path.join(tempRoot, 'stage');
|
|
137
|
+
const backups = new Map();
|
|
138
|
+
const attempted = [];
|
|
139
|
+
let preparedRoot = null;
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
const downloaded = await downloadTarball({
|
|
143
|
+
source: canonicalSource,
|
|
144
|
+
ref: pin,
|
|
145
|
+
token: authToken,
|
|
146
|
+
dest: tarball,
|
|
147
|
+
fetchImpl,
|
|
148
|
+
});
|
|
149
|
+
const extracted = await extractTarball({ tarball, dest: staging });
|
|
150
|
+
const discovered = discoverSkills({ dir: extracted.dir, subpath, repoName: parsed.repo });
|
|
151
|
+
if (discovered.length === 0) {
|
|
152
|
+
throw new InstallerError('no-skills', `${canonicalSource} 里没有找到 SKILL.md`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const available = new Map(discovered.map((skill) => [skill.name, skill]));
|
|
156
|
+
const selected =
|
|
157
|
+
requested.length > 0
|
|
158
|
+
? requested.map((name) => {
|
|
159
|
+
const skill = available.get(name);
|
|
160
|
+
if (!skill) {
|
|
161
|
+
throw new InstallerError('skill-not-found', `${canonicalSource} 里找不到技能 ${name}`, {
|
|
162
|
+
name,
|
|
163
|
+
available: [...available.keys()],
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
return skill;
|
|
167
|
+
})
|
|
168
|
+
: discovered;
|
|
169
|
+
|
|
170
|
+
const planned = selected.map((skill) => ({
|
|
171
|
+
...skill,
|
|
172
|
+
treeSha: gitTreeSha(skill.dirPath),
|
|
173
|
+
contentHash: contentHash(skill.dirPath),
|
|
174
|
+
}));
|
|
175
|
+
|
|
176
|
+
const warnings = [];
|
|
177
|
+
const expected = new Map((expectedEntries || []).map((entry) => [entry.name, entry]));
|
|
178
|
+
const mismatched = [];
|
|
179
|
+
for (const item of planned) {
|
|
180
|
+
const want = expected.get(item.name);
|
|
181
|
+
if (want?.treeSha && want.treeSha !== item.treeSha) {
|
|
182
|
+
const detail = {
|
|
183
|
+
name: item.name,
|
|
184
|
+
warning: '本地 tree SHA 与快照不一致(可能下载到了别的 commit)',
|
|
185
|
+
expected: want.treeSha,
|
|
186
|
+
actual: item.treeSha,
|
|
187
|
+
};
|
|
188
|
+
mismatched.push(detail);
|
|
189
|
+
warnings.push(detail);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// 更新要求「下载到的内容」与计划中的目标完全一致;普通安装只警告,不阻断。
|
|
193
|
+
if (strict && mismatched.length > 0) {
|
|
194
|
+
throw new InstallerError('tree-mismatch', `下载内容与目标 tree 不一致: ${mismatched[0].name}`, {
|
|
195
|
+
mismatched,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// 同盘 staging:先复制到 canonical 下的隐藏目录,再 rename 到最终位置。
|
|
200
|
+
// 隐藏目录以 `.` 开头,inventory 扫描会跳过,不会把半成品当成技能。
|
|
201
|
+
fs.mkdirSync(paths.canonical, { recursive: true });
|
|
202
|
+
preparedRoot = path.join(
|
|
203
|
+
paths.canonical,
|
|
204
|
+
`.dshsc-staging-${process.pid}-${Date.now()}-${crypto.randomBytes(3).toString('hex')}`,
|
|
205
|
+
);
|
|
206
|
+
fs.mkdirSync(preparedRoot, { recursive: false });
|
|
207
|
+
for (const item of planned) {
|
|
208
|
+
copyTree(item.dirPath, path.join(preparedRoot, item.name));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
for (const item of planned) {
|
|
212
|
+
const dest = path.join(paths.canonical, item.name);
|
|
213
|
+
if (fs.existsSync(dest)) {
|
|
214
|
+
const stat = fs.lstatSync(dest);
|
|
215
|
+
if (stat.isSymbolicLink()) {
|
|
216
|
+
throw new InstallerError('unsupported-existing', `${dest} 是符号链接,拒绝覆盖`);
|
|
217
|
+
}
|
|
218
|
+
const previousHash = contentHash(dest);
|
|
219
|
+
// 事务期间不 prune:多技能安装时先备份的目录不能被后面的 createBackup 挤掉
|
|
220
|
+
const backup = createBackup({ paths, skill: item.name, fromDir: dest, hash: previousHash, prune: false });
|
|
221
|
+
backups.set(item.name, { key: backup.key, previousHash });
|
|
222
|
+
}
|
|
223
|
+
// 先登记再动盘:任何一步失败都要能从备份恢复(包括 rm 之后、rename 之前)
|
|
224
|
+
attempted.push({ item, dest, backup: backups.get(item.name) || null });
|
|
225
|
+
fs.rmSync(dest, { recursive: true, force: true });
|
|
226
|
+
fs.renameSync(path.join(preparedRoot, item.name), dest);
|
|
227
|
+
const promotedHash = contentHash(dest);
|
|
228
|
+
if (promotedHash !== item.contentHash) {
|
|
229
|
+
throw new InstallerError('verify-failed', `${item.name} 落盘后内容哈希不一致`, {
|
|
230
|
+
expected: item.contentHash,
|
|
231
|
+
actual: promotedHash,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const now = new Date();
|
|
237
|
+
const updatedAt = now.toISOString();
|
|
238
|
+
const entries = new Map();
|
|
239
|
+
for (const item of planned) {
|
|
240
|
+
const existing = lockBefore.data?.skills?.[item.name] || null;
|
|
241
|
+
entries.set(
|
|
242
|
+
item.name,
|
|
243
|
+
makeLockEntry({
|
|
244
|
+
source: canonicalSource,
|
|
245
|
+
ref: pin,
|
|
246
|
+
skillPath: item.skillPath,
|
|
247
|
+
treeSha: item.treeSha,
|
|
248
|
+
installedAt: validIsoOrUndefined(existing?.installedAt),
|
|
249
|
+
updatedAt,
|
|
250
|
+
now,
|
|
251
|
+
}),
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
updateLock({
|
|
256
|
+
lockPath: paths.lockFile,
|
|
257
|
+
expectedDigest: lockBefore.digest,
|
|
258
|
+
mutate: (draft) => {
|
|
259
|
+
for (const [name, entry] of entries) {
|
|
260
|
+
const previous = draft.skills[name];
|
|
261
|
+
// 已知字段以新条目为准,旧条目里的未知字段保留(旧 CLI/未来 CLI 加的字段不丢)
|
|
262
|
+
draft.skills[name] = previous && typeof previous === 'object' ? { ...previous, ...entry } : entry;
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
try {
|
|
268
|
+
pruneBackups({ paths });
|
|
269
|
+
} catch {
|
|
270
|
+
// 备份清理是维护动作,失败不影响安装结果
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const installed = planned.map((item) => {
|
|
274
|
+
const backup = backups.get(item.name) || null;
|
|
275
|
+
const previousLockHash = lockBefore.data?.skills?.[item.name]?.skillFolderHash || null;
|
|
276
|
+
return {
|
|
277
|
+
name: item.name,
|
|
278
|
+
skillPath: item.skillPath,
|
|
279
|
+
treeSha: item.treeSha,
|
|
280
|
+
contentHash: item.contentHash,
|
|
281
|
+
backup: backup?.key || null,
|
|
282
|
+
backupHash: backup?.previousHash || null,
|
|
283
|
+
backupTreeSha:
|
|
284
|
+
typeof previousLockHash === 'string' && /^[0-9a-f]{40}$/.test(previousLockHash)
|
|
285
|
+
? previousLockHash
|
|
286
|
+
: null,
|
|
287
|
+
};
|
|
288
|
+
});
|
|
289
|
+
logger('installer.install', {
|
|
290
|
+
source: canonicalSource,
|
|
291
|
+
ref: pin,
|
|
292
|
+
skills: installed.map((item) => item.name),
|
|
293
|
+
bytes: downloaded.bytes,
|
|
294
|
+
warnings: warnings.length,
|
|
295
|
+
});
|
|
296
|
+
return { installed, warnings, root: extracted.root };
|
|
297
|
+
} catch (error) {
|
|
298
|
+
for (const entry of [...attempted].reverse()) {
|
|
299
|
+
fs.rmSync(entry.dest, { recursive: true, force: true });
|
|
300
|
+
if (entry.backup) {
|
|
301
|
+
const restored = readBackup({ paths, key: entry.backup.key });
|
|
302
|
+
if (restored) restoreBackup({ backup: restored, toDir: entry.dest });
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
throw normalizeError(error);
|
|
306
|
+
} finally {
|
|
307
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
308
|
+
if (preparedRoot) fs.rmSync(preparedRoot, { recursive: true, force: true });
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** 删除 canonical 目录与 lock 条目;lock 写失败时不删目录,避免半状态。 */
|
|
313
|
+
function remove({ name } = {}) {
|
|
314
|
+
assertSafeSkillName(name);
|
|
315
|
+
const lockBefore = readLockFile(paths.lockFile);
|
|
316
|
+
const entry = lockBefore.data?.skills?.[name] || null;
|
|
317
|
+
const canonicalPath = path.join(paths.canonical, name);
|
|
318
|
+
|
|
319
|
+
updateLock({
|
|
320
|
+
lockPath: paths.lockFile,
|
|
321
|
+
expectedDigest: lockBefore.digest,
|
|
322
|
+
mutate: (draft) => {
|
|
323
|
+
delete draft.skills[name];
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
try {
|
|
328
|
+
fs.rmSync(canonicalPath, { recursive: true, force: true });
|
|
329
|
+
} catch (error) {
|
|
330
|
+
// 目录删不掉就把 lock 条目放回去(尽量恢复,恢复不了也如实报错)
|
|
331
|
+
if (entry) {
|
|
332
|
+
try {
|
|
333
|
+
updateLock({
|
|
334
|
+
lockPath: paths.lockFile,
|
|
335
|
+
mutate: (draft) => {
|
|
336
|
+
draft.skills[name] = entry;
|
|
337
|
+
},
|
|
338
|
+
});
|
|
339
|
+
} catch {
|
|
340
|
+
// 恢复失败只能让调用方看到原始错误;lock 里最多少一条,不会指错目录
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
throw new InstallerError('remove-failed', `删除 ${name} 失败: ${error.message}`, { cause: error });
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
logger('installer.remove', { name, hadLockEntry: Boolean(entry) });
|
|
347
|
+
return { removed: Boolean(entry), treeSha: entry?.skillFolderHash || null };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return { readiness, install, remove };
|
|
351
|
+
}
|
package/lib/core/lock.js
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* v3 skill lock(`~/.agents/.skill-lock.json`,或 XDG_STATE_HOME 下)的**唯一写入者**。
|
|
7
|
+
*
|
|
8
|
+
* 兼容目标(与 `skills` CLI 落盘格式一致):
|
|
9
|
+
* - `version` 必须恰好是 3;>3 视为不认识的新版本,只读并抛 `lock-unsupported`,绝不覆盖。
|
|
10
|
+
* - JSON 损坏同样抛错;宁可写失败,也不拿空账覆盖用户的 lock。
|
|
11
|
+
* - 顶层未知字段、每个技能条目的未知字段、`dismissed` 全部原样保留,只动调用方明确要动的键。
|
|
12
|
+
* - 写入:同目录临时文件 → fsync → rename;写前留 `.bak`;rename 失败时原文件不会被截断。
|
|
13
|
+
* - `expectedDigest` 用来防止覆盖外部 CLI 刚写入的内容:写前重算,不一致就报冲突。
|
|
14
|
+
*
|
|
15
|
+
* 语义边界:只负责文件一致性,不判断技能是否该装、不碰 canonical;事务边界由 installer 负责。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export const LOCK_VERSION = 3;
|
|
19
|
+
|
|
20
|
+
const SOURCE_TYPES = new Set(['github', 'local', 'download']);
|
|
21
|
+
const TREE_SHA_RE = /^[0-9a-f]{40}$/;
|
|
22
|
+
const CONTENT_SHA_RE = /^[0-9a-f]{64}$/;
|
|
23
|
+
|
|
24
|
+
export class LockError extends Error {
|
|
25
|
+
constructor(code, message, extra = {}) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.name = 'LockError';
|
|
28
|
+
this.code = code;
|
|
29
|
+
Object.assign(this, extra);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isPlainObject(value) {
|
|
34
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function lockDigest(text) {
|
|
38
|
+
return crypto.createHash('sha256').update(text).digest('hex');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 读 lock 用于写。返回 `{ present, raw, digest, data }`;
|
|
43
|
+
* 文件不存在返回 `present:false`(调用方创建空账);其余异常一律抛 LockError。
|
|
44
|
+
*/
|
|
45
|
+
export function readLockFile(lockPath) {
|
|
46
|
+
if (typeof lockPath !== 'string' || lockPath.length === 0) {
|
|
47
|
+
throw new LockError('lock-invalid', 'readLockFile 需要 lock 路径');
|
|
48
|
+
}
|
|
49
|
+
let raw;
|
|
50
|
+
try {
|
|
51
|
+
raw = fs.readFileSync(lockPath, 'utf8');
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (error && error.code === 'ENOENT') {
|
|
54
|
+
return { present: false, raw: null, digest: null, data: null };
|
|
55
|
+
}
|
|
56
|
+
throw new LockError('lock-unreadable', `无法读取 lock: ${error.message}`, { cause: error });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let data;
|
|
60
|
+
try {
|
|
61
|
+
data = JSON.parse(raw);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
throw new LockError('lock-corrupt', `lock 不是合法 JSON: ${error.message}`, { cause: error });
|
|
64
|
+
}
|
|
65
|
+
if (!isPlainObject(data)) throw new LockError('lock-corrupt', 'lock 顶层不是对象');
|
|
66
|
+
if (typeof data.version !== 'number' || !Number.isInteger(data.version)) {
|
|
67
|
+
throw new LockError('lock-corrupt', 'lock.version 缺失或不是整数');
|
|
68
|
+
}
|
|
69
|
+
if (data.version !== LOCK_VERSION) {
|
|
70
|
+
throw new LockError(
|
|
71
|
+
'lock-unsupported',
|
|
72
|
+
`lock.version ${data.version} 不是可写的 v${LOCK_VERSION},拒绝覆盖`,
|
|
73
|
+
{ version: data.version },
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
if (!isPlainObject(data.skills)) throw new LockError('lock-corrupt', 'lock.skills 缺失或不是对象');
|
|
77
|
+
if (data.dismissed !== undefined && !isPlainObject(data.dismissed)) {
|
|
78
|
+
throw new LockError('lock-corrupt', 'lock.dismissed 不是对象');
|
|
79
|
+
}
|
|
80
|
+
return { present: true, raw, digest: lockDigest(raw), data };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function emptyLock() {
|
|
84
|
+
return { version: LOCK_VERSION, skills: {}, dismissed: {} };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function assertLockShape(data) {
|
|
88
|
+
if (!isPlainObject(data)) throw new LockError('lock-invalid', '写回的 lock 不是对象');
|
|
89
|
+
if (data.version !== LOCK_VERSION) {
|
|
90
|
+
throw new LockError('lock-invalid', `写回的 lock.version 必须是 ${LOCK_VERSION}`);
|
|
91
|
+
}
|
|
92
|
+
if (!isPlainObject(data.skills)) throw new LockError('lock-invalid', '写回的 lock.skills 不是对象');
|
|
93
|
+
if (data.dismissed !== undefined && !isPlainObject(data.dismissed)) {
|
|
94
|
+
throw new LockError('lock-invalid', '写回的 lock.dismissed 不是对象');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function atomicWrite(target, text) {
|
|
99
|
+
const dir = path.dirname(target);
|
|
100
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
101
|
+
const tmp = path.join(
|
|
102
|
+
dir,
|
|
103
|
+
`.${path.basename(target)}.${process.pid}.${Date.now()}.${crypto.randomBytes(4).toString('hex')}.tmp`,
|
|
104
|
+
);
|
|
105
|
+
let fd = null;
|
|
106
|
+
try {
|
|
107
|
+
fd = fs.openSync(tmp, 'wx', 0o644);
|
|
108
|
+
fs.writeFileSync(fd, text, 'utf8');
|
|
109
|
+
fs.fsyncSync(fd);
|
|
110
|
+
fs.closeSync(fd);
|
|
111
|
+
fd = null;
|
|
112
|
+
fs.renameSync(tmp, target);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (fd !== null) {
|
|
115
|
+
try {
|
|
116
|
+
fs.closeSync(fd);
|
|
117
|
+
} catch {
|
|
118
|
+
// 已经关掉就算了,别覆盖原始错误
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
fs.rmSync(tmp, { force: true });
|
|
122
|
+
throw new LockError('lock-write-failed', `写 lock 失败: ${error.message}`, { cause: error });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 读改写 lock。`mutate(draft)` 直接改 draft(同步);返回 `{ data, digest, changed, backup }`。
|
|
128
|
+
*
|
|
129
|
+
* - `expectedDigest` 传 `null` 表示“写之前必须不存在”;传字符串表示必须等于当前内容摘要;
|
|
130
|
+
* 不传则不校验。
|
|
131
|
+
* - 内容没变化时不落盘(不产生 `.bak`、不更新时间戳)。
|
|
132
|
+
*/
|
|
133
|
+
export function updateLock({ lockPath, mutate, expectedDigest } = {}) {
|
|
134
|
+
if (typeof mutate !== 'function') {
|
|
135
|
+
throw new LockError('lock-invalid', 'updateLock 需要 mutate 函数');
|
|
136
|
+
}
|
|
137
|
+
const current = readLockFile(lockPath);
|
|
138
|
+
if (expectedDigest !== undefined && current.digest !== expectedDigest) {
|
|
139
|
+
throw new LockError('lock-conflict', 'lock 在本次操作期间被其他进程修改,拒绝覆盖', {
|
|
140
|
+
expectedDigest,
|
|
141
|
+
actualDigest: current.digest,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const draft = current.present ? structuredClone(current.data) : emptyLock();
|
|
146
|
+
const result = mutate(draft);
|
|
147
|
+
const next = result === undefined ? draft : result;
|
|
148
|
+
assertLockShape(next);
|
|
149
|
+
|
|
150
|
+
const text = `${JSON.stringify(next, null, 2)}\n`;
|
|
151
|
+
if (current.present && text === current.raw) {
|
|
152
|
+
return { data: next, digest: current.digest, changed: false, backup: null };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
let backup = null;
|
|
156
|
+
if (current.present) {
|
|
157
|
+
backup = `${lockPath}.bak`;
|
|
158
|
+
try {
|
|
159
|
+
fs.writeFileSync(backup, current.raw, { encoding: 'utf8', mode: 0o644 });
|
|
160
|
+
} catch (error) {
|
|
161
|
+
throw new LockError('lock-write-failed', `备份 lock 失败: ${error.message}`, { cause: error });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
atomicWrite(lockPath, text);
|
|
165
|
+
return { data: next, digest: lockDigest(text), changed: true, backup };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const KNOWN_ENTRY_KEYS = new Set([
|
|
169
|
+
'source',
|
|
170
|
+
'sourceType',
|
|
171
|
+
'sourceUrl',
|
|
172
|
+
'ref',
|
|
173
|
+
'skillPath',
|
|
174
|
+
'skillFolderHash',
|
|
175
|
+
'installedAt',
|
|
176
|
+
'updatedAt',
|
|
177
|
+
]);
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* 新旧条目合并:已知字段以新条目为准(新条目没有 `ref` 就删掉旧的),
|
|
181
|
+
* 未知字段原样保留 —— 与 CLI 的“不重建整个对象”语义一致,未来 CLI 加字段不会被我们抹掉。
|
|
182
|
+
*/
|
|
183
|
+
function mergeLockEntry(previous, entry) {
|
|
184
|
+
if (!isPlainObject(previous)) return entry;
|
|
185
|
+
const merged = {};
|
|
186
|
+
for (const [key, value] of Object.entries(previous)) {
|
|
187
|
+
if (!KNOWN_ENTRY_KEYS.has(key)) merged[key] = value;
|
|
188
|
+
}
|
|
189
|
+
return { ...merged, ...entry };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** 安装/更新后写入一个技能条目;`entry` 由 makeLockEntry 或调用方按 CLI 形状构造。 */
|
|
193
|
+
export function upsertSkill({ lockPath, name, entry, expectedDigest } = {}) {
|
|
194
|
+
assertSkillName(name);
|
|
195
|
+
assertLockEntry(name, entry);
|
|
196
|
+
return updateLock({
|
|
197
|
+
lockPath,
|
|
198
|
+
expectedDigest,
|
|
199
|
+
mutate: (draft) => {
|
|
200
|
+
draft.skills[name] = mergeLockEntry(draft.skills[name], entry);
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** 删除一个技能条目;条目本就不存在时视为成功(changed:false)。 */
|
|
206
|
+
export function removeSkill({ lockPath, name, expectedDigest } = {}) {
|
|
207
|
+
assertSkillName(name);
|
|
208
|
+
return updateLock({
|
|
209
|
+
lockPath,
|
|
210
|
+
expectedDigest,
|
|
211
|
+
mutate: (draft) => {
|
|
212
|
+
delete draft.skills[name];
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function assertSkillName(name) {
|
|
218
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
219
|
+
throw new LockError('lock-invalid', '技能名必须是非空字符串');
|
|
220
|
+
}
|
|
221
|
+
if (name.includes('/') || name.includes('\\') || name.includes('..')) {
|
|
222
|
+
throw new LockError('lock-invalid', `技能名不能含路径分隔符: ${name}`);
|
|
223
|
+
}
|
|
224
|
+
// eslint-disable-next-line no-control-regex
|
|
225
|
+
if (/[\u0000-\u001f\u007f]/.test(name)) {
|
|
226
|
+
throw new LockError('lock-invalid', `技能名不能含控制字符: ${JSON.stringify(name)}`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function isIsoDate(value) {
|
|
231
|
+
if (typeof value !== 'string' || value.length === 0) return false;
|
|
232
|
+
const time = Date.parse(value);
|
|
233
|
+
return Number.isFinite(time);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* 校验一条 lock 条目。允许 40 位 tree SHA 或旧数据的 64 位内容 hash,
|
|
238
|
+
* 但 `makeLockEntry` 只产出 40 位 —— 新安装绝不用内容 hash 冒充 tree SHA。
|
|
239
|
+
*/
|
|
240
|
+
export function assertLockEntry(name, entry) {
|
|
241
|
+
const fail = (message) => {
|
|
242
|
+
throw new LockError('lock-invalid', `${name}: ${message}`);
|
|
243
|
+
};
|
|
244
|
+
if (!isPlainObject(entry)) fail('条目不是对象');
|
|
245
|
+
if (typeof entry.source !== 'string' || !/^[\w.-]+\/[\w.-]+$/.test(entry.source)) {
|
|
246
|
+
fail('source 不是 owner/repo');
|
|
247
|
+
}
|
|
248
|
+
if (!SOURCE_TYPES.has(entry.sourceType)) fail(`sourceType 不认识: ${entry.sourceType}`);
|
|
249
|
+
if (typeof entry.sourceUrl !== 'string' || !entry.sourceUrl.startsWith('https://')) {
|
|
250
|
+
fail('sourceUrl 必须是 https URL');
|
|
251
|
+
}
|
|
252
|
+
if (typeof entry.skillPath !== 'string' || entry.skillPath.length === 0) fail('skillPath 缺失');
|
|
253
|
+
if (
|
|
254
|
+
entry.skillPath.startsWith('/') ||
|
|
255
|
+
entry.skillPath.includes('\\') ||
|
|
256
|
+
entry.skillPath.split('/').some((segment) => segment === '' || segment === '.' || segment === '..')
|
|
257
|
+
) {
|
|
258
|
+
fail(`skillPath 不是安全的 POSIX 相对路径: ${entry.skillPath}`);
|
|
259
|
+
}
|
|
260
|
+
if (!entry.skillPath.endsWith('.md')) fail('skillPath 应指向 .md 文件');
|
|
261
|
+
if (typeof entry.skillFolderHash !== 'string') fail('skillFolderHash 缺失');
|
|
262
|
+
if (!TREE_SHA_RE.test(entry.skillFolderHash) && !CONTENT_SHA_RE.test(entry.skillFolderHash)) {
|
|
263
|
+
fail('skillFolderHash 既不是 40 位 tree SHA 也不是 64 位内容 hash');
|
|
264
|
+
}
|
|
265
|
+
if (!isIsoDate(entry.installedAt)) fail('installedAt 不是 ISO 时间');
|
|
266
|
+
if (!isIsoDate(entry.updatedAt)) fail('updatedAt 不是 ISO 时间');
|
|
267
|
+
if (entry.ref !== undefined && entry.ref !== null) {
|
|
268
|
+
if (typeof entry.ref !== 'string' || entry.ref.length === 0 || entry.ref.length > 200) {
|
|
269
|
+
fail('ref 不是合法字符串');
|
|
270
|
+
}
|
|
271
|
+
if (!/^[A-Za-z0-9._/-]+$/.test(entry.ref) || entry.ref.includes('..')) fail(`ref 含非法字符: ${entry.ref}`);
|
|
272
|
+
}
|
|
273
|
+
return entry;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* 按 CLI 落盘形状构造条目(键顺序照 skills@1.7.4:source / sourceType / sourceUrl /
|
|
278
|
+
* [ref] / skillPath / skillFolderHash / installedAt / updatedAt)。
|
|
279
|
+
* `ref` 为空时直接省略(CLI 对默认分支安装就是这么写的)。
|
|
280
|
+
*/
|
|
281
|
+
export function makeLockEntry({
|
|
282
|
+
source,
|
|
283
|
+
ref = null,
|
|
284
|
+
skillPath,
|
|
285
|
+
treeSha,
|
|
286
|
+
installedAt,
|
|
287
|
+
updatedAt,
|
|
288
|
+
now = new Date(),
|
|
289
|
+
} = {}) {
|
|
290
|
+
const normalized = typeof source === 'string' ? source.trim().replace(/\.git$/, '') : source;
|
|
291
|
+
if (typeof normalized !== 'string' || !/^[\w.-]+\/[\w.-]+$/.test(normalized)) {
|
|
292
|
+
throw new LockError('lock-invalid', `source 必须是 owner/repo: ${source}`);
|
|
293
|
+
}
|
|
294
|
+
if (typeof treeSha !== 'string' || !TREE_SHA_RE.test(treeSha)) {
|
|
295
|
+
throw new LockError('lock-invalid', `treeSha 必须是 40 位小写 hex: ${treeSha}`);
|
|
296
|
+
}
|
|
297
|
+
const stamp = now instanceof Date ? now.toISOString() : new Date(now).toISOString();
|
|
298
|
+
const entry = {
|
|
299
|
+
source: normalized,
|
|
300
|
+
sourceType: 'github',
|
|
301
|
+
sourceUrl: `https://github.com/${normalized}.git`,
|
|
302
|
+
};
|
|
303
|
+
if (ref !== null && ref !== undefined) entry.ref = ref;
|
|
304
|
+
entry.skillPath = skillPath;
|
|
305
|
+
entry.skillFolderHash = treeSha;
|
|
306
|
+
entry.installedAt = installedAt === undefined ? stamp : installedAt;
|
|
307
|
+
entry.updatedAt = updatedAt === undefined ? stamp : updatedAt;
|
|
308
|
+
assertLockEntry(source, entry);
|
|
309
|
+
return entry;
|
|
310
|
+
}
|