@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
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { findPackageRoot, validateSkill } from './catalog.js';
|
|
4
|
+
import { injectCustomContent, injectRawCustomContent } from './customization.js';
|
|
5
|
+
import { commitSkillBackup, exportSkillTree, getBackupRoot, pruneOlderBackups } from './backup.js';
|
|
6
|
+
import { createInstallPlan } from './plan.js';
|
|
7
|
+
import { loadProjectLock, saveProjectLock, updateProjectLockSkill, PROJECT_LOCK_FILENAME } from './project-lock.js';
|
|
8
|
+
import { resolveHomeDir } from './destinations.js';
|
|
9
|
+
import {
|
|
10
|
+
getGlobalStateDir,
|
|
11
|
+
getGlobalStatePath,
|
|
12
|
+
getProjectStateDir,
|
|
13
|
+
getProjectStatePath,
|
|
14
|
+
loadGlobalState,
|
|
15
|
+
loadProjectState,
|
|
16
|
+
saveGlobalState,
|
|
17
|
+
saveProjectState,
|
|
18
|
+
recordSkillInState,
|
|
19
|
+
} from './state.js';
|
|
20
|
+
import { createSkillLink, pathExists, removeManagedPath } from './links.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Acquire process concurrency lock for project.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} projectRoot
|
|
26
|
+
* @param {string} [customStateDir]
|
|
27
|
+
* @returns {() => void} Release lock function
|
|
28
|
+
*/
|
|
29
|
+
export function acquireConcurrencyLock(projectRoot, customStateDir) {
|
|
30
|
+
const stateDir = getProjectStateDir(projectRoot, customStateDir);
|
|
31
|
+
if (!fs.existsSync(stateDir)) {
|
|
32
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const lockPath = path.join(stateDir, '.sigma.lock');
|
|
36
|
+
|
|
37
|
+
function tryCreateLock() {
|
|
38
|
+
try {
|
|
39
|
+
const fd = fs.openSync(lockPath, 'wx');
|
|
40
|
+
const payload = JSON.stringify({
|
|
41
|
+
pid: process.pid,
|
|
42
|
+
createdAt: new Date().toISOString(),
|
|
43
|
+
});
|
|
44
|
+
fs.writeFileSync(fd, payload, 'utf8');
|
|
45
|
+
fs.closeSync(fd);
|
|
46
|
+
return true;
|
|
47
|
+
} catch (err) {
|
|
48
|
+
if (err.code === 'EEXIST') {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
throw err;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!tryCreateLock()) {
|
|
56
|
+
// Check if existing lock is stale
|
|
57
|
+
try {
|
|
58
|
+
const existing = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
|
|
59
|
+
const isAlive = isProcessAlive(existing.pid);
|
|
60
|
+
if (isAlive) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`Concurrent SigmaSkills operation in progress (PID ${existing.pid}). Aborting to prevent corruption.`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
// Stale lock from dead process: remove and retry
|
|
66
|
+
fs.unlinkSync(lockPath);
|
|
67
|
+
if (!tryCreateLock()) {
|
|
68
|
+
throw new Error('Failed to acquire lock after clearing stale lock file.');
|
|
69
|
+
}
|
|
70
|
+
} catch (readErr) {
|
|
71
|
+
if (readErr.message.includes('Concurrent SigmaSkills operation')) {
|
|
72
|
+
throw readErr;
|
|
73
|
+
}
|
|
74
|
+
// If reading/parsing failed, attempt unlink and recreate
|
|
75
|
+
try {
|
|
76
|
+
fs.unlinkSync(lockPath);
|
|
77
|
+
if (!tryCreateLock()) {
|
|
78
|
+
throw new Error('Failed to acquire lock.');
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
throw new Error(`Failed to acquire project lock at ${lockPath}: ${readErr.message}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let released = false;
|
|
87
|
+
return function releaseLock() {
|
|
88
|
+
if (released) return;
|
|
89
|
+
released = true;
|
|
90
|
+
try {
|
|
91
|
+
if (fs.existsSync(lockPath)) {
|
|
92
|
+
const existing = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
|
|
93
|
+
if (existing.pid === process.pid) {
|
|
94
|
+
fs.unlinkSync(lockPath);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
} catch {
|
|
98
|
+
// Best-effort cleanup
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Check if a process ID is currently running.
|
|
105
|
+
*
|
|
106
|
+
* @param {number} pid
|
|
107
|
+
* @returns {boolean}
|
|
108
|
+
*/
|
|
109
|
+
function isProcessAlive(pid) {
|
|
110
|
+
if (!pid || typeof pid !== 'number') return false;
|
|
111
|
+
try {
|
|
112
|
+
process.kill(pid, 0);
|
|
113
|
+
return true;
|
|
114
|
+
} catch (err) {
|
|
115
|
+
return err.code === 'EPERM'; // Process exists but no permission
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Build the fail-closed error for an unowned destination.
|
|
121
|
+
*
|
|
122
|
+
* @param {object} plan
|
|
123
|
+
* @returns {Error}
|
|
124
|
+
*/
|
|
125
|
+
export function createUnownedConflictError(plan) {
|
|
126
|
+
const conflictDest = (plan.destinations || []).find((dest) => dest.unownedConflict)?.destination
|
|
127
|
+
|| plan.destination;
|
|
128
|
+
return new Error(
|
|
129
|
+
`Destination '${conflictDest}' already exists and is not owned by SigmaSkills. Safe adoption is not enabled. Installation aborted.`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function createNeedsResolutionError(plan) {
|
|
134
|
+
const pending = (plan.destinations || []).filter((dest) => dest.migratable && !dest.resolution);
|
|
135
|
+
const details = pending.map((dest) => {
|
|
136
|
+
const diff = dest.diff || { added: [], replaced: [], deleted: [] };
|
|
137
|
+
return `${dest.relativeDestination} (${dest.recognition}, provenance ${dest.confidence}; +${(diff.added || []).length} ~${(diff.replaced || []).length} -${(diff.deleted || []).length})`;
|
|
138
|
+
}).join('; ');
|
|
139
|
+
return new Error(
|
|
140
|
+
`Changed or legacy Sigma-looking trees need an explicit replace, skip, or export choice before mutation: ${details}`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Execute transactional single-skill project installation.
|
|
146
|
+
*
|
|
147
|
+
* @param {object} params
|
|
148
|
+
* @param {object} params.catalog
|
|
149
|
+
* @param {string} params.skillId
|
|
150
|
+
* @param {string} [params.projectRoot]
|
|
151
|
+
* @param {string} [params.customStateDir]
|
|
152
|
+
* @param {string} [params.packageRoot]
|
|
153
|
+
* @param {boolean} [params.dryRun]
|
|
154
|
+
* @returns {object} Execution summary with plan
|
|
155
|
+
*/
|
|
156
|
+
export function executeProjectInstall(params) {
|
|
157
|
+
const {
|
|
158
|
+
catalog,
|
|
159
|
+
skillId,
|
|
160
|
+
customStateDir,
|
|
161
|
+
dryRun = false,
|
|
162
|
+
} = params;
|
|
163
|
+
|
|
164
|
+
const scope = params.scope || 'project';
|
|
165
|
+
const env = params.env || process.env;
|
|
166
|
+
const homeDir = path.resolve(params.homeDir || resolveHomeDir(env));
|
|
167
|
+
const projectRoot = path.resolve(params.projectRoot || process.cwd());
|
|
168
|
+
const root = scope === 'global' ? homeDir : projectRoot;
|
|
169
|
+
const packageRoot = params.packageRoot || findPackageRoot();
|
|
170
|
+
|
|
171
|
+
const plan = createInstallPlan(catalog, {
|
|
172
|
+
skillId,
|
|
173
|
+
projectRoot,
|
|
174
|
+
homeDir,
|
|
175
|
+
scope,
|
|
176
|
+
customStateDir,
|
|
177
|
+
packageRoot,
|
|
178
|
+
dryRun,
|
|
179
|
+
selectedRoots: params.selectedRoots,
|
|
180
|
+
destinationGroups: params.destinationGroups,
|
|
181
|
+
registry: params.registry,
|
|
182
|
+
env,
|
|
183
|
+
method: params.method,
|
|
184
|
+
copyRoots: params.copyRoots,
|
|
185
|
+
resolutions: params.resolutions,
|
|
186
|
+
adoptChanged: params.adoptChanged,
|
|
187
|
+
adoptLegacy: params.adoptLegacy,
|
|
188
|
+
adoptUnverified: params.adoptUnverified,
|
|
189
|
+
adoptMalformed: params.adoptMalformed,
|
|
190
|
+
exportDir: params.exportDir,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
if (plan.unownedConflict) throw createUnownedConflictError(plan);
|
|
194
|
+
|
|
195
|
+
if (dryRun) {
|
|
196
|
+
return {
|
|
197
|
+
success: true,
|
|
198
|
+
dryRun: true,
|
|
199
|
+
plan,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (plan.requiresApproval) throw createNeedsResolutionError(plan);
|
|
204
|
+
|
|
205
|
+
const releaseLock = acquireConcurrencyLock(root, customStateDir);
|
|
206
|
+
|
|
207
|
+
const stagingParent = path.join(root, '.agents', '.sigma-staging');
|
|
208
|
+
const stagingDir = path.join(
|
|
209
|
+
stagingParent,
|
|
210
|
+
`${skillId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
const committed = [];
|
|
214
|
+
const privateBackups = [];
|
|
215
|
+
|
|
216
|
+
const useProjectLock = scope !== 'global';
|
|
217
|
+
const lockPath = path.join(root, PROJECT_LOCK_FILENAME);
|
|
218
|
+
const lockExisted = useProjectLock && fs.existsSync(lockPath);
|
|
219
|
+
const originalLockBytes = lockExisted ? fs.readFileSync(lockPath) : null;
|
|
220
|
+
const originalLock = useProjectLock ? loadProjectLock(root) : { skills: {} };
|
|
221
|
+
|
|
222
|
+
const statePath = scope === 'global'
|
|
223
|
+
? getGlobalStatePath(root, customStateDir)
|
|
224
|
+
: getProjectStatePath(root, customStateDir);
|
|
225
|
+
const stateExisted = fs.existsSync(statePath);
|
|
226
|
+
const originalStateBytes = stateExisted ? fs.readFileSync(statePath) : null;
|
|
227
|
+
const originalState = scope === 'global'
|
|
228
|
+
? loadGlobalState(root, customStateDir)
|
|
229
|
+
: loadProjectState(root, customStateDir);
|
|
230
|
+
const persistState = params.saveState || (scope === 'global' ? saveGlobalState : saveProjectState);
|
|
231
|
+
const stateDirForBackups = scope === 'global'
|
|
232
|
+
? getGlobalStateDir(root, customStateDir)
|
|
233
|
+
: getProjectStateDir(root, customStateDir);
|
|
234
|
+
|
|
235
|
+
const cleanupStaging = () => {
|
|
236
|
+
try {
|
|
237
|
+
if (fs.existsSync(stagingDir)) {
|
|
238
|
+
fs.rmSync(stagingDir, { recursive: true, force: true });
|
|
239
|
+
}
|
|
240
|
+
if (fs.existsSync(stagingParent) && fs.readdirSync(stagingParent).length === 0) {
|
|
241
|
+
fs.rmSync(stagingParent, { recursive: true, force: true });
|
|
242
|
+
}
|
|
243
|
+
} catch {
|
|
244
|
+
// Staging cleanup is best-effort
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const rollback = () => {
|
|
249
|
+
try {
|
|
250
|
+
for (const entry of committed.splice(0).reverse()) {
|
|
251
|
+
if (entry.adopted) continue;
|
|
252
|
+
try {
|
|
253
|
+
if (entry.backupDir && pathExists(entry.backupDir)) {
|
|
254
|
+
if (pathExists(entry.destDir)) {
|
|
255
|
+
removeManagedPath(entry.destDir);
|
|
256
|
+
}
|
|
257
|
+
fs.renameSync(entry.backupDir, entry.destDir);
|
|
258
|
+
} else if (pathExists(entry.destDir)) {
|
|
259
|
+
removeManagedPath(entry.destDir);
|
|
260
|
+
}
|
|
261
|
+
} catch {
|
|
262
|
+
// Per-destination rollback is best-effort
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
for (const backupPath of privateBackups.splice(0)) {
|
|
266
|
+
try {
|
|
267
|
+
if (pathExists(backupPath)) removeManagedPath(backupPath);
|
|
268
|
+
} catch {
|
|
269
|
+
// Private backup cleanup is best-effort
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
// Restore state and lock or remove if they didn't exist before
|
|
273
|
+
if (stateExisted && originalStateBytes) {
|
|
274
|
+
fs.writeFileSync(statePath, originalStateBytes);
|
|
275
|
+
} else if (fs.existsSync(statePath)) {
|
|
276
|
+
fs.unlinkSync(statePath);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (useProjectLock && lockExisted && originalLockBytes) {
|
|
280
|
+
fs.writeFileSync(lockPath, originalLockBytes);
|
|
281
|
+
} else if (useProjectLock && fs.existsSync(lockPath)) {
|
|
282
|
+
fs.unlinkSync(lockPath);
|
|
283
|
+
}
|
|
284
|
+
} catch {
|
|
285
|
+
// Rollback is best-effort
|
|
286
|
+
} finally {
|
|
287
|
+
cleanupStaging();
|
|
288
|
+
releaseLock();
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
// Register signal listeners during transaction
|
|
293
|
+
const signalHandler = () => {
|
|
294
|
+
rollback();
|
|
295
|
+
process.exit(130);
|
|
296
|
+
};
|
|
297
|
+
process.once('SIGINT', signalHandler);
|
|
298
|
+
process.once('SIGTERM', signalHandler);
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
const willWrite = (dest) => (
|
|
302
|
+
!dest.adoption
|
|
303
|
+
&& dest.resolution !== 'skip'
|
|
304
|
+
&& dest.resolution !== 'export'
|
|
305
|
+
);
|
|
306
|
+
const allAdopted = plan.destinations.every((dest) => dest.adoption);
|
|
307
|
+
const needsSkillWrite = plan.destinations.some(willWrite);
|
|
308
|
+
const existingEntry = originalState.skills?.[skillId];
|
|
309
|
+
const existingDests = new Set(
|
|
310
|
+
(existingEntry?.copies || []).map((copy) => String(copy.destination || '').replace(/\\/g, '/')),
|
|
311
|
+
);
|
|
312
|
+
const plannedDests = plan.destinations.map((dest) => dest.relativeDestination);
|
|
313
|
+
const sameManagedLayout = existingEntry
|
|
314
|
+
&& existingEntry.revision === plan.sourceRevision
|
|
315
|
+
&& plannedDests.every((dest) => existingDests.has(dest))
|
|
316
|
+
&& existingDests.size === plannedDests.length
|
|
317
|
+
&& (scope === 'global' || originalLock.skills?.[skillId]?.revision === plan.sourceRevision);
|
|
318
|
+
if (allAdopted && sameManagedLayout) {
|
|
319
|
+
cleanupStaging();
|
|
320
|
+
releaseLock();
|
|
321
|
+
return {
|
|
322
|
+
success: true,
|
|
323
|
+
dryRun: false,
|
|
324
|
+
plan,
|
|
325
|
+
lock: originalLock,
|
|
326
|
+
state: originalState,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const catalogSkill = catalog.skills.find((item) => item.id === skillId);
|
|
331
|
+
let fileHashes = catalogSkill?.files || {};
|
|
332
|
+
|
|
333
|
+
if (needsSkillWrite) {
|
|
334
|
+
fs.mkdirSync(stagingDir, { recursive: true });
|
|
335
|
+
const sourceSkillDir = path.join(packageRoot, skillId);
|
|
336
|
+
if (!fs.existsSync(sourceSkillDir)) {
|
|
337
|
+
throw new Error(`source skill directory missing at ${sourceSkillDir}`);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
fs.cpSync(sourceSkillDir, stagingDir, { recursive: true });
|
|
341
|
+
|
|
342
|
+
const manifestMetadata = catalog.manifest.skills.find((s) => s.id === skillId);
|
|
343
|
+
const validatedStaged = validateSkill(stagingDir, manifestMetadata);
|
|
344
|
+
if (validatedStaged.revision !== plan.sourceRevision) {
|
|
345
|
+
throw new Error(
|
|
346
|
+
`staged skill revision '${validatedStaged.revision}' does not match catalog revision '${plan.sourceRevision}'`,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
fileHashes = validatedStaged.files;
|
|
350
|
+
if (params.preservedCustomRaw !== undefined) {
|
|
351
|
+
const stagedSkillMd = path.join(stagingDir, 'SKILL.md');
|
|
352
|
+
if (pathExists(stagedSkillMd)) {
|
|
353
|
+
const stagedMarkdown = fs.readFileSync(stagedSkillMd, 'utf8');
|
|
354
|
+
fs.writeFileSync(
|
|
355
|
+
stagedSkillMd,
|
|
356
|
+
injectRawCustomContent(stagedMarkdown, params.preservedCustomRaw, skillId),
|
|
357
|
+
'utf8',
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const createLink = params.createLink || ((linkPath, targetPath) => (
|
|
364
|
+
createSkillLink(linkPath, targetPath, root)
|
|
365
|
+
));
|
|
366
|
+
const canonicalDest = plan.destinations.find((dest) => dest.kind === 'canonical')
|
|
367
|
+
|| plan.destinations[0];
|
|
368
|
+
|
|
369
|
+
const restoreBackup = (destDir, backupDir) => {
|
|
370
|
+
if (backupDir && pathExists(backupDir)) {
|
|
371
|
+
if (pathExists(destDir)) removeManagedPath(destDir);
|
|
372
|
+
fs.renameSync(backupDir, destDir);
|
|
373
|
+
} else if (pathExists(destDir)) {
|
|
374
|
+
removeManagedPath(destDir);
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
const prepareDestination = (destDir) => {
|
|
379
|
+
const destParent = path.dirname(destDir);
|
|
380
|
+
if (!fs.existsSync(destParent)) {
|
|
381
|
+
fs.mkdirSync(destParent, { recursive: true });
|
|
382
|
+
}
|
|
383
|
+
let backupDir = null;
|
|
384
|
+
if (pathExists(destDir)) {
|
|
385
|
+
backupDir = path.join(
|
|
386
|
+
destParent,
|
|
387
|
+
`.${skillId}-backup-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
388
|
+
);
|
|
389
|
+
fs.renameSync(destDir, backupDir);
|
|
390
|
+
}
|
|
391
|
+
return backupDir;
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
const commitCopy = (destDir) => {
|
|
395
|
+
const backupDir = prepareDestination(destDir);
|
|
396
|
+
try {
|
|
397
|
+
fs.cpSync(stagingDir, destDir, { recursive: true });
|
|
398
|
+
committed.push({ destDir, backupDir });
|
|
399
|
+
} catch (copyErr) {
|
|
400
|
+
restoreBackup(destDir, backupDir);
|
|
401
|
+
throw new Error(`failed to write destination '${destDir}': ${copyErr.message}`);
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
const commitLink = (dest) => {
|
|
406
|
+
const destDir = dest.destination;
|
|
407
|
+
const backupDir = prepareDestination(destDir);
|
|
408
|
+
try {
|
|
409
|
+
createLink(destDir, canonicalDest.destination, root);
|
|
410
|
+
committed.push({ destDir, backupDir });
|
|
411
|
+
} catch (linkErr) {
|
|
412
|
+
restoreBackup(destDir, backupDir);
|
|
413
|
+
const failure = {
|
|
414
|
+
destination: dest.destination,
|
|
415
|
+
relativeDestination: dest.relativeDestination,
|
|
416
|
+
relativeRoot: dest.relativeRoot,
|
|
417
|
+
method: dest.method,
|
|
418
|
+
cause: linkErr.message,
|
|
419
|
+
code: linkErr.code,
|
|
420
|
+
};
|
|
421
|
+
const decision = typeof params.onLinkFailure === 'function'
|
|
422
|
+
? params.onLinkFailure(failure)
|
|
423
|
+
: 'abort';
|
|
424
|
+
if (decision !== 'copy') {
|
|
425
|
+
const wrapped = new Error(linkErr.message);
|
|
426
|
+
wrapped.cause = linkErr;
|
|
427
|
+
wrapped.code = linkErr.code;
|
|
428
|
+
wrapped.linkFailure = failure;
|
|
429
|
+
throw wrapped;
|
|
430
|
+
}
|
|
431
|
+
dest.fallbackFrom = dest.method;
|
|
432
|
+
dest.method = 'copy';
|
|
433
|
+
dest.dependsOn = null;
|
|
434
|
+
commitCopy(destDir);
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
const ordered = [...plan.destinations].sort((a, b) => {
|
|
439
|
+
if (a.kind === 'canonical' && b.kind !== 'canonical') return -1;
|
|
440
|
+
if (b.kind === 'canonical' && a.kind !== 'canonical') return 1;
|
|
441
|
+
return 0;
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
for (const dest of ordered) {
|
|
445
|
+
if (dest.adoption) {
|
|
446
|
+
committed.push({ destDir: dest.destination, backupDir: null, adopted: true });
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
if (dest.resolution === 'skip') {
|
|
450
|
+
dest.exportPath = null;
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
if (dest.resolution === 'export') {
|
|
454
|
+
const exportRoot = plan.exportDir || path.join(root, '.sigma-export');
|
|
455
|
+
const exporter = params.exportSkill || exportSkillTree;
|
|
456
|
+
dest.exportPath = exporter({
|
|
457
|
+
sourceDir: dest.destination,
|
|
458
|
+
exportRoot,
|
|
459
|
+
skillId,
|
|
460
|
+
dest: dest.exportPath,
|
|
461
|
+
});
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
if (dest.resolution === 'replace' && pathExists(dest.destination)) {
|
|
465
|
+
const backupFn = params.backupSkill || commitSkillBackup;
|
|
466
|
+
const existing = originalState.skills?.[skillId];
|
|
467
|
+
const privateBackup = backupFn({
|
|
468
|
+
stateDir: stateDirForBackups,
|
|
469
|
+
skillId,
|
|
470
|
+
sourceDir: dest.destination,
|
|
471
|
+
ownership: {
|
|
472
|
+
scope,
|
|
473
|
+
release: existing?.release || plan.release || null,
|
|
474
|
+
revision: existing?.revision || null,
|
|
475
|
+
method: existing?.method || dest.method,
|
|
476
|
+
canonicalTarget: existing?.destination || dest.relativeDestination,
|
|
477
|
+
copies: existing?.copies || [],
|
|
478
|
+
ownedPaths: existing?.ownedPaths || [dest.relativeDestination],
|
|
479
|
+
},
|
|
480
|
+
});
|
|
481
|
+
privateBackups.push(privateBackup);
|
|
482
|
+
dest.privateBackup = privateBackup;
|
|
483
|
+
if (typeof params.afterBackup === 'function') {
|
|
484
|
+
params.afterBackup(privateBackup);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
if (dest.method === 'copy') commitCopy(dest.destination);
|
|
488
|
+
else commitLink(dest);
|
|
489
|
+
const customStatus = dest.customization?.status;
|
|
490
|
+
if (
|
|
491
|
+
params.preservedCustomRaw === undefined
|
|
492
|
+
&& dest.resolution === 'replace'
|
|
493
|
+
&& (customStatus === 'valid' || customStatus === 'empty')
|
|
494
|
+
) {
|
|
495
|
+
const skillMd = path.join(dest.destination, 'SKILL.md');
|
|
496
|
+
if (pathExists(skillMd)) {
|
|
497
|
+
const current = fs.readFileSync(skillMd, 'utf8');
|
|
498
|
+
fs.writeFileSync(
|
|
499
|
+
skillMd,
|
|
500
|
+
injectCustomContent(current, dest.customization.customContent || '', skillId),
|
|
501
|
+
'utf8',
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const copies = plan.destinations
|
|
508
|
+
.filter((dest) => dest.adoption || dest.resolution === 'replace' || (!dest.migratable && dest.resolution !== 'skip' && dest.resolution !== 'export'))
|
|
509
|
+
.map((dest) => {
|
|
510
|
+
const independent = dest.method === 'copy';
|
|
511
|
+
return {
|
|
512
|
+
kind: dest.kind,
|
|
513
|
+
destination: dest.relativeDestination,
|
|
514
|
+
method: dest.method,
|
|
515
|
+
dependsOn: dest.dependsOn || null,
|
|
516
|
+
hostIds: (dest.hosts || []).map((host) => host.id),
|
|
517
|
+
ownedPaths: independent
|
|
518
|
+
? plan.files.map((file) => `${dest.relativeDestination}/${file}`)
|
|
519
|
+
: [dest.relativeDestination],
|
|
520
|
+
...(independent ? { baseHashes: dest.baseHashes && dest.resolution !== 'replace' ? dest.baseHashes : fileHashes } : {}),
|
|
521
|
+
};
|
|
522
|
+
});
|
|
523
|
+
if (copies.length === 0) {
|
|
524
|
+
cleanupStaging();
|
|
525
|
+
releaseLock();
|
|
526
|
+
return {
|
|
527
|
+
success: true,
|
|
528
|
+
dryRun: false,
|
|
529
|
+
plan,
|
|
530
|
+
lock: originalLock,
|
|
531
|
+
state: originalState,
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
const primary = copies.find((copy) => copy.kind === 'canonical') || copies[0];
|
|
535
|
+
|
|
536
|
+
const lastBackup = privateBackups.length > 0
|
|
537
|
+
? path.relative(stateDirForBackups, privateBackups[privateBackups.length - 1]).replace(/\\/g, '/')
|
|
538
|
+
: undefined;
|
|
539
|
+
const updatedState = recordSkillInState(originalState, {
|
|
540
|
+
skillId,
|
|
541
|
+
release: plan.release,
|
|
542
|
+
revision: plan.sourceRevision,
|
|
543
|
+
method: plan.method,
|
|
544
|
+
destination: primary.destination,
|
|
545
|
+
projectRoot: root,
|
|
546
|
+
ownedPaths: primary.ownedPaths,
|
|
547
|
+
baseHashes: primary.baseHashes || fileHashes,
|
|
548
|
+
copies,
|
|
549
|
+
scope,
|
|
550
|
+
lastBackup,
|
|
551
|
+
cleanupDebt: [],
|
|
552
|
+
});
|
|
553
|
+
persistState(root, updatedState, customStateDir);
|
|
554
|
+
|
|
555
|
+
let updatedLock = originalLock;
|
|
556
|
+
if (useProjectLock) {
|
|
557
|
+
updatedLock = updateProjectLockSkill(
|
|
558
|
+
originalLock,
|
|
559
|
+
skillId,
|
|
560
|
+
plan.sourceRevision,
|
|
561
|
+
plan.release,
|
|
562
|
+
);
|
|
563
|
+
saveProjectLock(root, updatedLock);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// 5. Cleanup backups after state & lock write succeed
|
|
567
|
+
for (const entry of committed) {
|
|
568
|
+
if (entry.backupDir && pathExists(entry.backupDir)) {
|
|
569
|
+
removeManagedPath(entry.backupDir);
|
|
570
|
+
entry.backupDir = null;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
for (const backupPath of privateBackups) {
|
|
574
|
+
let debt = [];
|
|
575
|
+
try {
|
|
576
|
+
const pruneFn = params.pruneBackups || pruneOlderBackups;
|
|
577
|
+
const result = pruneFn({
|
|
578
|
+
stateDir: stateDirForBackups,
|
|
579
|
+
skillId,
|
|
580
|
+
keepPath: backupPath,
|
|
581
|
+
});
|
|
582
|
+
if (result && Array.isArray(result.debt)) debt = result.debt;
|
|
583
|
+
} catch {
|
|
584
|
+
const dir = path.join(getBackupRoot(stateDirForBackups), skillId);
|
|
585
|
+
if (pathExists(dir)) {
|
|
586
|
+
const keep = path.resolve(backupPath);
|
|
587
|
+
for (const name of fs.readdirSync(dir)) {
|
|
588
|
+
const full = path.resolve(dir, name);
|
|
589
|
+
if (full !== keep) {
|
|
590
|
+
debt.push(path.relative(stateDirForBackups, full).replace(/\\/g, '/'));
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
if (debt.length > 0) {
|
|
596
|
+
try {
|
|
597
|
+
persistState(root, recordSkillInState(updatedState, {
|
|
598
|
+
skillId,
|
|
599
|
+
release: plan.release,
|
|
600
|
+
revision: plan.sourceRevision,
|
|
601
|
+
method: plan.method,
|
|
602
|
+
destination: primary.destination,
|
|
603
|
+
projectRoot: root,
|
|
604
|
+
ownedPaths: primary.ownedPaths,
|
|
605
|
+
baseHashes: primary.baseHashes || fileHashes,
|
|
606
|
+
copies,
|
|
607
|
+
scope,
|
|
608
|
+
lastBackup,
|
|
609
|
+
cleanupDebt: debt,
|
|
610
|
+
}), customStateDir);
|
|
611
|
+
} catch {
|
|
612
|
+
// Two backups remain; recording debt is best-effort.
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
cleanupStaging();
|
|
618
|
+
releaseLock();
|
|
619
|
+
|
|
620
|
+
return {
|
|
621
|
+
success: true,
|
|
622
|
+
dryRun: false,
|
|
623
|
+
plan,
|
|
624
|
+
lock: updatedLock,
|
|
625
|
+
state: updatedState,
|
|
626
|
+
};
|
|
627
|
+
} catch (err) {
|
|
628
|
+
rollback();
|
|
629
|
+
throw err;
|
|
630
|
+
} finally {
|
|
631
|
+
process.removeListener('SIGINT', signalHandler);
|
|
632
|
+
process.removeListener('SIGTERM', signalHandler);
|
|
633
|
+
cleanupStaging();
|
|
634
|
+
releaseLock();
|
|
635
|
+
}
|
|
636
|
+
}
|