@djordje-stojanovic/sigmaskills 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/CHANGELOG.md +77 -0
- package/LICENSE +21 -0
- package/README.md +342 -0
- package/bin/sigmaskills.js +7 -0
- package/manifest.json +28 -0
- package/package.json +42 -0
- package/registry/agent-hosts.json +2404 -0
- package/registry/schema.json +110 -0
- package/registry/skill-baselines.json +4 -0
- package/registry/source.json +6 -0
- package/sigmabrief/SKILL.md +56 -0
- package/sigmabrief/agents/openai.yaml +12 -0
- package/sigmabrief/references/brief-method.md +73 -0
- package/sigmabrief/references/prompt-contract.md +174 -0
- package/sigmaperformance/SKILL.md +118 -0
- package/sigmaperformance/agents/openai.yaml +12 -0
- package/sigmaperformance/references/audit-method.md +112 -0
- package/sigmaperformance/references/calibration.md +45 -0
- package/sigmaperformance/references/report-contract.md +103 -0
- package/sigmareview/SKILL.md +133 -0
- package/sigmareview/agents/openai.yaml +12 -0
- package/sigmareview/references/report-contract.md +217 -0
- package/sigmareview/references/review-method.md +233 -0
- package/sigmawrite/SKILL.md +45 -0
- package/sigmawrite/agents/openai.yaml +12 -0
- package/src/adoption.js +370 -0
- package/src/backup.js +398 -0
- package/src/catalog.js +211 -0
- package/src/cli.js +657 -0
- package/src/customization.js +344 -0
- package/src/destinations.js +491 -0
- package/src/interactive.js +959 -0
- package/src/links.js +157 -0
- package/src/plan.js +429 -0
- package/src/prepack.js +10 -0
- package/src/project-lock.js +169 -0
- package/src/purge.js +477 -0
- package/src/registry/automation-ci.js +411 -0
- package/src/registry/automation.js +554 -0
- package/src/registry/diff.js +149 -0
- package/src/registry/normalize.js +67 -0
- package/src/registry/parse.js +184 -0
- package/src/registry/sync.js +230 -0
- package/src/registry/validate.js +223 -0
- package/src/release-ci.js +12 -0
- package/src/release.js +837 -0
- package/src/restore.js +518 -0
- package/src/revision.js +82 -0
- package/src/state.js +480 -0
- package/src/status.js +469 -0
- package/src/transaction.js +636 -0
- package/src/uninstall.js +647 -0
- package/src/update.js +815 -0
package/src/restore.js
ADDED
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
BACKUP_METADATA_NAME,
|
|
5
|
+
commitSkillBackup,
|
|
6
|
+
copySkillTree,
|
|
7
|
+
findLatestBackupDir,
|
|
8
|
+
inventoriesMatch,
|
|
9
|
+
inventorySkillTree,
|
|
10
|
+
pruneOlderBackups,
|
|
11
|
+
verifyBackupIntegrity,
|
|
12
|
+
} from './backup.js';
|
|
13
|
+
import { resolveHomeDir } from './destinations.js';
|
|
14
|
+
import { createSkillLink, pathExists, removeManagedPath } from './links.js';
|
|
15
|
+
import { loadProjectLock, saveProjectLock, updateProjectLockSkill } from './project-lock.js';
|
|
16
|
+
import { computeSkillRevisionAndHashes } from './revision.js';
|
|
17
|
+
import {
|
|
18
|
+
getGlobalStateDir,
|
|
19
|
+
getProjectStateDir,
|
|
20
|
+
loadGlobalState,
|
|
21
|
+
loadProjectState,
|
|
22
|
+
recordSkillInState,
|
|
23
|
+
saveGlobalState,
|
|
24
|
+
saveProjectState,
|
|
25
|
+
} from './state.js';
|
|
26
|
+
import { acquireConcurrencyLock } from './transaction.js';
|
|
27
|
+
|
|
28
|
+
export const RESTORE_SCHEMA_VERSION = 1;
|
|
29
|
+
|
|
30
|
+
function codedError(message, code) {
|
|
31
|
+
const err = new Error(message);
|
|
32
|
+
err.code = code;
|
|
33
|
+
return err;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function resolveRoot(options) {
|
|
37
|
+
const scope = options.scope || 'project';
|
|
38
|
+
if (scope === 'global') return path.resolve(options.homeDir || resolveHomeDir(options.env || process.env));
|
|
39
|
+
return path.resolve(options.projectRoot || process.cwd());
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function resolveStateDir(scope, root, customStateDir) {
|
|
43
|
+
return scope === 'global'
|
|
44
|
+
? getGlobalStateDir(root, customStateDir)
|
|
45
|
+
: getProjectStateDir(root, customStateDir);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function loadState(scope, root, customStateDir) {
|
|
49
|
+
return scope === 'global'
|
|
50
|
+
? loadGlobalState(root, customStateDir)
|
|
51
|
+
: loadProjectState(root, customStateDir);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function recordedCopyDestinations(entry) {
|
|
55
|
+
const destinations = [];
|
|
56
|
+
if (entry?.destination) destinations.push(String(entry.destination).replace(/\\/g, '/'));
|
|
57
|
+
if (Array.isArray(entry?.copies)) {
|
|
58
|
+
for (const copy of entry.copies) {
|
|
59
|
+
if (copy?.destination) destinations.push(String(copy.destination).replace(/\\/g, '/'));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return destinations;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function ownerOfPath(state, relativePath) {
|
|
66
|
+
const wanted = String(relativePath || '').replace(/\\/g, '/');
|
|
67
|
+
for (const [skillId, entry] of Object.entries(state.skills || {})) {
|
|
68
|
+
if (recordedCopyDestinations(entry).includes(wanted)) return skillId;
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function previewFrom(metadata, sizeBytes, backupDir) {
|
|
74
|
+
const copies = Array.isArray(metadata.copies) ? metadata.copies : [];
|
|
75
|
+
const links = copies
|
|
76
|
+
.filter((copy) => copy.method && copy.method !== 'copy')
|
|
77
|
+
.map((copy) => ({
|
|
78
|
+
destination: copy.destination,
|
|
79
|
+
method: copy.method,
|
|
80
|
+
}));
|
|
81
|
+
const managedCopies = copies
|
|
82
|
+
.filter((copy) => !copy.method || copy.method === 'copy')
|
|
83
|
+
.map((copy) => ({
|
|
84
|
+
destination: copy.destination,
|
|
85
|
+
kind: copy.kind || 'canonical',
|
|
86
|
+
}));
|
|
87
|
+
return {
|
|
88
|
+
release: metadata.release || null,
|
|
89
|
+
revision: metadata.revision || null,
|
|
90
|
+
createdAt: metadata.createdAt || null,
|
|
91
|
+
sizeBytes,
|
|
92
|
+
scope: metadata.scope || null,
|
|
93
|
+
canonicalTarget: metadata.canonicalTarget || null,
|
|
94
|
+
backupDir,
|
|
95
|
+
links,
|
|
96
|
+
copies: managedCopies,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function classifyRestoreSkill(options, skillId) {
|
|
101
|
+
const scope = options.scope || 'project';
|
|
102
|
+
const root = resolveRoot(options);
|
|
103
|
+
const stateDir = resolveStateDir(scope, root, options.customStateDir);
|
|
104
|
+
const state = loadState(scope, root, options.customStateDir);
|
|
105
|
+
const entry = state.skills?.[skillId];
|
|
106
|
+
const backupDir = findLatestBackupDir({
|
|
107
|
+
stateDir,
|
|
108
|
+
skillId,
|
|
109
|
+
lastBackup: entry?.lastBackup,
|
|
110
|
+
});
|
|
111
|
+
const fallbackRel = entry?.destination || `.agents/skills/${skillId}`;
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
const verified = verifyBackupIntegrity({ backupDir, skillId });
|
|
115
|
+
const metadata = verified.metadata;
|
|
116
|
+
const canonicalRel = metadata.canonicalTarget || fallbackRel;
|
|
117
|
+
const canonicalAbs = path.resolve(root, ...canonicalRel.split('/'));
|
|
118
|
+
const blockedReasons = [];
|
|
119
|
+
|
|
120
|
+
if (metadata.scope && metadata.scope !== scope) {
|
|
121
|
+
blockedReasons.push('stale-ownership');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const claimed = [
|
|
125
|
+
canonicalRel,
|
|
126
|
+
...(Array.isArray(metadata.ownedPaths) ? metadata.ownedPaths : []),
|
|
127
|
+
...(Array.isArray(metadata.copies) ? metadata.copies.map((copy) => copy.destination).filter(Boolean) : []),
|
|
128
|
+
].map((item) => String(item).replace(/\\/g, '/'));
|
|
129
|
+
|
|
130
|
+
for (const rel of claimed) {
|
|
131
|
+
const owner = ownerOfPath(state, rel);
|
|
132
|
+
if (owner && owner !== skillId) {
|
|
133
|
+
blockedReasons.push('stale-ownership');
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const liveExists = pathExists(canonicalAbs);
|
|
139
|
+
const weOwn = ownerOfPath(state, canonicalRel) === skillId;
|
|
140
|
+
if (liveExists && !weOwn) {
|
|
141
|
+
blockedReasons.push('occupied-unowned');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const needed = Math.max(verified.sizeBytes, 1) * 3;
|
|
145
|
+
const statfs = options.statfs || (typeof fs.statfsSync === 'function' ? fs.statfsSync.bind(fs) : null);
|
|
146
|
+
if (typeof statfs === 'function') {
|
|
147
|
+
try {
|
|
148
|
+
const parent = pathExists(path.dirname(canonicalAbs)) ? path.dirname(canonicalAbs) : root;
|
|
149
|
+
const stat = statfs(parent);
|
|
150
|
+
const available = Number(stat.bavail) * Number(stat.bsize);
|
|
151
|
+
if (Number.isFinite(available) && available < needed) {
|
|
152
|
+
blockedReasons.push('insufficient-space');
|
|
153
|
+
}
|
|
154
|
+
} catch {
|
|
155
|
+
// Filesystems without statfs still attempt the restore; explicit hooks can fail closed.
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const liveInventory = liveExists ? inventorySkillTree(canonicalAbs) : { entries: {} };
|
|
160
|
+
const identical = liveExists && inventoriesMatch(liveInventory, verified.inventory);
|
|
161
|
+
const uniqueReasons = [...new Set(blockedReasons)];
|
|
162
|
+
return {
|
|
163
|
+
id: skillId,
|
|
164
|
+
backupDir,
|
|
165
|
+
metadata,
|
|
166
|
+
canonicalRel,
|
|
167
|
+
canonicalAbs,
|
|
168
|
+
preview: previewFrom(metadata, verified.sizeBytes, backupDir),
|
|
169
|
+
identical,
|
|
170
|
+
blocked: uniqueReasons.length > 0,
|
|
171
|
+
blockedReasons: uniqueReasons,
|
|
172
|
+
action: uniqueReasons.length > 0 ? 'blocked' : (identical ? 'no-op' : 'restore'),
|
|
173
|
+
};
|
|
174
|
+
} catch (err) {
|
|
175
|
+
const code = err.code || 'missing';
|
|
176
|
+
const reason = code === 'schema' ? 'schema-incompatible' : code;
|
|
177
|
+
return {
|
|
178
|
+
id: skillId,
|
|
179
|
+
backupDir,
|
|
180
|
+
metadata: null,
|
|
181
|
+
canonicalRel: fallbackRel,
|
|
182
|
+
canonicalAbs: path.resolve(root, ...fallbackRel.split('/')),
|
|
183
|
+
preview: {
|
|
184
|
+
release: null,
|
|
185
|
+
revision: null,
|
|
186
|
+
createdAt: null,
|
|
187
|
+
sizeBytes: 0,
|
|
188
|
+
scope,
|
|
189
|
+
canonicalTarget: entry?.destination || null,
|
|
190
|
+
backupDir,
|
|
191
|
+
links: [],
|
|
192
|
+
copies: [],
|
|
193
|
+
},
|
|
194
|
+
identical: false,
|
|
195
|
+
blocked: true,
|
|
196
|
+
blockedReasons: [reason],
|
|
197
|
+
action: 'blocked',
|
|
198
|
+
error: err.message,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Preview restore of the latest retained backup for selected skills.
|
|
205
|
+
*
|
|
206
|
+
* @param {object} options
|
|
207
|
+
* @returns {object}
|
|
208
|
+
*/
|
|
209
|
+
export function createRestorePlan(options = {}) {
|
|
210
|
+
const scope = options.scope || 'project';
|
|
211
|
+
const skillIds = Array.isArray(options.skillIds) ? options.skillIds.filter(Boolean) : [];
|
|
212
|
+
return {
|
|
213
|
+
schemaVersion: RESTORE_SCHEMA_VERSION,
|
|
214
|
+
command: 'restore',
|
|
215
|
+
scope,
|
|
216
|
+
dryRun: Boolean(options.dryRun),
|
|
217
|
+
skills: skillIds.map((skillId) => classifyRestoreSkill(options, skillId)),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function swapLiveTree(staged, live, options) {
|
|
222
|
+
const rename = options.renameDirectory || ((from, to) => fs.renameSync(from, to));
|
|
223
|
+
const copy = options.copyDirectory || ((from, to) => {
|
|
224
|
+
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
225
|
+
copySkillTree(from, to);
|
|
226
|
+
});
|
|
227
|
+
const displaced = `${live}.sigma-displace-${process.pid}-${Date.now()}`;
|
|
228
|
+
const incoming = `${live}.sigma-incoming-${process.pid}-${Date.now()}`;
|
|
229
|
+
const liveExisted = pathExists(live);
|
|
230
|
+
|
|
231
|
+
const restoreDisplaced = () => {
|
|
232
|
+
if (pathExists(displaced) && !pathExists(live)) {
|
|
233
|
+
try {
|
|
234
|
+
fs.renameSync(displaced, live);
|
|
235
|
+
} catch {
|
|
236
|
+
copySkillTree(displaced, live);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (pathExists(incoming)) {
|
|
240
|
+
try {
|
|
241
|
+
fs.rmSync(incoming, { recursive: true, force: true });
|
|
242
|
+
} catch {
|
|
243
|
+
// Incoming cleanup is best-effort.
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
if (liveExisted) {
|
|
250
|
+
try {
|
|
251
|
+
rename(live, displaced);
|
|
252
|
+
} catch (err) {
|
|
253
|
+
try {
|
|
254
|
+
copy(staged, incoming);
|
|
255
|
+
try {
|
|
256
|
+
rename(live, displaced);
|
|
257
|
+
} catch (liveErr) {
|
|
258
|
+
throw codedError(
|
|
259
|
+
`Windows fallback failed: ${liveErr.message}`,
|
|
260
|
+
liveErr.code || err.code || 'fallback',
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
rename(incoming, live);
|
|
265
|
+
} catch {
|
|
266
|
+
copy(incoming, live);
|
|
267
|
+
fs.rmSync(incoming, { recursive: true, force: true });
|
|
268
|
+
}
|
|
269
|
+
if (pathExists(displaced)) fs.rmSync(displaced, { recursive: true, force: true });
|
|
270
|
+
if (pathExists(staged)) fs.rmSync(staged, { recursive: true, force: true });
|
|
271
|
+
return;
|
|
272
|
+
} catch (fallbackErr) {
|
|
273
|
+
restoreDisplaced();
|
|
274
|
+
throw codedError(
|
|
275
|
+
`Windows fallback failed: ${fallbackErr.message}`,
|
|
276
|
+
fallbackErr.code || err.code || 'fallback',
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
try {
|
|
283
|
+
rename(staged, live);
|
|
284
|
+
} catch (err) {
|
|
285
|
+
try {
|
|
286
|
+
copy(staged, live);
|
|
287
|
+
if (pathExists(staged)) fs.rmSync(staged, { recursive: true, force: true });
|
|
288
|
+
} catch (fallbackErr) {
|
|
289
|
+
restoreDisplaced();
|
|
290
|
+
throw codedError(
|
|
291
|
+
`Windows fallback failed: ${fallbackErr.message}`,
|
|
292
|
+
fallbackErr.code || err.code || 'fallback',
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (pathExists(displaced)) fs.rmSync(displaced, { recursive: true, force: true });
|
|
297
|
+
} catch (err) {
|
|
298
|
+
restoreDisplaced();
|
|
299
|
+
throw err;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function applyOwnedCopies(root, skill) {
|
|
304
|
+
const copies = Array.isArray(skill.metadata?.copies) ? skill.metadata.copies : [];
|
|
305
|
+
for (const copy of copies) {
|
|
306
|
+
const rel = String(copy.destination || '').replace(/\\/g, '/');
|
|
307
|
+
if (!rel || rel === skill.canonicalRel) continue;
|
|
308
|
+
const abs = path.resolve(root, ...rel.split('/'));
|
|
309
|
+
if (copy.method && copy.method !== 'copy') {
|
|
310
|
+
if (pathExists(abs)) removeManagedPath(abs);
|
|
311
|
+
createSkillLink(abs, skill.canonicalAbs, root);
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
if (copy.kind === 'host' || copy.method === 'copy') {
|
|
315
|
+
if (pathExists(abs)) removeManagedPath(abs);
|
|
316
|
+
copySkillTree(skill.canonicalAbs, abs);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function applyOneRestore(options, skill, state) {
|
|
322
|
+
const scope = options.scope || 'project';
|
|
323
|
+
const root = resolveRoot(options);
|
|
324
|
+
const stateDir = resolveStateDir(scope, root, options.customStateDir);
|
|
325
|
+
const stagingParent = path.join(stateDir, '.sigma-restore-staging');
|
|
326
|
+
const stagingDir = path.join(
|
|
327
|
+
stagingParent,
|
|
328
|
+
`${skill.id}-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
329
|
+
);
|
|
330
|
+
let undoBackup = null;
|
|
331
|
+
|
|
332
|
+
const cleanupStaging = () => {
|
|
333
|
+
try {
|
|
334
|
+
if (pathExists(stagingDir)) fs.rmSync(stagingDir, { recursive: true, force: true });
|
|
335
|
+
if (pathExists(stagingParent) && fs.readdirSync(stagingParent).length === 0) {
|
|
336
|
+
fs.rmSync(stagingParent, { recursive: true, force: true });
|
|
337
|
+
}
|
|
338
|
+
} catch {
|
|
339
|
+
// Staging cleanup is best-effort.
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
try {
|
|
344
|
+
copySkillTree(skill.backupDir, stagingDir);
|
|
345
|
+
if (pathExists(path.join(stagingDir, BACKUP_METADATA_NAME))) {
|
|
346
|
+
fs.unlinkSync(path.join(stagingDir, BACKUP_METADATA_NAME));
|
|
347
|
+
}
|
|
348
|
+
if (!inventoriesMatch(inventorySkillTree(stagingDir), inventorySkillTree(skill.backupDir))) {
|
|
349
|
+
throw codedError(`tampered backup for '${skill.id}'`, 'tampered');
|
|
350
|
+
}
|
|
351
|
+
if (typeof options.afterStage === 'function') options.afterStage(stagingDir);
|
|
352
|
+
|
|
353
|
+
if (pathExists(skill.canonicalAbs)) {
|
|
354
|
+
const backupFn = options.backupSkill || commitSkillBackup;
|
|
355
|
+
undoBackup = backupFn({
|
|
356
|
+
stateDir,
|
|
357
|
+
skillId: skill.id,
|
|
358
|
+
sourceDir: skill.canonicalAbs,
|
|
359
|
+
ownership: {
|
|
360
|
+
scope,
|
|
361
|
+
release: state.skills?.[skill.id]?.release || null,
|
|
362
|
+
revision: state.skills?.[skill.id]?.revision || null,
|
|
363
|
+
method: state.skills?.[skill.id]?.method || skill.metadata.method,
|
|
364
|
+
canonicalTarget: skill.canonicalRel,
|
|
365
|
+
copies: state.skills?.[skill.id]?.copies || skill.metadata.copies || [],
|
|
366
|
+
ownedPaths: state.skills?.[skill.id]?.ownedPaths || skill.metadata.ownedPaths || [skill.canonicalRel],
|
|
367
|
+
},
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
swapLiveTree(stagingDir, skill.canonicalAbs, options);
|
|
372
|
+
applyOwnedCopies(root, skill);
|
|
373
|
+
cleanupStaging();
|
|
374
|
+
return undoBackup;
|
|
375
|
+
} catch (err) {
|
|
376
|
+
if (undoBackup && pathExists(undoBackup)) {
|
|
377
|
+
try {
|
|
378
|
+
fs.rmSync(undoBackup, { recursive: true, force: true });
|
|
379
|
+
} catch {
|
|
380
|
+
// Drop the unused undo snapshot so the retained backup stays the latest.
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
cleanupStaging();
|
|
384
|
+
throw err;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function recordRestoredSkill(state, skill, root, undoBackup, stateDir) {
|
|
389
|
+
const computed = computeSkillRevisionAndHashes(skill.canonicalAbs);
|
|
390
|
+
const copies = Array.isArray(skill.metadata.copies) && skill.metadata.copies.length > 0
|
|
391
|
+
? skill.metadata.copies
|
|
392
|
+
: [{
|
|
393
|
+
kind: 'canonical',
|
|
394
|
+
destination: skill.canonicalRel,
|
|
395
|
+
method: skill.metadata.method || 'copy',
|
|
396
|
+
dependsOn: null,
|
|
397
|
+
hostIds: [],
|
|
398
|
+
ownedPaths: skill.metadata.ownedPaths || [skill.canonicalRel],
|
|
399
|
+
}];
|
|
400
|
+
const primary = copies.find((copy) => copy.kind === 'canonical') || copies[0];
|
|
401
|
+
const backupAbs = undoBackup || skill.backupDir;
|
|
402
|
+
const lastBackup = backupAbs
|
|
403
|
+
? path.relative(stateDir, backupAbs).replace(/\\/g, '/')
|
|
404
|
+
: null;
|
|
405
|
+
return recordSkillInState(state, {
|
|
406
|
+
skillId: skill.id,
|
|
407
|
+
release: skill.metadata.release || null,
|
|
408
|
+
revision: skill.metadata.revision || computed.revision,
|
|
409
|
+
method: skill.metadata.method || primary.method || 'copy',
|
|
410
|
+
destination: primary.destination || skill.canonicalRel,
|
|
411
|
+
projectRoot: root,
|
|
412
|
+
ownedPaths: skill.metadata.ownedPaths || primary.ownedPaths || [skill.canonicalRel],
|
|
413
|
+
baseHashes: computed.files,
|
|
414
|
+
copies,
|
|
415
|
+
lastBackup,
|
|
416
|
+
cleanupDebt: [],
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Restore the latest retained backup for selected skills.
|
|
422
|
+
*
|
|
423
|
+
* @param {object} options
|
|
424
|
+
* @returns {object}
|
|
425
|
+
*/
|
|
426
|
+
export function executeRestore(options = {}) {
|
|
427
|
+
const plan = createRestorePlan(options);
|
|
428
|
+
if (options.dryRun) {
|
|
429
|
+
return { ...plan, dryRun: true };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const blocked = plan.skills.filter((skill) => skill.blocked);
|
|
433
|
+
if (blocked.length > 0) {
|
|
434
|
+
const reasons = blocked.map((skill) => `${skill.id}: ${skill.blockedReasons.join(', ')}`).join('; ');
|
|
435
|
+
const first = blocked[0].blockedReasons[0];
|
|
436
|
+
const message = first === 'insufficient-space'
|
|
437
|
+
? `insufficient space to restore: ${reasons}`
|
|
438
|
+
: first === 'occupied-unowned'
|
|
439
|
+
? `occupied-unowned destination stopped restore: ${reasons}`
|
|
440
|
+
: first === 'stale-ownership'
|
|
441
|
+
? `stale-ownership stopped restore: ${reasons}`
|
|
442
|
+
: first === 'schema-incompatible'
|
|
443
|
+
? `schema-incompatible backup stopped restore: ${reasons}`
|
|
444
|
+
: blocked[0].error || `restore stopped: ${reasons}`;
|
|
445
|
+
throw codedError(message, first);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const scope = options.scope || 'project';
|
|
449
|
+
const root = resolveRoot(options);
|
|
450
|
+
const customStateDir = options.customStateDir;
|
|
451
|
+
const releaseLock = acquireConcurrencyLock(root, customStateDir);
|
|
452
|
+
let state = loadState(scope, root, customStateDir);
|
|
453
|
+
|
|
454
|
+
try {
|
|
455
|
+
for (const skill of plan.skills) {
|
|
456
|
+
if (skill.identical) {
|
|
457
|
+
skill.action = 'no-op';
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
const undoBackup = applyOneRestore(options, skill, state);
|
|
461
|
+
state = recordRestoredSkill(state, skill, root, undoBackup, resolveStateDir(scope, root, customStateDir));
|
|
462
|
+
const persist = options.saveState || (scope === 'global' ? saveGlobalState : saveProjectState);
|
|
463
|
+
persist(root, state, customStateDir);
|
|
464
|
+
if (scope !== 'global' && skill.metadata.revision) {
|
|
465
|
+
const lock = loadProjectLock(root);
|
|
466
|
+
saveProjectLock(root, updateProjectLockSkill(lock, skill.id, skill.metadata.revision, skill.metadata.release));
|
|
467
|
+
}
|
|
468
|
+
if (undoBackup) {
|
|
469
|
+
pruneOlderBackups({
|
|
470
|
+
stateDir: resolveStateDir(scope, root, customStateDir),
|
|
471
|
+
skillId: skill.id,
|
|
472
|
+
keepPath: undoBackup,
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
skill.action = 'restored';
|
|
476
|
+
}
|
|
477
|
+
return { ...plan, dryRun: false, state };
|
|
478
|
+
} finally {
|
|
479
|
+
releaseLock();
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* @param {object} result
|
|
485
|
+
* @returns {string}
|
|
486
|
+
*/
|
|
487
|
+
export function formatRestoreJson(result) {
|
|
488
|
+
return `${JSON.stringify(result, null, 2)}\n`;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* @param {object} result
|
|
493
|
+
* @returns {string}
|
|
494
|
+
*/
|
|
495
|
+
export function formatRestoreHuman(result) {
|
|
496
|
+
const lines = [
|
|
497
|
+
`SigmaSkills Restore ${result.dryRun ? 'Preview' : 'Result'}`,
|
|
498
|
+
`Scope: ${result.scope}`,
|
|
499
|
+
'',
|
|
500
|
+
];
|
|
501
|
+
for (const skill of result.skills || []) {
|
|
502
|
+
lines.push(`Skill: ${skill.id}`);
|
|
503
|
+
lines.push(` Action: ${skill.action}`);
|
|
504
|
+
if (skill.preview?.release) lines.push(` Release: ${skill.preview.release}`);
|
|
505
|
+
if (skill.preview?.revision) lines.push(` Revision: ${skill.preview.revision}`);
|
|
506
|
+
if (skill.preview?.createdAt) lines.push(` Created: ${skill.preview.createdAt}`);
|
|
507
|
+
if (skill.preview?.sizeBytes) lines.push(` Size: ${skill.preview.sizeBytes} bytes`);
|
|
508
|
+
if (skill.preview?.scope) lines.push(` Backup scope: ${skill.preview.scope}`);
|
|
509
|
+
if (skill.preview?.canonicalTarget) lines.push(` Canonical target: ${skill.preview.canonicalTarget}`);
|
|
510
|
+
const links = (skill.preview?.links || []).map((link) => link.destination).join(', ');
|
|
511
|
+
const copies = (skill.preview?.copies || []).map((copy) => copy.destination).join(', ');
|
|
512
|
+
lines.push(` Links: ${links || 'none'}`);
|
|
513
|
+
lines.push(` Copies: ${copies || 'none'}`);
|
|
514
|
+
if (skill.blockedReasons?.length) lines.push(` Blocked: ${skill.blockedReasons.join(', ')}`);
|
|
515
|
+
lines.push('');
|
|
516
|
+
}
|
|
517
|
+
return lines.join('\n');
|
|
518
|
+
}
|
package/src/revision.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Recursively list all files in a directory, returning paths relative to rootDir
|
|
7
|
+
* with POSIX forward slashes.
|
|
8
|
+
*
|
|
9
|
+
* @param {string} dir
|
|
10
|
+
* @param {string} [baseDir]
|
|
11
|
+
* @returns {string[]}
|
|
12
|
+
*/
|
|
13
|
+
export function listRelativeFiles(dir, baseDir = dir) {
|
|
14
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
15
|
+
const results = [];
|
|
16
|
+
|
|
17
|
+
for (const entry of entries) {
|
|
18
|
+
const fullPath = path.join(dir, entry.name);
|
|
19
|
+
if (entry.isDirectory()) {
|
|
20
|
+
results.push(...listRelativeFiles(fullPath, baseDir));
|
|
21
|
+
} else if (entry.isFile()) {
|
|
22
|
+
const relPath = path.relative(baseDir, fullPath).replace(/\\/g, '/');
|
|
23
|
+
results.push(relPath);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return results;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Compute deterministic SHA-256 revision hash and per-file hashes for a skill directory
|
|
32
|
+
* in a single disk pass.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} skillDir
|
|
35
|
+
* @returns {{ revision: string, files: Record<string, string> }}
|
|
36
|
+
*/
|
|
37
|
+
export function computeSkillRevisionAndHashes(skillDir) {
|
|
38
|
+
const relFiles = listRelativeFiles(skillDir);
|
|
39
|
+
relFiles.sort();
|
|
40
|
+
|
|
41
|
+
const folderHash = crypto.createHash('sha256');
|
|
42
|
+
const files = {};
|
|
43
|
+
|
|
44
|
+
for (const relPath of relFiles) {
|
|
45
|
+
const fullPath = path.join(skillDir, relPath);
|
|
46
|
+
const fileBytes = fs.readFileSync(fullPath);
|
|
47
|
+
|
|
48
|
+
// Update combined folder hash
|
|
49
|
+
folderHash.update(relPath);
|
|
50
|
+
folderHash.update('\0');
|
|
51
|
+
folderHash.update(fileBytes);
|
|
52
|
+
folderHash.update('\0');
|
|
53
|
+
|
|
54
|
+
// Record per-file hash
|
|
55
|
+
files[relPath] = crypto.createHash('sha256').update(fileBytes).digest('hex');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
revision: folderHash.digest('hex'),
|
|
60
|
+
files,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Compute deterministic SHA-256 revision hash for a skill directory.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} skillDir
|
|
68
|
+
* @returns {string} SHA-256 hex string (64 characters)
|
|
69
|
+
*/
|
|
70
|
+
export function computeSkillRevision(skillDir) {
|
|
71
|
+
return computeSkillRevisionAndHashes(skillDir).revision;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Compute SHA-256 hash for every file in a skill directory.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} skillDir
|
|
78
|
+
* @returns {Record<string, string>}
|
|
79
|
+
*/
|
|
80
|
+
export function computeFileHashes(skillDir) {
|
|
81
|
+
return computeSkillRevisionAndHashes(skillDir).files;
|
|
82
|
+
}
|