@yottameta/yotta-skills 0.7.2 → 0.19.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/CHANGELOG.md +108 -1
- package/README.md +29 -9
- package/README.zh-CN.md +33 -12
- package/SKILL.md +86 -12
- package/bin/yotta-skills.js +681 -90
- package/lib/hook-adapter.js +385 -0
- package/lib/install-evidence.js +18 -0
- package/lib/install-health.js +151 -0
- package/lib/install-lifecycle.js +149 -0
- package/lib/install-pipeline.js +387 -0
- package/lib/install-snapshot.js +245 -0
- package/lib/manifest.js +136 -0
- package/lib/update-check.js +99 -0
- package/lib/verify-gate.js +81 -0
- package/package.json +2 -1
- package/references/faq.md +26 -3
- package/references/install-flow.md +95 -7
- package/references/skill-list.md +10 -10
- package/references/tutorial.md +40 -6
- package/references/walkthroughs.md +11 -3
- package/scripts/yotta-skills-mcp.py +1 -1
- package/skill-manifest.json +34 -0
- package/skills.json +10 -10
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const manifestLib = require('./manifest');
|
|
6
|
+
const gateLib = require('./verify-gate');
|
|
7
|
+
const lifecycleLib = require('./install-lifecycle');
|
|
8
|
+
const healthLib = require('./install-health');
|
|
9
|
+
const snapshotLib = require('./install-snapshot');
|
|
10
|
+
const hookAdapterLib = require('./hook-adapter');
|
|
11
|
+
|
|
12
|
+
function nowTag() {
|
|
13
|
+
return new Date().toISOString().replace(/[:.]/g, '-');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function sleepSync(ms) {
|
|
17
|
+
const array = new Int32Array(new SharedArrayBuffer(4));
|
|
18
|
+
Atomics.wait(array, 0, 0, ms);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function renameWithRetry(from, to, options) {
|
|
22
|
+
const rename = (options && options.rename) || fs.renameSync;
|
|
23
|
+
const sleep = (options && options.sleep) || sleepSync;
|
|
24
|
+
let lastError;
|
|
25
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
26
|
+
try {
|
|
27
|
+
return rename(from, to);
|
|
28
|
+
} catch (error) {
|
|
29
|
+
lastError = error;
|
|
30
|
+
if (!['EPERM', 'EACCES', 'EBUSY'].includes(error.code) || attempt === 4) throw error;
|
|
31
|
+
sleep(100 * (attempt + 1));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
throw lastError;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isSafeTarEntry(entry) {
|
|
38
|
+
const value = String(entry || '').replace(/\\/g, '/');
|
|
39
|
+
if (value !== 'package' && !value.startsWith('package/')) return false;
|
|
40
|
+
if (value.startsWith('/') || /^[A-Za-z]:\//.test(value)) return false;
|
|
41
|
+
return !value.split('/').includes('..');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function copyTree(src, dst, copyDir) {
|
|
45
|
+
fs.rmSync(dst, { recursive: true, force: true });
|
|
46
|
+
fs.mkdirSync(dst, { recursive: true });
|
|
47
|
+
copyDir(src, dst);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function restoreBackup(target, backup, hadTarget) {
|
|
51
|
+
if (backup && fs.existsSync(backup)) {
|
|
52
|
+
if (fs.existsSync(target)) fs.rmSync(target, { recursive: true, force: true });
|
|
53
|
+
renameWithRetry(backup, target);
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
if (!hadTarget && fs.existsSync(target)) {
|
|
57
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function createInstaller(deps) {
|
|
64
|
+
const homeDir = deps.homeDir || os.homedir();
|
|
65
|
+
const runPhase = deps.runPhase || lifecycleLib.runPhase;
|
|
66
|
+
const checkInstalledSkill = deps.checkInstalledSkill || healthLib.checkInstalledSkill;
|
|
67
|
+
const captureSnapshot = deps.createSnapshot || snapshotLib.createSnapshot;
|
|
68
|
+
const evaluateHook = deps.evaluateHook || hookAdapterLib.evaluateHook;
|
|
69
|
+
const appendHookEvidence = deps.appendHookEvidence || hookAdapterLib.appendHookEvidence;
|
|
70
|
+
|
|
71
|
+
function record(entry) {
|
|
72
|
+
return deps.appendEvidence(entry, { homeDir });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function safeRecord(entry) {
|
|
76
|
+
try {
|
|
77
|
+
return record(entry);
|
|
78
|
+
} catch (_) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return function installOne(skill, dest, opts) {
|
|
84
|
+
const target = path.join(dest, skill.slug);
|
|
85
|
+
const existingVersion = deps.readInstalledVersion(target);
|
|
86
|
+
if (!opts.force && existingVersion === skill.version) {
|
|
87
|
+
return { skill, status: 'skip', version: existingVersion, note: '已是最新', exitCode: 0 };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let tmp = null;
|
|
91
|
+
let staged = null;
|
|
92
|
+
let snapshot = null;
|
|
93
|
+
let backup = null;
|
|
94
|
+
const hadTarget = fs.existsSync(target);
|
|
95
|
+
let manifest = null;
|
|
96
|
+
let extracted = null;
|
|
97
|
+
let lifecycle = { setup: null, builtin_doctor: null, doctor: null, rollback: null };
|
|
98
|
+
try {
|
|
99
|
+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'yotta-skills-'));
|
|
100
|
+
const packDir = path.join(tmp, 'pack');
|
|
101
|
+
fs.mkdirSync(packDir, { recursive: true });
|
|
102
|
+
const packed = deps.runNpmPack(skill, opts, packDir);
|
|
103
|
+
if (packed.error) {
|
|
104
|
+
safeRecord({ event: 'before_install', skill: skill.slug, decision: 'fail', error: packed.error });
|
|
105
|
+
return { skill, status: 'fail', version: null, note: packed.error, exitCode: 1 };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const extractDir = path.join(tmp, 'extract');
|
|
109
|
+
fs.mkdirSync(extractDir, { recursive: true });
|
|
110
|
+
extracted = deps.extractTarball(packed.tarball, extractDir);
|
|
111
|
+
if (extracted.error) {
|
|
112
|
+
safeRecord({ event: 'before_install', skill: skill.slug, decision: 'fail', error: extracted.error });
|
|
113
|
+
return { skill, status: 'fail', version: packed.resolved, note: extracted.error, exitCode: 1 };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const resolvedSkill = { ...skill, version: packed.resolved || skill.version };
|
|
117
|
+
const loaded = manifestLib.loadManifest({ pkgDir: extracted.pkgDir, skill: resolvedSkill });
|
|
118
|
+
if (loaded.errors.length > 0) {
|
|
119
|
+
const note = loaded.errors.join('; ');
|
|
120
|
+
safeRecord({ event: 'manifest', skill: skill.slug, decision: 'block', errors: loaded.errors });
|
|
121
|
+
return { skill, status: 'fail', version: packed.resolved, note, exitCode: 6 };
|
|
122
|
+
}
|
|
123
|
+
manifest = loaded.manifest;
|
|
124
|
+
if (manifest.trust !== 'yottameta') {
|
|
125
|
+
safeRecord({
|
|
126
|
+
event: 'manifest',
|
|
127
|
+
skill: skill.slug,
|
|
128
|
+
decision: 'block',
|
|
129
|
+
errors: ['trust 必须为 yottameta'],
|
|
130
|
+
});
|
|
131
|
+
return {
|
|
132
|
+
skill,
|
|
133
|
+
status: 'fail',
|
|
134
|
+
version: packed.resolved,
|
|
135
|
+
note: 'manifest trust 必须为 yottameta',
|
|
136
|
+
exitCode: 6,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let gate;
|
|
141
|
+
let scan;
|
|
142
|
+
if (opts.skipScan) {
|
|
143
|
+
gate = { ok: true, engine: null, mode: 'explicit-unverified' };
|
|
144
|
+
scan = { decision: 'unverified', verdict: null, counts: null, warn: true, mode: 'explicit-unverified' };
|
|
145
|
+
} else {
|
|
146
|
+
gate = deps.ensureGate({ skill, extracted, dest, opts });
|
|
147
|
+
if (!gate.ok) {
|
|
148
|
+
safeRecord({ event: 'before_install', skill: skill.slug, decision: 'block', error: gate.error });
|
|
149
|
+
return { skill, status: 'fail', version: packed.resolved, note: gate.error, exitCode: 5 };
|
|
150
|
+
}
|
|
151
|
+
const scanResult = deps.scanTarget(gate.engine, extracted.pkgDir);
|
|
152
|
+
if (!scanResult.ok) {
|
|
153
|
+
safeRecord({ event: 'before_install', skill: skill.slug, decision: 'block', error: scanResult.error });
|
|
154
|
+
return { skill, status: 'fail', version: packed.resolved, note: scanResult.error, exitCode: 5 };
|
|
155
|
+
}
|
|
156
|
+
const verdict = gateLib.evaluateVerdict(scanResult.verdict);
|
|
157
|
+
let hookEvaluation = { decision: 'allow', evidence: [], user_message: '无 hook 声明' };
|
|
158
|
+
try {
|
|
159
|
+
hookEvaluation = evaluateHook({
|
|
160
|
+
host: opts.host || 'codex',
|
|
161
|
+
event: 'before_install',
|
|
162
|
+
manifest,
|
|
163
|
+
capabilities: deps.hookCapabilities || hookAdapterLib.capabilitiesForHost(opts.host || 'codex'),
|
|
164
|
+
context: opts.skipScan
|
|
165
|
+
? { wrapperRegistered: false, checks: {} }
|
|
166
|
+
: {
|
|
167
|
+
wrapperRegistered: true,
|
|
168
|
+
checks: {
|
|
169
|
+
scan_skill: {
|
|
170
|
+
ok: !verdict.block,
|
|
171
|
+
evidence: { audit_log: 'install-log.jsonl' },
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
for (const entry of hookEvaluation.evidence) {
|
|
177
|
+
appendHookEvidence(entry, { homeDir });
|
|
178
|
+
}
|
|
179
|
+
} catch (error) {
|
|
180
|
+
hookEvaluation = {
|
|
181
|
+
decision: 'block',
|
|
182
|
+
evidence: [],
|
|
183
|
+
user_message: 'hook 适配器失败: ' + error.message,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
if (verdict.block || hookEvaluation.decision === 'block') {
|
|
187
|
+
const hookReason = hookEvaluation.decision === 'block'
|
|
188
|
+
? 'before_install hook 阻断: ' + hookEvaluation.user_message
|
|
189
|
+
: '元信 verdict: ' + scanResult.verdict;
|
|
190
|
+
safeRecord({
|
|
191
|
+
event: 'before_install',
|
|
192
|
+
skill: skill.slug,
|
|
193
|
+
decision: 'block',
|
|
194
|
+
verdict: scanResult.verdict,
|
|
195
|
+
counts: scanResult.counts,
|
|
196
|
+
hook_decision: hookEvaluation.decision,
|
|
197
|
+
});
|
|
198
|
+
return {
|
|
199
|
+
skill,
|
|
200
|
+
status: 'fail',
|
|
201
|
+
version: packed.resolved,
|
|
202
|
+
note: hookReason,
|
|
203
|
+
exitCode: 5,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
scan = {
|
|
207
|
+
decision: verdict.decision,
|
|
208
|
+
verdict: scanResult.verdict,
|
|
209
|
+
counts: scanResult.counts,
|
|
210
|
+
warn: verdict.warn,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
215
|
+
const stagingRoot = path.join(dest, '.yottaskills-staging');
|
|
216
|
+
fs.mkdirSync(stagingRoot, { recursive: true });
|
|
217
|
+
staged = fs.mkdtempSync(path.join(stagingRoot, skill.slug + '-'));
|
|
218
|
+
copyTree(extracted.pkgDir, staged, deps.copyDir);
|
|
219
|
+
|
|
220
|
+
if (fs.existsSync(target)) {
|
|
221
|
+
const captured = captureSnapshot(target, {
|
|
222
|
+
homeDir,
|
|
223
|
+
slug: skill.slug,
|
|
224
|
+
version: existingVersion || 'unknown',
|
|
225
|
+
copyDir: deps.copyDir,
|
|
226
|
+
});
|
|
227
|
+
snapshot = captured.path;
|
|
228
|
+
backup = path.join(dest, '.yottaskills-backup-' + skill.slug + '-' + process.pid);
|
|
229
|
+
renameWithRetry(target, backup);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
renameWithRetry(staged, target);
|
|
233
|
+
staged = null;
|
|
234
|
+
|
|
235
|
+
function lifecycleSummary(result) {
|
|
236
|
+
if (!result) return null;
|
|
237
|
+
return {
|
|
238
|
+
ok: !!result.ok,
|
|
239
|
+
skipped: !!result.skipped,
|
|
240
|
+
error: result.error || null,
|
|
241
|
+
result: result.result || null,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function rollbackAfterFailure(reason, phase) {
|
|
246
|
+
let restoreError = null;
|
|
247
|
+
let rollbackResult = { ok: false, skipped: true, error: null, result: null };
|
|
248
|
+
try {
|
|
249
|
+
restoreBackup(target, backup, hadTarget);
|
|
250
|
+
backup = null;
|
|
251
|
+
} catch (error) {
|
|
252
|
+
restoreError = error.message;
|
|
253
|
+
}
|
|
254
|
+
try {
|
|
255
|
+
const rollbackPhase = runPhase(extracted.pkgDir, manifest, 'rollback', {
|
|
256
|
+
skillDir: target,
|
|
257
|
+
packageDir: extracted.pkgDir,
|
|
258
|
+
dest,
|
|
259
|
+
snapshot,
|
|
260
|
+
});
|
|
261
|
+
if (!rollbackPhase.skipped) rollbackResult = rollbackPhase;
|
|
262
|
+
} catch (error) {
|
|
263
|
+
rollbackResult = { ok: false, skipped: false, error: error.message, result: null };
|
|
264
|
+
}
|
|
265
|
+
lifecycle.rollback = lifecycleSummary(rollbackResult);
|
|
266
|
+
const rollbackError = restoreError || (rollbackResult.ok || rollbackResult.skipped ? null : rollbackResult.error);
|
|
267
|
+
safeRecord({
|
|
268
|
+
event: 'rollback',
|
|
269
|
+
skill: skill.slug,
|
|
270
|
+
package: skill.pkg,
|
|
271
|
+
version: packed.resolved || skill.version,
|
|
272
|
+
decision: rollbackError ? 'fail' : 'ok',
|
|
273
|
+
phase,
|
|
274
|
+
reason,
|
|
275
|
+
restore_error: restoreError,
|
|
276
|
+
rollback_error: rollbackResult.ok || rollbackResult.skipped ? null : rollbackResult.error,
|
|
277
|
+
snapshot,
|
|
278
|
+
});
|
|
279
|
+
return {
|
|
280
|
+
skill,
|
|
281
|
+
status: 'fail',
|
|
282
|
+
version: packed.resolved || skill.version,
|
|
283
|
+
note: reason + (rollbackError ? ';回滚失败: ' + rollbackError : ''),
|
|
284
|
+
exitCode: 1,
|
|
285
|
+
snapshot,
|
|
286
|
+
lifecycle,
|
|
287
|
+
rollback_reason: reason,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
lifecycle.setup = runPhase(extracted.pkgDir, manifest, 'setup', {
|
|
292
|
+
skillDir: target,
|
|
293
|
+
packageDir: extracted.pkgDir,
|
|
294
|
+
dest,
|
|
295
|
+
snapshot,
|
|
296
|
+
});
|
|
297
|
+
if (!lifecycle.setup.ok) {
|
|
298
|
+
return rollbackAfterFailure('setup 失败: ' + lifecycle.setup.error, 'setup');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
lifecycle.builtin_doctor = checkInstalledSkill({
|
|
302
|
+
slug: skill.slug,
|
|
303
|
+
target,
|
|
304
|
+
expectedVersion: packed.resolved || skill.version,
|
|
305
|
+
expectedPackage: skill.pkg,
|
|
306
|
+
});
|
|
307
|
+
if (!lifecycle.builtin_doctor.ok) {
|
|
308
|
+
return rollbackAfterFailure(
|
|
309
|
+
'内置 doctor 失败: ' + lifecycle.builtin_doctor.errors.join('; '),
|
|
310
|
+
'builtin-doctor',
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
lifecycle.doctor = runPhase(extracted.pkgDir, manifest, 'doctor', {
|
|
315
|
+
skillDir: target,
|
|
316
|
+
packageDir: extracted.pkgDir,
|
|
317
|
+
dest,
|
|
318
|
+
snapshot,
|
|
319
|
+
});
|
|
320
|
+
if (!lifecycle.doctor.ok) {
|
|
321
|
+
return rollbackAfterFailure('doctor 失败: ' + lifecycle.doctor.error, 'doctor');
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
let evidence;
|
|
325
|
+
try {
|
|
326
|
+
evidence = record({
|
|
327
|
+
event: 'before_install',
|
|
328
|
+
skill: skill.slug,
|
|
329
|
+
package: skill.pkg,
|
|
330
|
+
version: packed.resolved || skill.version,
|
|
331
|
+
gate_mode: gate.mode,
|
|
332
|
+
bootstrap_scan: gate.mode === 'trusted-bootstrap' && skill.slug === 'yotta-verify' ? 'self' : null,
|
|
333
|
+
verdict: scan.verdict,
|
|
334
|
+
decision: scan.decision,
|
|
335
|
+
snapshot,
|
|
336
|
+
});
|
|
337
|
+
} catch (error) {
|
|
338
|
+
return rollbackAfterFailure('证据写入失败: ' + error.message, 'evidence');
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
safeRecord({
|
|
342
|
+
event: 'after_install',
|
|
343
|
+
skill: skill.slug,
|
|
344
|
+
package: skill.pkg,
|
|
345
|
+
version: packed.resolved || skill.version,
|
|
346
|
+
decision: 'allow',
|
|
347
|
+
snapshot,
|
|
348
|
+
lifecycle: {
|
|
349
|
+
setup: lifecycleSummary(lifecycle.setup),
|
|
350
|
+
builtin_doctor: lifecycleSummary(lifecycle.builtin_doctor),
|
|
351
|
+
doctor: lifecycleSummary(lifecycle.doctor),
|
|
352
|
+
},
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
if (backup && fs.existsSync(backup)) {
|
|
356
|
+
try {
|
|
357
|
+
fs.rmSync(backup, { recursive: true, force: true });
|
|
358
|
+
} catch (_) {
|
|
359
|
+
// The new version is already active; a stale backup is safer than a failed install.
|
|
360
|
+
}
|
|
361
|
+
backup = null;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return {
|
|
365
|
+
skill,
|
|
366
|
+
status: 'ok',
|
|
367
|
+
version: packed.resolved || skill.version,
|
|
368
|
+
note: null,
|
|
369
|
+
gate: { ...scan, mode: gate.mode },
|
|
370
|
+
evidence,
|
|
371
|
+
snapshot,
|
|
372
|
+
lifecycle,
|
|
373
|
+
exitCode: 0,
|
|
374
|
+
};
|
|
375
|
+
} catch (error) {
|
|
376
|
+
restoreBackup(target, backup, hadTarget);
|
|
377
|
+
backup = null;
|
|
378
|
+
safeRecord({ event: 'install', skill: skill.slug, decision: 'fail', error: error.message });
|
|
379
|
+
return { skill, status: 'fail', version: null, note: error.message, exitCode: 1 };
|
|
380
|
+
} finally {
|
|
381
|
+
if (tmp) fs.rmSync(tmp, { recursive: true, force: true });
|
|
382
|
+
if (staged) fs.rmSync(staged, { recursive: true, force: true });
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
module.exports = { createInstaller, renameWithRetry, isSafeTarEntry };
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const crypto = require('crypto');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const scanLib = require('./skills-scan');
|
|
7
|
+
|
|
8
|
+
function nowTag() {
|
|
9
|
+
return new Date().toISOString().replace(/[:.]/g, '-');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function snapshotRoot(homeDir, slug) {
|
|
13
|
+
return path.join(homeDir || os.homedir(), '.yottaskills', 'snapshots', slug);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function copyTree(src, dst) {
|
|
17
|
+
fs.rmSync(dst, { recursive: true, force: true });
|
|
18
|
+
fs.mkdirSync(dst, { recursive: true });
|
|
19
|
+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
20
|
+
const from = path.join(src, entry.name);
|
|
21
|
+
const to = path.join(dst, entry.name);
|
|
22
|
+
if (entry.isDirectory()) copyTree(from, to);
|
|
23
|
+
else if (entry.isFile()) fs.copyFileSync(from, to);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function renameWithRetry(from, to, options) {
|
|
28
|
+
const rename = (options && options.rename) || fs.renameSync;
|
|
29
|
+
let lastError;
|
|
30
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
31
|
+
try {
|
|
32
|
+
return rename(from, to);
|
|
33
|
+
} catch (error) {
|
|
34
|
+
lastError = error;
|
|
35
|
+
if (!['EPERM', 'EACCES', 'EBUSY'].includes(error.code) || attempt === 4) throw error;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
throw lastError;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function walkFiles(root, current, output) {
|
|
42
|
+
const dir = current || root;
|
|
43
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
44
|
+
const full = path.join(dir, entry.name);
|
|
45
|
+
if (entry.isDirectory()) walkFiles(root, full, output);
|
|
46
|
+
else if (entry.isFile()) output.push(full);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function computeTreeDigest(root) {
|
|
51
|
+
const files = [];
|
|
52
|
+
walkFiles(path.resolve(root), null, files);
|
|
53
|
+
files.sort((a, b) => path.relative(root, a).localeCompare(path.relative(root, b)));
|
|
54
|
+
const hash = crypto.createHash('sha256');
|
|
55
|
+
for (const file of files) {
|
|
56
|
+
const relative = path.relative(root, file).replace(/\\/g, '/');
|
|
57
|
+
hash.update(relative, 'utf8');
|
|
58
|
+
hash.update('\0', 'utf8');
|
|
59
|
+
hash.update(fs.readFileSync(file));
|
|
60
|
+
hash.update('\0', 'utf8');
|
|
61
|
+
}
|
|
62
|
+
return { digest: hash.digest('hex'), files };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function writeJsonAtomic(file, value) {
|
|
66
|
+
const tmp = file + '.tmp-' + process.pid;
|
|
67
|
+
fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n', 'utf8');
|
|
68
|
+
fs.renameSync(tmp, file);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function readVersion(root) {
|
|
72
|
+
try {
|
|
73
|
+
const fm = scanLib.parseFrontmatter(fs.readFileSync(path.join(root, 'SKILL.md'), 'utf8'));
|
|
74
|
+
return fm && fm.version ? String(fm.version).trim() : null;
|
|
75
|
+
} catch (_) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function validateSnapshot(snapshot) {
|
|
81
|
+
const root = path.resolve(snapshot || '');
|
|
82
|
+
if (!snapshot || !fs.existsSync(root)) {
|
|
83
|
+
return { ok: false, version: null, digest: null, legacy: false, reason: '快照目录不存在' };
|
|
84
|
+
}
|
|
85
|
+
if (!fs.statSync(root).isDirectory()) {
|
|
86
|
+
return { ok: false, version: null, digest: null, legacy: false, reason: '快照不是目录' };
|
|
87
|
+
}
|
|
88
|
+
const metaFile = root + '.meta.json';
|
|
89
|
+
if (fs.existsSync(metaFile)) {
|
|
90
|
+
let metadata;
|
|
91
|
+
try {
|
|
92
|
+
metadata = JSON.parse(fs.readFileSync(metaFile, 'utf8'));
|
|
93
|
+
} catch (error) {
|
|
94
|
+
return { ok: false, version: null, digest: null, legacy: false, reason: '快照元数据无法解析: ' + error.message };
|
|
95
|
+
}
|
|
96
|
+
let current;
|
|
97
|
+
try {
|
|
98
|
+
current = computeTreeDigest(root);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
return { ok: false, version: metadata.version || null, digest: metadata.digest || null, legacy: false, reason: '快照不可读: ' + error.message };
|
|
101
|
+
}
|
|
102
|
+
if (!metadata.digest || current.digest !== metadata.digest) {
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
version: metadata.version || readVersion(root),
|
|
106
|
+
digest: metadata.digest || null,
|
|
107
|
+
legacy: false,
|
|
108
|
+
reason: '快照摘要不一致,可能已被修改',
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
ok: true,
|
|
113
|
+
version: metadata.version || readVersion(root),
|
|
114
|
+
digest: metadata.digest,
|
|
115
|
+
legacy: false,
|
|
116
|
+
reason: null,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const version = readVersion(root);
|
|
121
|
+
if (!fs.existsSync(path.join(root, 'SKILL.md'))) {
|
|
122
|
+
return { ok: false, version, digest: null, legacy: true, reason: '旧快照缺少 SKILL.md' };
|
|
123
|
+
}
|
|
124
|
+
return { ok: true, version, digest: null, legacy: true, reason: null };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function createSnapshot(target, options) {
|
|
128
|
+
const opts = options || {};
|
|
129
|
+
if (!target || !fs.existsSync(target) || !fs.statSync(target).isDirectory()) {
|
|
130
|
+
throw new Error('无法创建快照:目标目录不存在');
|
|
131
|
+
}
|
|
132
|
+
const slug = opts.slug || path.basename(target);
|
|
133
|
+
const root = snapshotRoot(opts.homeDir, slug);
|
|
134
|
+
fs.mkdirSync(root, { recursive: true });
|
|
135
|
+
const version = opts.version || readVersion(target) || 'unknown';
|
|
136
|
+
const snapshot = fs.mkdtempSync(path.join(root, nowTag() + '-' + version + '-'));
|
|
137
|
+
const copyDir = opts.copyDir || copyTree;
|
|
138
|
+
try {
|
|
139
|
+
copyDir(target, snapshot);
|
|
140
|
+
const integrity = computeTreeDigest(snapshot);
|
|
141
|
+
const metadata = {
|
|
142
|
+
schema: 1,
|
|
143
|
+
slug,
|
|
144
|
+
version,
|
|
145
|
+
created_at: new Date().toISOString(),
|
|
146
|
+
source: path.resolve(target),
|
|
147
|
+
digest: integrity.digest,
|
|
148
|
+
files: integrity.files.length,
|
|
149
|
+
};
|
|
150
|
+
writeJsonAtomic(snapshot + '.meta.json', metadata);
|
|
151
|
+
return {
|
|
152
|
+
path: snapshot,
|
|
153
|
+
version,
|
|
154
|
+
digest: integrity.digest,
|
|
155
|
+
files: integrity.files.length,
|
|
156
|
+
metadata,
|
|
157
|
+
};
|
|
158
|
+
} catch (error) {
|
|
159
|
+
fs.rmSync(snapshot, { recursive: true, force: true });
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function listSnapshots(homeDir, slug) {
|
|
165
|
+
const roots = [];
|
|
166
|
+
if (slug) {
|
|
167
|
+
roots.push({ slug, dir: snapshotRoot(homeDir, slug) });
|
|
168
|
+
} else {
|
|
169
|
+
const base = path.join(homeDir || os.homedir(), '.yottaskills', 'snapshots');
|
|
170
|
+
if (fs.existsSync(base)) {
|
|
171
|
+
for (const entry of fs.readdirSync(base, { withFileTypes: true })) {
|
|
172
|
+
if (entry.isDirectory()) roots.push({ slug: entry.name, dir: path.join(base, entry.name) });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const rows = [];
|
|
178
|
+
for (const root of roots) {
|
|
179
|
+
if (!fs.existsSync(root.dir)) continue;
|
|
180
|
+
for (const entry of fs.readdirSync(root.dir, { withFileTypes: true })) {
|
|
181
|
+
if (!entry.isDirectory()) continue;
|
|
182
|
+
const snapshot = path.join(root.dir, entry.name);
|
|
183
|
+
const validation = validateSnapshot(snapshot);
|
|
184
|
+
let mtimeMs = 0;
|
|
185
|
+
try { mtimeMs = fs.statSync(snapshot).mtimeMs; } catch (_) { /* keep 0 */ }
|
|
186
|
+
rows.push({
|
|
187
|
+
path: snapshot,
|
|
188
|
+
name: entry.name,
|
|
189
|
+
slug: root.slug,
|
|
190
|
+
valid: validation.ok,
|
|
191
|
+
version: validation.version,
|
|
192
|
+
legacy: validation.legacy,
|
|
193
|
+
reason: validation.reason,
|
|
194
|
+
mtime_ms: mtimeMs,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
rows.sort((a, b) => (b.mtime_ms - a.mtime_ms) || b.name.localeCompare(a.name));
|
|
199
|
+
return rows;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function restoreSnapshot(snapshot, target, options) {
|
|
203
|
+
const validation = validateSnapshot(snapshot);
|
|
204
|
+
if (!validation.ok) {
|
|
205
|
+
return { ok: false, version: validation.version, error: validation.reason || '快照校验失败' };
|
|
206
|
+
}
|
|
207
|
+
const opts = options || {};
|
|
208
|
+
const destination = path.dirname(path.resolve(target));
|
|
209
|
+
const base = path.basename(path.resolve(target));
|
|
210
|
+
const copyDir = opts.copyDir || copyTree;
|
|
211
|
+
const staging = path.join(destination, '.yottaskills-rollback-' + base + '-' + process.pid + '-' + Date.now());
|
|
212
|
+
const backup = path.join(destination, '.yottaskills-rollback-backup-' + base + '-' + process.pid + '-' + Date.now());
|
|
213
|
+
let movedCurrent = false;
|
|
214
|
+
try {
|
|
215
|
+
fs.mkdirSync(destination, { recursive: true });
|
|
216
|
+
copyDir(snapshot, staging);
|
|
217
|
+
if (fs.existsSync(target)) {
|
|
218
|
+
renameWithRetry(target, backup, opts);
|
|
219
|
+
movedCurrent = true;
|
|
220
|
+
}
|
|
221
|
+
renameWithRetry(staging, target, opts);
|
|
222
|
+
if (movedCurrent && fs.existsSync(backup)) fs.rmSync(backup, { recursive: true, force: true });
|
|
223
|
+
return { ok: true, version: validation.version, error: null, snapshot };
|
|
224
|
+
} catch (error) {
|
|
225
|
+
try {
|
|
226
|
+
if (fs.existsSync(target) && movedCurrent) fs.rmSync(target, { recursive: true, force: true });
|
|
227
|
+
if (movedCurrent && fs.existsSync(backup)) renameWithRetry(backup, target, opts);
|
|
228
|
+
} catch (_) {
|
|
229
|
+
// Preserve both the failure and any recovered tree for manual inspection.
|
|
230
|
+
}
|
|
231
|
+
if (fs.existsSync(staging)) fs.rmSync(staging, { recursive: true, force: true });
|
|
232
|
+
return { ok: false, version: validation.version, error: error.message, snapshot };
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
module.exports = {
|
|
237
|
+
snapshotRoot,
|
|
238
|
+
copyTree,
|
|
239
|
+
computeTreeDigest,
|
|
240
|
+
createSnapshot,
|
|
241
|
+
validateSnapshot,
|
|
242
|
+
listSnapshots,
|
|
243
|
+
restoreSnapshot,
|
|
244
|
+
renameWithRetry,
|
|
245
|
+
};
|