@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,169 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const PROJECT_LOCK_FILENAME = 'skills-lock.json';
|
|
5
|
+
export const PROJECT_LOCK_SCHEMA_VERSION = 1;
|
|
6
|
+
|
|
7
|
+
function emptyProjectLock() {
|
|
8
|
+
return {
|
|
9
|
+
schemaVersion: PROJECT_LOCK_SCHEMA_VERSION,
|
|
10
|
+
release: null,
|
|
11
|
+
skills: {},
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function isGenericLock(parsed) {
|
|
16
|
+
return !parsed || typeof parsed !== 'object' || typeof parsed.schemaVersion !== 'number';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Inspect a project lockfile without treating generic CLI locks as Sigma ownership.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} projectRoot
|
|
23
|
+
* @returns {{ kind: 'missing' | 'sigma' | 'generic', lock: object }}
|
|
24
|
+
*/
|
|
25
|
+
export function inspectProjectLock(projectRoot) {
|
|
26
|
+
const lockPath = path.join(projectRoot, PROJECT_LOCK_FILENAME);
|
|
27
|
+
if (!fs.existsSync(lockPath)) {
|
|
28
|
+
return { kind: 'missing', lock: emptyProjectLock() };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let parsed;
|
|
32
|
+
try {
|
|
33
|
+
parsed = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
|
|
34
|
+
} catch (err) {
|
|
35
|
+
throw new Error(`failed to read project lockfile at ${lockPath}: ${err.message}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (isGenericLock(parsed)) {
|
|
39
|
+
return { kind: 'generic', lock: emptyProjectLock() };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
validateProjectLock(parsed);
|
|
43
|
+
return { kind: 'sigma', lock: parsed };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Load the Sigma project lock. Generic `skills-lock.json` files yield an empty Sigma lock.
|
|
48
|
+
*
|
|
49
|
+
* @param {string} projectRoot
|
|
50
|
+
* @returns {object}
|
|
51
|
+
*/
|
|
52
|
+
export function loadProjectLock(projectRoot) {
|
|
53
|
+
return inspectProjectLock(projectRoot).lock;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Validate that a project lock structure complies with repo contracts:
|
|
58
|
+
* - schemaVersion is a positive integer
|
|
59
|
+
* - skills is an object
|
|
60
|
+
* - each skill entry contains only revision (no machine paths, no timestamps)
|
|
61
|
+
*
|
|
62
|
+
* @param {object} lock
|
|
63
|
+
*/
|
|
64
|
+
export function validateProjectLock(lock) {
|
|
65
|
+
if (!lock || typeof lock !== 'object') {
|
|
66
|
+
throw new Error('invalid project lock: expected JSON object');
|
|
67
|
+
}
|
|
68
|
+
if (typeof lock.schemaVersion !== 'number' || lock.schemaVersion < 1) {
|
|
69
|
+
throw new Error('invalid project lock: missing or invalid schemaVersion');
|
|
70
|
+
}
|
|
71
|
+
if (lock.release !== null && typeof lock.release !== 'string') {
|
|
72
|
+
throw new Error('invalid project lock: release must be string or null');
|
|
73
|
+
}
|
|
74
|
+
if (!lock.skills || typeof lock.skills !== 'object' || Array.isArray(lock.skills)) {
|
|
75
|
+
throw new Error('invalid project lock: skills must be an object');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (const [skillId, skillData] of Object.entries(lock.skills)) {
|
|
79
|
+
if (!skillData || typeof skillData !== 'object') {
|
|
80
|
+
throw new Error(`invalid project lock skill entry for '${skillId}'`);
|
|
81
|
+
}
|
|
82
|
+
if (!skillData.revision || typeof skillData.revision !== 'string') {
|
|
83
|
+
throw new Error(`invalid project lock skill '${skillId}': missing revision string`);
|
|
84
|
+
}
|
|
85
|
+
// Prohibit machine paths or timestamps in lockfile
|
|
86
|
+
if ('path' in skillData || 'installedAt' in skillData || 'updatedAt' in skillData || 'destination' in skillData) {
|
|
87
|
+
throw new Error(`invalid project lock skill '${skillId}': lock must not contain paths or timestamps`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Serialize and atomically write project lockfile (skills-lock.json) with sorted keys.
|
|
94
|
+
*
|
|
95
|
+
* @param {string} projectRoot
|
|
96
|
+
* @param {object} lock
|
|
97
|
+
*/
|
|
98
|
+
export function saveProjectLock(projectRoot, lock) {
|
|
99
|
+
validateProjectLock(lock);
|
|
100
|
+
|
|
101
|
+
// Sort skill keys alphabetically for deterministic git-merge friendly format
|
|
102
|
+
const sortedSkills = {};
|
|
103
|
+
const skillKeys = Object.keys(lock.skills || {}).sort();
|
|
104
|
+
for (const key of skillKeys) {
|
|
105
|
+
sortedSkills[key] = {
|
|
106
|
+
revision: lock.skills[key].revision,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const cleanLock = {
|
|
111
|
+
schemaVersion: lock.schemaVersion || PROJECT_LOCK_SCHEMA_VERSION,
|
|
112
|
+
release: lock.release || null,
|
|
113
|
+
skills: sortedSkills,
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const content = JSON.stringify(cleanLock, null, 2) + '\n';
|
|
117
|
+
const lockPath = path.join(projectRoot, PROJECT_LOCK_FILENAME);
|
|
118
|
+
const tempPath = path.join(projectRoot, `${PROJECT_LOCK_FILENAME}.tmp.${process.pid}.${Date.now()}`);
|
|
119
|
+
|
|
120
|
+
fs.writeFileSync(tempPath, content, 'utf8');
|
|
121
|
+
try {
|
|
122
|
+
fs.renameSync(tempPath, lockPath);
|
|
123
|
+
} catch (err) {
|
|
124
|
+
try {
|
|
125
|
+
fs.copyFileSync(tempPath, lockPath);
|
|
126
|
+
fs.unlinkSync(tempPath);
|
|
127
|
+
} catch {
|
|
128
|
+
throw err;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Update or add a skill in the project lock.
|
|
135
|
+
*
|
|
136
|
+
* @param {object} currentLock
|
|
137
|
+
* @param {string} skillId
|
|
138
|
+
* @param {string} revision
|
|
139
|
+
* @param {string} release
|
|
140
|
+
* @returns {object} Updated lock object
|
|
141
|
+
*/
|
|
142
|
+
export function updateProjectLockSkill(currentLock, skillId, revision, release) {
|
|
143
|
+
const nextSkills = { ...(currentLock.skills || {}) };
|
|
144
|
+
nextSkills[skillId] = { revision };
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
schemaVersion: currentLock.schemaVersion || PROJECT_LOCK_SCHEMA_VERSION,
|
|
148
|
+
release: release || currentLock.release,
|
|
149
|
+
skills: nextSkills,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Drop a skill from the project lock after a successful uninstall.
|
|
155
|
+
*
|
|
156
|
+
* @param {object} currentLock
|
|
157
|
+
* @param {string} skillId
|
|
158
|
+
* @returns {object}
|
|
159
|
+
*/
|
|
160
|
+
export function removeProjectLockSkill(currentLock, skillId) {
|
|
161
|
+
const nextSkills = { ...(currentLock.skills || {}) };
|
|
162
|
+
delete nextSkills[skillId];
|
|
163
|
+
const remaining = Object.keys(nextSkills);
|
|
164
|
+
return {
|
|
165
|
+
schemaVersion: currentLock.schemaVersion || PROJECT_LOCK_SCHEMA_VERSION,
|
|
166
|
+
release: remaining.length > 0 ? (currentLock.release || null) : null,
|
|
167
|
+
skills: nextSkills,
|
|
168
|
+
};
|
|
169
|
+
}
|
package/src/purge.js
ADDED
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { copySkillTree, getBackupRoot } from './backup.js';
|
|
4
|
+
import { resolveHomeDir } from './destinations.js';
|
|
5
|
+
import { createSkillLink, inspectManagedPath, pathExists, removeManagedPath } from './links.js';
|
|
6
|
+
import { inspectProjectLock, PROJECT_LOCK_FILENAME } from './project-lock.js';
|
|
7
|
+
import {
|
|
8
|
+
STATE_FILENAME,
|
|
9
|
+
getGlobalStateDir,
|
|
10
|
+
getProjectStateDir,
|
|
11
|
+
} from './state.js';
|
|
12
|
+
import { acquireConcurrencyLock } from './transaction.js';
|
|
13
|
+
import { createUninstallPlan, UNINSTALL_JOURNAL_FILENAME } from './uninstall.js';
|
|
14
|
+
|
|
15
|
+
export const PURGE_SCHEMA_VERSION = 1;
|
|
16
|
+
export const PURGE_JOURNAL_FILENAME = 'purge-journal.json';
|
|
17
|
+
export const PURGE_QUARANTINE_DIRNAME = '.sigma-purge-quarantine';
|
|
18
|
+
export const PURGE_CONFIRMATION_PHRASE = 'purge SigmaSkills';
|
|
19
|
+
|
|
20
|
+
const PRIVATE_STAGING = [
|
|
21
|
+
'.sigma-uninstall-staging',
|
|
22
|
+
'.sigma-restore-staging',
|
|
23
|
+
'.sigma-staging',
|
|
24
|
+
];
|
|
25
|
+
const HARD_STOP = new Set(['unowned', 'wrong-target', 'broken-link', 'copy-disagreement', 'stale-state']);
|
|
26
|
+
|
|
27
|
+
function codedError(message, code) {
|
|
28
|
+
const err = new Error(message);
|
|
29
|
+
err.code = code;
|
|
30
|
+
return err;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function resolveRoot(options) {
|
|
34
|
+
const scope = options.scope || 'project';
|
|
35
|
+
if (scope === 'global') return path.resolve(options.homeDir || resolveHomeDir(options.env || process.env));
|
|
36
|
+
return path.resolve(options.projectRoot || process.cwd());
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function resolveStateDir(scope, root, customStateDir) {
|
|
40
|
+
return scope === 'global'
|
|
41
|
+
? getGlobalStateDir(root, customStateDir)
|
|
42
|
+
: getProjectStateDir(root, customStateDir);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function posix(value) {
|
|
46
|
+
return String(value || '').replace(/\\/g, '/');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function relativeToRoot(root, absolutePath) {
|
|
50
|
+
const rel = path.relative(root, absolutePath);
|
|
51
|
+
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
52
|
+
return posix(path.basename(absolutePath));
|
|
53
|
+
}
|
|
54
|
+
return posix(rel);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function deletionOrder(destinations) {
|
|
58
|
+
const links = destinations.filter((dest) => dest.method && dest.method !== 'copy');
|
|
59
|
+
const hostCopies = destinations.filter((dest) => dest.method === 'copy' && dest.kind !== 'canonical');
|
|
60
|
+
const canonical = destinations.filter((dest) => dest.kind === 'canonical');
|
|
61
|
+
const rest = destinations.filter((dest) => !links.includes(dest) && !hostCopies.includes(dest) && !canonical.includes(dest));
|
|
62
|
+
return [...links, ...hostCopies, ...rest, ...canonical];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function readJsonIfPresent(file) {
|
|
66
|
+
if (!pathExists(file)) return null;
|
|
67
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function persistJson(file, payload) {
|
|
71
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
72
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
73
|
+
fs.writeFileSync(tmp, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
|
74
|
+
fs.renameSync(tmp, file);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function relocate(source, dest) {
|
|
78
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
79
|
+
try {
|
|
80
|
+
fs.renameSync(source, dest);
|
|
81
|
+
return;
|
|
82
|
+
} catch (err) {
|
|
83
|
+
if (err.code !== 'EXDEV') throw err;
|
|
84
|
+
}
|
|
85
|
+
if (!pathExists(source)) return;
|
|
86
|
+
const stat = fs.lstatSync(source);
|
|
87
|
+
if (stat.isSymbolicLink()) {
|
|
88
|
+
fs.symlinkSync(fs.readlinkSync(source), dest, process.platform === 'win32' ? 'junction' : 'dir');
|
|
89
|
+
fs.unlinkSync(source);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (stat.isFile()) {
|
|
93
|
+
fs.copyFileSync(source, dest);
|
|
94
|
+
fs.unlinkSync(source);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
copySkillTree(source, dest);
|
|
98
|
+
removeManagedPath(source);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function restoreQuarantined(root, items) {
|
|
102
|
+
for (const item of items.slice().reverse()) {
|
|
103
|
+
if (item.status !== 'quarantined' || !item.quarantinePath || !pathExists(item.quarantinePath)) continue;
|
|
104
|
+
try {
|
|
105
|
+
if (item.kind === 'link') {
|
|
106
|
+
if (!pathExists(item.absolutePath) && item.target && pathExists(item.target)) {
|
|
107
|
+
createSkillLink(item.absolutePath, item.target, root);
|
|
108
|
+
removeManagedPath(item.quarantinePath);
|
|
109
|
+
} else if (!pathExists(item.absolutePath)) {
|
|
110
|
+
relocate(item.quarantinePath, item.absolutePath);
|
|
111
|
+
}
|
|
112
|
+
} else if (!pathExists(item.absolutePath)) {
|
|
113
|
+
relocate(item.quarantinePath, item.absolutePath);
|
|
114
|
+
}
|
|
115
|
+
item.status = 'pending';
|
|
116
|
+
item.quarantinePath = null;
|
|
117
|
+
} catch {
|
|
118
|
+
// Continue restoring remaining leaves.
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function collectPrivateItems(root, stateDir) {
|
|
124
|
+
const items = [];
|
|
125
|
+
const backupRoot = getBackupRoot(stateDir);
|
|
126
|
+
if (pathExists(backupRoot)) {
|
|
127
|
+
items.push({
|
|
128
|
+
kind: 'backups',
|
|
129
|
+
relative: relativeToRoot(root, backupRoot),
|
|
130
|
+
absolutePath: backupRoot,
|
|
131
|
+
status: 'pending',
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
for (const name of PRIVATE_STAGING) {
|
|
135
|
+
const absolutePath = path.join(stateDir, name);
|
|
136
|
+
if (pathExists(absolutePath)) {
|
|
137
|
+
items.push({ kind: 'staging', relative: name, absolutePath, status: 'pending' });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
for (const name of [UNINSTALL_JOURNAL_FILENAME, PURGE_JOURNAL_FILENAME]) {
|
|
141
|
+
const absolutePath = path.join(stateDir, name);
|
|
142
|
+
if (pathExists(absolutePath)) {
|
|
143
|
+
items.push({ kind: 'journal', relative: name, absolutePath, status: 'pending' });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const lockPath = path.join(stateDir, '.sigma.lock');
|
|
147
|
+
if (pathExists(lockPath)) {
|
|
148
|
+
items.push({ kind: 'lock', relative: '.sigma.lock', absolutePath: lockPath, status: 'pending' });
|
|
149
|
+
}
|
|
150
|
+
const manifestPath = path.join(stateDir, STATE_FILENAME);
|
|
151
|
+
if (pathExists(manifestPath)) {
|
|
152
|
+
items.push({
|
|
153
|
+
kind: 'manifest',
|
|
154
|
+
relative: relativeToRoot(root, manifestPath),
|
|
155
|
+
absolutePath: manifestPath,
|
|
156
|
+
status: 'pending',
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return items;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function findResumeJournal(stateDir) {
|
|
163
|
+
const primary = path.join(stateDir, PURGE_JOURNAL_FILENAME);
|
|
164
|
+
const nested = path.join(stateDir, PURGE_QUARANTINE_DIRNAME, PURGE_JOURNAL_FILENAME);
|
|
165
|
+
for (const file of [primary, nested]) {
|
|
166
|
+
const journal = readJsonIfPresent(file);
|
|
167
|
+
if (journal && journal.command === 'purge' && journal.status && journal.status !== 'complete') {
|
|
168
|
+
return { journal, file };
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Preview every revalidated Sigma-owned path in one Project or Global scope.
|
|
176
|
+
*
|
|
177
|
+
* @param {object} options
|
|
178
|
+
* @returns {object}
|
|
179
|
+
*/
|
|
180
|
+
export function createPurgePlan(options = {}) {
|
|
181
|
+
const scope = options.scope || 'project';
|
|
182
|
+
const root = resolveRoot(options);
|
|
183
|
+
const stateDir = resolveStateDir(scope, root, options.customStateDir);
|
|
184
|
+
const resume = findResumeJournal(stateDir);
|
|
185
|
+
if (resume?.journal?.items && (resume.journal.status === 'quarantined' || resume.journal.status === 'cleanup')) {
|
|
186
|
+
return {
|
|
187
|
+
schemaVersion: PURGE_SCHEMA_VERSION,
|
|
188
|
+
command: 'purge',
|
|
189
|
+
scope,
|
|
190
|
+
dryRun: Boolean(options.dryRun),
|
|
191
|
+
confirmationPhrase: PURGE_CONFIRMATION_PHRASE,
|
|
192
|
+
resumed: true,
|
|
193
|
+
blocked: [],
|
|
194
|
+
items: resume.journal.items,
|
|
195
|
+
skills: resume.journal.skills || [],
|
|
196
|
+
quarantineDir: resume.journal.quarantineDir,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const uninstallPlan = createUninstallPlan({
|
|
201
|
+
...options,
|
|
202
|
+
all: true,
|
|
203
|
+
yes: true,
|
|
204
|
+
});
|
|
205
|
+
const blocked = [];
|
|
206
|
+
const destinationItems = [];
|
|
207
|
+
for (const skill of uninstallPlan.skills) {
|
|
208
|
+
const reasons = (skill.blockedReasons || []).filter((reason) => HARD_STOP.has(reason));
|
|
209
|
+
if (reasons.length > 0) {
|
|
210
|
+
blocked.push({ id: skill.id, reasons });
|
|
211
|
+
}
|
|
212
|
+
for (const dest of deletionOrder(skill.destinations || [])) {
|
|
213
|
+
const kind = dest.method && dest.method !== 'copy' ? 'link' : 'tree';
|
|
214
|
+
destinationItems.push({
|
|
215
|
+
kind,
|
|
216
|
+
skillId: skill.id,
|
|
217
|
+
relative: dest.relativeDestination,
|
|
218
|
+
absolutePath: dest.absolutePath,
|
|
219
|
+
method: dest.method || null,
|
|
220
|
+
role: dest.kind || null,
|
|
221
|
+
dependsOn: dest.dependsOn || null,
|
|
222
|
+
missing: Boolean(dest.missing),
|
|
223
|
+
target: dest.method && dest.method !== 'copy' ? skill.canonicalAbs : null,
|
|
224
|
+
status: dest.missing ? 'absent' : 'pending',
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const items = [
|
|
230
|
+
...destinationItems,
|
|
231
|
+
...collectPrivateItems(root, stateDir),
|
|
232
|
+
];
|
|
233
|
+
if (scope === 'project') {
|
|
234
|
+
const lockInspect = inspectProjectLock(root);
|
|
235
|
+
if (lockInspect.kind === 'sigma') {
|
|
236
|
+
items.push({
|
|
237
|
+
kind: 'project-lock',
|
|
238
|
+
relative: PROJECT_LOCK_FILENAME,
|
|
239
|
+
absolutePath: path.join(root, PROJECT_LOCK_FILENAME),
|
|
240
|
+
status: 'pending',
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
schemaVersion: PURGE_SCHEMA_VERSION,
|
|
247
|
+
command: 'purge',
|
|
248
|
+
scope,
|
|
249
|
+
dryRun: Boolean(options.dryRun),
|
|
250
|
+
confirmationPhrase: PURGE_CONFIRMATION_PHRASE,
|
|
251
|
+
resumed: false,
|
|
252
|
+
blocked,
|
|
253
|
+
items,
|
|
254
|
+
skills: (uninstallPlan.skills || []).map((skill) => ({
|
|
255
|
+
id: skill.id,
|
|
256
|
+
destinations: skill.destinations,
|
|
257
|
+
blockedReasons: skill.blockedReasons,
|
|
258
|
+
})),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function assertConfirmed(options) {
|
|
263
|
+
const phrase = options.confirmPurge;
|
|
264
|
+
if (phrase === undefined || phrase === null) {
|
|
265
|
+
throw codedError(
|
|
266
|
+
'purge requires --confirm-purge with the typed confirmation phrase; --yes, CI, non-TTY, and JSON are not authority',
|
|
267
|
+
'confirmation',
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
if (String(phrase).trim() === '') {
|
|
271
|
+
throw codedError('purge cancelled', 'cancelled');
|
|
272
|
+
}
|
|
273
|
+
if (String(phrase) !== PURGE_CONFIRMATION_PHRASE) {
|
|
274
|
+
throw codedError(
|
|
275
|
+
`purge confirmation phrase is wrong; type exactly: ${PURGE_CONFIRMATION_PHRASE}`,
|
|
276
|
+
'confirmation',
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function revalidateDestinations(plan) {
|
|
282
|
+
for (const item of plan.items) {
|
|
283
|
+
if (item.kind !== 'link' && item.kind !== 'tree') continue;
|
|
284
|
+
if (!pathExists(item.absolutePath)) {
|
|
285
|
+
item.missing = true;
|
|
286
|
+
item.status = item.status === 'quarantined' ? item.status : 'absent';
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
const expected = item.kind === 'link' ? item.target : null;
|
|
290
|
+
const inspect = inspectManagedPath(item.absolutePath, expected);
|
|
291
|
+
const recordedLink = item.kind === 'link';
|
|
292
|
+
const liveLink = inspect.method && inspect.method !== 'copy';
|
|
293
|
+
if (inspect.wrongTarget || Boolean(recordedLink) !== Boolean(liveLink)) {
|
|
294
|
+
throw codedError(
|
|
295
|
+
`unowned occupant stopped purge at '${item.relative}'`,
|
|
296
|
+
inspect.wrongTarget ? 'wrong-target' : 'unowned',
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
if (inspect.broken) {
|
|
300
|
+
throw codedError(`broken-link stopped purge at '${item.relative}'`, 'broken-link');
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function journalPayload(plan, extra) {
|
|
306
|
+
return {
|
|
307
|
+
schemaVersion: PURGE_SCHEMA_VERSION,
|
|
308
|
+
command: 'purge',
|
|
309
|
+
scope: plan.scope,
|
|
310
|
+
confirmationPhrase: PURGE_CONFIRMATION_PHRASE,
|
|
311
|
+
...extra,
|
|
312
|
+
items: plan.items,
|
|
313
|
+
skills: plan.skills,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function finishCleanup(stateDir, plan) {
|
|
318
|
+
const quarantineDir = path.join(stateDir, PURGE_QUARANTINE_DIRNAME);
|
|
319
|
+
for (const item of plan.items) {
|
|
320
|
+
if (item.kind === 'link' || item.kind === 'tree' || item.kind === 'backups') continue;
|
|
321
|
+
if (item.kind === 'lock') continue;
|
|
322
|
+
if (item.status === 'absent') continue;
|
|
323
|
+
if (pathExists(item.absolutePath)) {
|
|
324
|
+
removeManagedPath(item.absolutePath);
|
|
325
|
+
item.status = 'removed';
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
if (pathExists(quarantineDir)) {
|
|
329
|
+
fs.rmSync(quarantineDir, { recursive: true, force: true });
|
|
330
|
+
}
|
|
331
|
+
const leftoverJournal = path.join(stateDir, PURGE_JOURNAL_FILENAME);
|
|
332
|
+
if (pathExists(leftoverJournal)) fs.rmSync(leftoverJournal, { force: true });
|
|
333
|
+
const lockPath = path.join(stateDir, '.sigma.lock');
|
|
334
|
+
if (pathExists(lockPath)) {
|
|
335
|
+
try {
|
|
336
|
+
const existing = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
|
|
337
|
+
if (!existing.pid || existing.pid === process.pid) fs.rmSync(lockPath, { force: true });
|
|
338
|
+
} catch {
|
|
339
|
+
// Lock removal is best-effort after the ownership plan is complete.
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
for (const name of [...PRIVATE_STAGING, 'backups']) {
|
|
343
|
+
const target = path.join(stateDir, name);
|
|
344
|
+
if (pathExists(target)) fs.rmSync(target, { recursive: true, force: true });
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Remove every revalidated Sigma-owned artifact in one chosen scope.
|
|
350
|
+
*
|
|
351
|
+
* @param {object} options
|
|
352
|
+
* @returns {object}
|
|
353
|
+
*/
|
|
354
|
+
export function executePurge(options = {}) {
|
|
355
|
+
const plan = createPurgePlan(options);
|
|
356
|
+
if (options.dryRun) {
|
|
357
|
+
return { ...plan, dryRun: true };
|
|
358
|
+
}
|
|
359
|
+
assertConfirmed(options);
|
|
360
|
+
if ((plan.blocked || []).length > 0) {
|
|
361
|
+
const first = plan.blocked[0];
|
|
362
|
+
throw codedError(
|
|
363
|
+
`${(first.reasons || []).join(', ') || 'unowned'} stopped purge of '${first.id}'`,
|
|
364
|
+
first.reasons?.[0] || 'unowned',
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const scope = options.scope || 'project';
|
|
369
|
+
const root = resolveRoot(options);
|
|
370
|
+
const customStateDir = options.customStateDir;
|
|
371
|
+
const stateDir = resolveStateDir(scope, root, customStateDir);
|
|
372
|
+
const releaseLock = acquireConcurrencyLock(root, customStateDir);
|
|
373
|
+
const quarantineDir = path.join(stateDir, PURGE_QUARANTINE_DIRNAME);
|
|
374
|
+
const journalPath = path.join(stateDir, PURGE_JOURNAL_FILENAME);
|
|
375
|
+
const writeJournal = (status) => {
|
|
376
|
+
const payload = journalPayload(plan, { status, quarantineDir: posix(relativeToRoot(root, quarantineDir)) });
|
|
377
|
+
persistJson(journalPath, payload);
|
|
378
|
+
if (pathExists(quarantineDir)) {
|
|
379
|
+
persistJson(path.join(quarantineDir, PURGE_JOURNAL_FILENAME), payload);
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
try {
|
|
384
|
+
if (plan.resumed) {
|
|
385
|
+
writeJournal('cleanup');
|
|
386
|
+
finishCleanup(stateDir, plan);
|
|
387
|
+
return { ...plan, dryRun: false, resumed: true };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
revalidateDestinations(plan);
|
|
391
|
+
fs.mkdirSync(quarantineDir, { recursive: true });
|
|
392
|
+
writeJournal('in-progress');
|
|
393
|
+
let phase = 'quarantine';
|
|
394
|
+
|
|
395
|
+
try {
|
|
396
|
+
let index = 0;
|
|
397
|
+
for (const item of plan.items) {
|
|
398
|
+
if (item.kind !== 'link' && item.kind !== 'tree' && item.kind !== 'backups') continue;
|
|
399
|
+
if (item.status === 'absent' || item.missing || !pathExists(item.absolutePath)) {
|
|
400
|
+
item.status = 'absent';
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
const slot = path.join(quarantineDir, `${String(index).padStart(3, '0')}-${item.kind}-${(item.skillId || item.kind)}`);
|
|
404
|
+
index += 1;
|
|
405
|
+
relocate(item.absolutePath, slot);
|
|
406
|
+
item.quarantinePath = slot;
|
|
407
|
+
item.status = 'quarantined';
|
|
408
|
+
writeJournal('in-progress');
|
|
409
|
+
if (typeof options.afterQuarantineItem === 'function') options.afterQuarantineItem(item);
|
|
410
|
+
}
|
|
411
|
+
writeJournal('quarantined');
|
|
412
|
+
persistJson(path.join(quarantineDir, PURGE_JOURNAL_FILENAME), journalPayload(plan, {
|
|
413
|
+
status: 'quarantined',
|
|
414
|
+
quarantineDir: posix(relativeToRoot(root, quarantineDir)),
|
|
415
|
+
}));
|
|
416
|
+
phase = 'cleanup';
|
|
417
|
+
if (typeof options.afterQuarantine === 'function') options.afterQuarantine(quarantineDir);
|
|
418
|
+
} catch (err) {
|
|
419
|
+
if (phase === 'quarantine') {
|
|
420
|
+
restoreQuarantined(root, plan.items);
|
|
421
|
+
writeJournal('failed');
|
|
422
|
+
try {
|
|
423
|
+
const leftover = pathExists(quarantineDir)
|
|
424
|
+
? fs.readdirSync(quarantineDir).filter((name) => name !== PURGE_JOURNAL_FILENAME)
|
|
425
|
+
: [];
|
|
426
|
+
if (leftover.length === 0 && pathExists(quarantineDir)) {
|
|
427
|
+
fs.rmSync(quarantineDir, { recursive: true, force: true });
|
|
428
|
+
}
|
|
429
|
+
} catch {
|
|
430
|
+
// Keep a failed journal when quarantine cannot be removed.
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
throw err;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
writeJournal('cleanup');
|
|
437
|
+
finishCleanup(stateDir, plan);
|
|
438
|
+
return { ...plan, dryRun: false, resumed: false };
|
|
439
|
+
} finally {
|
|
440
|
+
releaseLock();
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* @param {object} result
|
|
446
|
+
* @returns {string}
|
|
447
|
+
*/
|
|
448
|
+
export function formatPurgeJson(result) {
|
|
449
|
+
return `${JSON.stringify(result, null, 2)}\n`;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* @param {object} result
|
|
454
|
+
* @returns {string}
|
|
455
|
+
*/
|
|
456
|
+
export function formatPurgeHuman(result) {
|
|
457
|
+
const lines = [
|
|
458
|
+
`SigmaSkills Purge ${result.dryRun ? 'Preview' : 'Result'}`,
|
|
459
|
+
`Scope: ${result.scope}`,
|
|
460
|
+
`Confirmation phrase: ${result.confirmationPhrase}`,
|
|
461
|
+
'',
|
|
462
|
+
];
|
|
463
|
+
if (result.resumed) lines.push('Mode: resume interrupted purge', '');
|
|
464
|
+
lines.push('Ownership plan:');
|
|
465
|
+
for (const item of result.items || []) {
|
|
466
|
+
lines.push(` ${item.relative} [${item.kind}${item.method ? `/${item.method}` : ''}]`);
|
|
467
|
+
}
|
|
468
|
+
if ((result.blocked || []).length > 0) {
|
|
469
|
+
lines.push('');
|
|
470
|
+
lines.push('Blocked:');
|
|
471
|
+
for (const item of result.blocked) {
|
|
472
|
+
lines.push(` ${item.id}: ${(item.reasons || []).join(', ')}`);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
lines.push('');
|
|
476
|
+
return lines.join('\n');
|
|
477
|
+
}
|