yam-flow 2.5.1 → 2.7.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 +47 -0
- package/COMMANDS.md +57 -1
- package/README.md +28 -2
- package/ROADMAP.md +41 -7
- package/dist/bin/yam.js +567 -7
- package/dist/lib/benchmark-promotion.d.ts +53 -0
- package/dist/lib/benchmark-promotion.js +204 -0
- package/dist/lib/design-production.d.ts +306 -0
- package/dist/lib/design-production.js +1341 -0
- package/dist/lib/external-updates.d.ts +23 -0
- package/dist/lib/external-updates.js +286 -30
- package/dist/lib/gate-result.d.ts +44 -0
- package/dist/lib/gate-result.js +219 -0
- package/dist/lib/media-provider-receipt.d.ts +79 -0
- package/dist/lib/media-provider-receipt.js +498 -0
- package/dist/lib/skill-installation.js +337 -97
- package/dist/lib/trust-kernel.js +17 -1
- package/dist/lib/verification-closure.d.ts +97 -0
- package/dist/lib/verification-closure.js +728 -0
- package/package.json +5 -2
- package/references/ueye.md +14 -0
- package/skills/ueye/SKILL.md +11 -0
- package/src/bin/yam.ts +569 -7
- package/src/lib/benchmark-promotion.ts +249 -0
- package/src/lib/design-production.ts +1441 -0
- package/src/lib/external-updates.ts +331 -28
- package/src/lib/gate-result.ts +223 -0
- package/src/lib/media-provider-receipt.ts +548 -0
- package/src/lib/skill-installation.ts +462 -95
- package/src/lib/trust-kernel.ts +20 -1
- package/src/lib/verification-closure.ts +738 -0
- package/yam.manifest.json +2 -2
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { constants as fsConstants } from 'node:fs';
|
|
2
3
|
import fsp from 'node:fs/promises';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { compareStableText } from './stable-order.js';
|
|
@@ -37,6 +38,137 @@ async function exists(target) {
|
|
|
37
38
|
return false;
|
|
38
39
|
}
|
|
39
40
|
}
|
|
41
|
+
function pathWithinBoundary(boundary, target) {
|
|
42
|
+
const relative = path.relative(boundary, target);
|
|
43
|
+
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
|
|
44
|
+
}
|
|
45
|
+
async function captureRegularParentPath(boundaryValue, targetValue, label) {
|
|
46
|
+
const boundary = path.resolve(boundaryValue);
|
|
47
|
+
const target = path.resolve(targetValue);
|
|
48
|
+
if (!pathWithinBoundary(boundary, target)) {
|
|
49
|
+
throw new Error(`${label} escapes its trusted path boundary: ${target}`);
|
|
50
|
+
}
|
|
51
|
+
const targetParent = target === boundary ? boundary : path.dirname(target);
|
|
52
|
+
const relativeParent = path.relative(boundary, targetParent);
|
|
53
|
+
const candidates = [boundary];
|
|
54
|
+
if (relativeParent) {
|
|
55
|
+
let current = boundary;
|
|
56
|
+
for (const segment of relativeParent.split(path.sep)) {
|
|
57
|
+
current = path.join(current, segment);
|
|
58
|
+
candidates.push(current);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const identities = [];
|
|
62
|
+
for (const candidate of candidates) {
|
|
63
|
+
const stat = await fsp.lstat(candidate);
|
|
64
|
+
if (stat.isSymbolicLink()) {
|
|
65
|
+
throw new Error(`${label} has a symlinked parent path segment: ${candidate}`);
|
|
66
|
+
}
|
|
67
|
+
if (!stat.isDirectory()) {
|
|
68
|
+
throw new Error(`${label} parent path segment must be a regular directory: ${candidate}`);
|
|
69
|
+
}
|
|
70
|
+
identities.push({ path: candidate, dev: stat.dev, ino: stat.ino });
|
|
71
|
+
}
|
|
72
|
+
return identities;
|
|
73
|
+
}
|
|
74
|
+
async function revalidateRegularParentPath(identities, label) {
|
|
75
|
+
for (const identity of identities) {
|
|
76
|
+
const stat = await fsp.lstat(identity.path);
|
|
77
|
+
if (stat.isSymbolicLink()
|
|
78
|
+
|| !stat.isDirectory()
|
|
79
|
+
|| stat.dev !== identity.dev
|
|
80
|
+
|| stat.ino !== identity.ino) {
|
|
81
|
+
throw new Error(`${label} parent path changed identity: ${identity.path}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
async function captureRegularDirectoryPath(boundaryValue, directoryValue, label) {
|
|
86
|
+
const boundary = path.resolve(boundaryValue);
|
|
87
|
+
const directory = path.resolve(directoryValue);
|
|
88
|
+
if (boundary === directory) {
|
|
89
|
+
const stat = await fsp.lstat(directory);
|
|
90
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
91
|
+
throw new Error(`${label} must be a regular directory: ${directory}`);
|
|
92
|
+
}
|
|
93
|
+
return [{ path: directory, dev: stat.dev, ino: stat.ino }];
|
|
94
|
+
}
|
|
95
|
+
const identities = await captureRegularParentPath(boundary, directory, label);
|
|
96
|
+
const stat = await fsp.lstat(directory);
|
|
97
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
98
|
+
throw new Error(`${label} must be a regular directory: ${directory}`);
|
|
99
|
+
}
|
|
100
|
+
identities.push({ path: directory, dev: stat.dev, ino: stat.ino });
|
|
101
|
+
return identities;
|
|
102
|
+
}
|
|
103
|
+
function readOnlyNoFollowFlags() {
|
|
104
|
+
const noFollow = process.platform !== 'win32' && typeof fsConstants.O_NOFOLLOW === 'number'
|
|
105
|
+
? fsConstants.O_NOFOLLOW
|
|
106
|
+
: 0;
|
|
107
|
+
return fsConstants.O_RDONLY | noFollow;
|
|
108
|
+
}
|
|
109
|
+
async function captureRegularFile(file, boundary = path.dirname(file)) {
|
|
110
|
+
const parents = await captureRegularParentPath(boundary, file, 'regular file read');
|
|
111
|
+
const before = await fsp.lstat(file);
|
|
112
|
+
if (!before.isFile() || before.isSymbolicLink())
|
|
113
|
+
throw new Error(`expected a regular file: ${file}`);
|
|
114
|
+
const handle = await fsp.open(file, readOnlyNoFollowFlags());
|
|
115
|
+
try {
|
|
116
|
+
const opened = await handle.stat();
|
|
117
|
+
if (!opened.isFile())
|
|
118
|
+
throw new Error(`expected a regular file: ${file}`);
|
|
119
|
+
const bytes = await handle.readFile();
|
|
120
|
+
const afterOpened = await handle.stat();
|
|
121
|
+
const after = await fsp.lstat(file);
|
|
122
|
+
await revalidateRegularParentPath(parents, 'regular file read');
|
|
123
|
+
if (!after.isFile()
|
|
124
|
+
|| after.isSymbolicLink()
|
|
125
|
+
|| before.dev !== opened.dev
|
|
126
|
+
|| before.ino !== opened.ino
|
|
127
|
+
|| afterOpened.dev !== opened.dev
|
|
128
|
+
|| afterOpened.ino !== opened.ino
|
|
129
|
+
|| after.dev !== opened.dev
|
|
130
|
+
|| after.ino !== opened.ino) {
|
|
131
|
+
throw new Error(`regular file changed identity while being captured: ${file}`);
|
|
132
|
+
}
|
|
133
|
+
return { bytes, mode: opened.mode & 0o777 };
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
await handle.close();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
async function readRegularFileBytes(file, boundary = path.dirname(file)) {
|
|
140
|
+
return (await captureRegularFile(file, boundary)).bytes;
|
|
141
|
+
}
|
|
142
|
+
async function copyRegularFile(source, target, sourceBoundary, targetBoundary) {
|
|
143
|
+
const sourceFile = await captureRegularFile(source, sourceBoundary);
|
|
144
|
+
const targetParents = await captureRegularParentPath(targetBoundary, target, 'install copy target');
|
|
145
|
+
const noFollow = process.platform !== 'win32' && typeof fsConstants.O_NOFOLLOW === 'number'
|
|
146
|
+
? fsConstants.O_NOFOLLOW
|
|
147
|
+
: 0;
|
|
148
|
+
const handle = await fsp.open(target, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | noFollow, sourceFile.mode);
|
|
149
|
+
try {
|
|
150
|
+
await handle.writeFile(sourceFile.bytes);
|
|
151
|
+
const opened = await handle.stat();
|
|
152
|
+
if (!opened.isFile())
|
|
153
|
+
throw new Error(`install copy target must be a regular file: ${target}`);
|
|
154
|
+
const openedIdentity = { dev: opened.dev, ino: opened.ino };
|
|
155
|
+
const afterOpened = await handle.stat();
|
|
156
|
+
if (afterOpened.dev !== openedIdentity.dev || afterOpened.ino !== openedIdentity.ino) {
|
|
157
|
+
throw new Error(`install copy target changed descriptor identity: ${target}`);
|
|
158
|
+
}
|
|
159
|
+
await revalidateRegularParentPath(targetParents, 'install copy target');
|
|
160
|
+
const installed = await fsp.lstat(target);
|
|
161
|
+
if (!installed.isFile()
|
|
162
|
+
|| installed.isSymbolicLink()
|
|
163
|
+
|| installed.dev !== openedIdentity.dev
|
|
164
|
+
|| installed.ino !== openedIdentity.ino) {
|
|
165
|
+
throw new Error(`install copy target changed path identity: ${target}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
await handle.close();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
40
172
|
export async function findInstallRecoveryArtifacts(root) {
|
|
41
173
|
try {
|
|
42
174
|
const entries = await fsp.readdir(root, { withFileTypes: true });
|
|
@@ -51,25 +183,23 @@ export async function findInstallRecoveryArtifacts(root) {
|
|
|
51
183
|
throw error;
|
|
52
184
|
}
|
|
53
185
|
}
|
|
54
|
-
async function digestFile(file, relativePath) {
|
|
186
|
+
async function digestFile(file, relativePath, boundary = path.dirname(file)) {
|
|
55
187
|
const stat = await fsp.lstat(file);
|
|
56
188
|
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
57
189
|
throw new Error(`install source must be a regular file: ${file}`);
|
|
58
190
|
}
|
|
59
|
-
const contents = await
|
|
191
|
+
const contents = await readRegularFileBytes(file, boundary);
|
|
60
192
|
return {
|
|
61
193
|
path: relativePath.split(path.sep).join('/'),
|
|
62
194
|
bytes: contents.byteLength,
|
|
63
195
|
sha256: createHash('sha256').update(contents).digest('hex')
|
|
64
196
|
};
|
|
65
197
|
}
|
|
66
|
-
async function collectTreeManifest(root, prefix = '') {
|
|
67
|
-
const
|
|
68
|
-
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
69
|
-
throw new Error(`install tree must be a regular directory: ${root}`);
|
|
70
|
-
}
|
|
198
|
+
async function collectTreeManifest(root, prefix = '', boundary = root) {
|
|
199
|
+
const rootIdentity = await captureRegularDirectoryPath(boundary, root, 'install tree');
|
|
71
200
|
const files = [];
|
|
72
201
|
async function walk(current, relative) {
|
|
202
|
+
const directoryIdentity = await captureRegularDirectoryPath(boundary, current, 'install tree');
|
|
73
203
|
const entries = await fsp.readdir(current, { withFileTypes: true });
|
|
74
204
|
entries.sort((left, right) => compareStableText(left.name, right.name));
|
|
75
205
|
for (const entry of entries) {
|
|
@@ -79,25 +209,29 @@ async function collectTreeManifest(root, prefix = '') {
|
|
|
79
209
|
await walk(absolute, nextRelative);
|
|
80
210
|
}
|
|
81
211
|
else if (entry.isFile()) {
|
|
82
|
-
files.push(await digestFile(absolute, path.join(prefix, nextRelative)));
|
|
212
|
+
files.push(await digestFile(absolute, path.join(prefix, nextRelative), boundary));
|
|
83
213
|
}
|
|
84
214
|
else {
|
|
85
215
|
throw new Error(`unsupported install tree entry: ${absolute}`);
|
|
86
216
|
}
|
|
87
217
|
}
|
|
218
|
+
await revalidateRegularParentPath(directoryIdentity, 'install tree');
|
|
88
219
|
}
|
|
89
220
|
await walk(root, '');
|
|
221
|
+
await revalidateRegularParentPath(rootIdentity, 'install tree');
|
|
90
222
|
return files.sort((left, right) => compareStableText(left.path, right.path));
|
|
91
223
|
}
|
|
92
224
|
async function expectedInstallManifest(sourceRoot, skills) {
|
|
225
|
+
const sourceIdentity = await captureRegularDirectoryPath(sourceRoot, sourceRoot, 'install source root');
|
|
93
226
|
const referencesRoot = path.join(sourceRoot, 'references');
|
|
94
|
-
const referenceFiles = await collectTreeManifest(referencesRoot);
|
|
227
|
+
const referenceFiles = await collectTreeManifest(referencesRoot, '', sourceRoot);
|
|
95
228
|
if (referenceFiles.length === 0)
|
|
96
229
|
throw new Error(`no reference files found: ${referencesRoot}`);
|
|
97
230
|
const files = [];
|
|
98
231
|
for (const skill of skills) {
|
|
99
232
|
const skillFile = path.join(sourceRoot, 'skills', skill, 'SKILL.md');
|
|
100
|
-
const
|
|
233
|
+
const skillBytes = await readRegularFileBytes(skillFile, sourceRoot);
|
|
234
|
+
const skillText = skillBytes.toString('utf8');
|
|
101
235
|
const frontmatter = skillText.match(/^---\n([\s\S]*?)\n---/);
|
|
102
236
|
const name = frontmatter?.[1].match(/^name:\s*([^\n]+)\s*$/m)?.[1]
|
|
103
237
|
?.trim()
|
|
@@ -105,7 +239,11 @@ async function expectedInstallManifest(sourceRoot, skills) {
|
|
|
105
239
|
if (name !== skill) {
|
|
106
240
|
throw new Error(`skill source frontmatter mismatch for ${skill}: ${name || 'missing'}`);
|
|
107
241
|
}
|
|
108
|
-
files.push(
|
|
242
|
+
files.push({
|
|
243
|
+
path: path.join(skill, 'SKILL.md').split(path.sep).join('/'),
|
|
244
|
+
bytes: skillBytes.byteLength,
|
|
245
|
+
sha256: createHash('sha256').update(skillBytes).digest('hex')
|
|
246
|
+
});
|
|
109
247
|
for (const reference of referenceFiles) {
|
|
110
248
|
files.push({
|
|
111
249
|
...reference,
|
|
@@ -113,29 +251,75 @@ async function expectedInstallManifest(sourceRoot, skills) {
|
|
|
113
251
|
});
|
|
114
252
|
}
|
|
115
253
|
}
|
|
254
|
+
await revalidateRegularParentPath(sourceIdentity, 'install source root');
|
|
116
255
|
return files.sort((left, right) => compareStableText(left.path, right.path));
|
|
117
256
|
}
|
|
118
|
-
async function copyTree(source, target) {
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
throw new Error(`install source must be a regular directory: ${source}`);
|
|
122
|
-
}
|
|
257
|
+
async function copyTree(source, target, sourceBoundary = source, targetBoundary = target) {
|
|
258
|
+
const sourceIdentity = await captureRegularDirectoryPath(sourceBoundary, source, 'install source tree');
|
|
259
|
+
const targetParents = await captureRegularParentPath(targetBoundary, target, 'install target tree');
|
|
123
260
|
await fsp.mkdir(target, { recursive: true });
|
|
261
|
+
const targetIdentity = await captureRegularDirectoryPath(targetBoundary, target, 'install target tree');
|
|
262
|
+
await revalidateRegularParentPath(sourceIdentity, 'install source tree');
|
|
263
|
+
await revalidateRegularParentPath(targetParents, 'install target tree');
|
|
124
264
|
const entries = await fsp.readdir(source, { withFileTypes: true });
|
|
125
265
|
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
126
266
|
for (const entry of entries) {
|
|
127
267
|
const from = path.join(source, entry.name);
|
|
128
268
|
const to = path.join(target, entry.name);
|
|
129
269
|
if (entry.isDirectory()) {
|
|
130
|
-
await copyTree(from, to);
|
|
270
|
+
await copyTree(from, to, sourceBoundary, targetBoundary);
|
|
131
271
|
}
|
|
132
272
|
else if (entry.isFile()) {
|
|
133
|
-
await
|
|
273
|
+
await copyRegularFile(from, to, sourceBoundary, targetBoundary);
|
|
134
274
|
}
|
|
135
275
|
else {
|
|
136
276
|
throw new Error(`unsupported install source entry: ${from}`);
|
|
137
277
|
}
|
|
138
278
|
}
|
|
279
|
+
await revalidateRegularParentPath(sourceIdentity, 'install source tree');
|
|
280
|
+
await revalidateRegularParentPath(targetIdentity, 'install target tree');
|
|
281
|
+
}
|
|
282
|
+
async function moveRegularDirectory(source, target, sourceBoundary, targetBoundary, label, onMoved) {
|
|
283
|
+
const sourceParents = await captureRegularParentPath(sourceBoundary, source, `${label} source`);
|
|
284
|
+
const sourceStat = await fsp.lstat(source);
|
|
285
|
+
if (!sourceStat.isDirectory() || sourceStat.isSymbolicLink()) {
|
|
286
|
+
throw new Error(`${label} source must be a regular directory: ${source}`);
|
|
287
|
+
}
|
|
288
|
+
const targetParents = await captureRegularParentPath(targetBoundary, target, `${label} target`);
|
|
289
|
+
if (await exists(target))
|
|
290
|
+
throw new Error(`${label} target unexpectedly exists: ${target}`);
|
|
291
|
+
await fsp.rename(source, target);
|
|
292
|
+
onMoved?.({ path: target, dev: sourceStat.dev, ino: sourceStat.ino, kind: 'directory' });
|
|
293
|
+
await revalidateRegularParentPath(sourceParents, `${label} source`);
|
|
294
|
+
await revalidateRegularParentPath(targetParents, `${label} target`);
|
|
295
|
+
const targetStat = await fsp.lstat(target);
|
|
296
|
+
if (!targetStat.isDirectory()
|
|
297
|
+
|| targetStat.isSymbolicLink()
|
|
298
|
+
|| targetStat.dev !== sourceStat.dev
|
|
299
|
+
|| targetStat.ino !== sourceStat.ino) {
|
|
300
|
+
throw new Error(`${label} directory changed identity while being moved: ${target}`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
async function moveRegularFile(source, target, sourceBoundary, targetBoundary, label, onMoved) {
|
|
304
|
+
const sourceParents = await captureRegularParentPath(sourceBoundary, source, `${label} source`);
|
|
305
|
+
const sourceStat = await fsp.lstat(source);
|
|
306
|
+
if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) {
|
|
307
|
+
throw new Error(`${label} source must be a regular file: ${source}`);
|
|
308
|
+
}
|
|
309
|
+
const targetParents = await captureRegularParentPath(targetBoundary, target, `${label} target`);
|
|
310
|
+
if (await exists(target))
|
|
311
|
+
throw new Error(`${label} target unexpectedly exists: ${target}`);
|
|
312
|
+
await fsp.rename(source, target);
|
|
313
|
+
onMoved?.({ path: target, dev: sourceStat.dev, ino: sourceStat.ino, kind: 'file' });
|
|
314
|
+
await revalidateRegularParentPath(sourceParents, `${label} source`);
|
|
315
|
+
await revalidateRegularParentPath(targetParents, `${label} target`);
|
|
316
|
+
const targetStat = await fsp.lstat(target);
|
|
317
|
+
if (!targetStat.isFile()
|
|
318
|
+
|| targetStat.isSymbolicLink()
|
|
319
|
+
|| targetStat.dev !== sourceStat.dev
|
|
320
|
+
|| targetStat.ino !== sourceStat.ino) {
|
|
321
|
+
throw new Error(`${label} file changed identity while being moved: ${target}`);
|
|
322
|
+
}
|
|
139
323
|
}
|
|
140
324
|
function manifestDigest(files) {
|
|
141
325
|
return digestManifestFiles([...files].sort((left, right) => compareStableText(left.path, right.path)));
|
|
@@ -282,7 +466,7 @@ async function readOwnershipReceipt(destination, packageName) {
|
|
|
282
466
|
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
283
467
|
return { exists: true, issues: [`install receipt must be a regular file: ${receiptPath}`] };
|
|
284
468
|
}
|
|
285
|
-
const parsed = JSON.parse(await
|
|
469
|
+
const parsed = JSON.parse((await readRegularFileBytes(receiptPath, destination)).toString('utf8'));
|
|
286
470
|
const issues = ownershipReceiptIssues(parsed, packageName, destination);
|
|
287
471
|
return issues.length
|
|
288
472
|
? { exists: true, issues }
|
|
@@ -306,7 +490,7 @@ async function verifyReceiptOwnedSkill(root, skill, receipt) {
|
|
|
306
490
|
if (!await exists(target))
|
|
307
491
|
return [`receipt-owned skill is missing: ${skill}`];
|
|
308
492
|
try {
|
|
309
|
-
const actual = await collectTreeManifest(target, skill);
|
|
493
|
+
const actual = await collectTreeManifest(target, skill, root);
|
|
310
494
|
return manifestIssues(expected, actual, `installed ${skill}`);
|
|
311
495
|
}
|
|
312
496
|
catch (error) {
|
|
@@ -382,36 +566,11 @@ async function preflightSafeUninstallOwnership(destination, packageName, skills)
|
|
|
382
566
|
}
|
|
383
567
|
return receipt;
|
|
384
568
|
}
|
|
385
|
-
async function readRegularFileBytes(file) {
|
|
386
|
-
const before = await fsp.lstat(file);
|
|
387
|
-
if (!before.isFile() || before.isSymbolicLink())
|
|
388
|
-
throw new Error(`expected a regular file: ${file}`);
|
|
389
|
-
const handle = await fsp.open(file, 'r');
|
|
390
|
-
try {
|
|
391
|
-
const opened = await handle.stat();
|
|
392
|
-
if (!opened.isFile())
|
|
393
|
-
throw new Error(`expected a regular file: ${file}`);
|
|
394
|
-
const bytes = await handle.readFile();
|
|
395
|
-
const after = await fsp.lstat(file);
|
|
396
|
-
if (!after.isFile()
|
|
397
|
-
|| after.isSymbolicLink()
|
|
398
|
-
|| before.dev !== opened.dev
|
|
399
|
-
|| before.ino !== opened.ino
|
|
400
|
-
|| after.dev !== opened.dev
|
|
401
|
-
|| after.ino !== opened.ino) {
|
|
402
|
-
throw new Error(`regular file changed identity while being captured: ${file}`);
|
|
403
|
-
}
|
|
404
|
-
return bytes;
|
|
405
|
-
}
|
|
406
|
-
finally {
|
|
407
|
-
await handle.close();
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
569
|
async function captureReceiptMutationSnapshot(destination, packageName) {
|
|
411
570
|
const receiptPath = path.join(destination, INSTALL_RECEIPT_NAME);
|
|
412
571
|
let bytes;
|
|
413
572
|
try {
|
|
414
|
-
bytes = await readRegularFileBytes(receiptPath);
|
|
573
|
+
bytes = await readRegularFileBytes(receiptPath, destination);
|
|
415
574
|
}
|
|
416
575
|
catch (error) {
|
|
417
576
|
if (error.code === 'ENOENT')
|
|
@@ -440,7 +599,7 @@ async function captureMutationSnapshot(destination, packageName, names, explicit
|
|
|
440
599
|
const receipt = await captureReceiptMutationSnapshot(destination, packageName);
|
|
441
600
|
const trees = [];
|
|
442
601
|
for (const name of names) {
|
|
443
|
-
const files = await collectTreeManifest(path.join(destination, name), name);
|
|
602
|
+
const files = await collectTreeManifest(path.join(destination, name), name, destination);
|
|
444
603
|
if (!explicitReplacements.includes(name)) {
|
|
445
604
|
if (!receipt.exists) {
|
|
446
605
|
throw new Error(`cannot capture receipt-owned skill without an install receipt: ${name}`);
|
|
@@ -468,7 +627,7 @@ async function verifyMovedMutationSnapshot(destination, snapshot, moved, receipt
|
|
|
468
627
|
continue;
|
|
469
628
|
}
|
|
470
629
|
try {
|
|
471
|
-
const actual = await collectTreeManifest(movedEntry.backup, tree.name);
|
|
630
|
+
const actual = await collectTreeManifest(movedEntry.backup, tree.name, path.dirname(movedEntry.backup));
|
|
472
631
|
issues.push(...manifestIssues(tree.files, actual, `moved backup ${tree.name}`));
|
|
473
632
|
}
|
|
474
633
|
catch (error) {
|
|
@@ -482,7 +641,7 @@ async function verifyMovedMutationSnapshot(destination, snapshot, moved, receipt
|
|
|
482
641
|
}
|
|
483
642
|
else if (snapshot.receipt.exists && receiptBackedUp) {
|
|
484
643
|
try {
|
|
485
|
-
const bytes = await readRegularFileBytes(receiptBackupPath);
|
|
644
|
+
const bytes = await readRegularFileBytes(receiptBackupPath, path.dirname(receiptBackupPath));
|
|
486
645
|
const sha256 = createHash('sha256').update(bytes).digest('hex');
|
|
487
646
|
if (sha256 !== snapshot.receipt.sha256 || !bytes.equals(snapshot.receipt.bytes)) {
|
|
488
647
|
issues.push('moved install receipt differs from the captured bytes');
|
|
@@ -556,7 +715,7 @@ export async function inspectSkillInstallation(options) {
|
|
|
556
715
|
continue;
|
|
557
716
|
}
|
|
558
717
|
try {
|
|
559
|
-
const installed = (await collectTreeManifest(target, skill));
|
|
718
|
+
const installed = (await collectTreeManifest(target, skill, destination));
|
|
560
719
|
const skillIssues = manifestIssues(expectedForSkill, installed, `installed ${skill}`);
|
|
561
720
|
skillStatuses.push({ skill, status: skillIssues.length ? 'drift' : 'ok', issues: skillIssues });
|
|
562
721
|
issues.push(...skillIssues);
|
|
@@ -574,7 +733,7 @@ export async function inspectSkillInstallation(options) {
|
|
|
574
733
|
}
|
|
575
734
|
else {
|
|
576
735
|
try {
|
|
577
|
-
const parsed = JSON.parse(await
|
|
736
|
+
const parsed = JSON.parse((await readRegularFileBytes(receiptPath, destination)).toString('utf8'));
|
|
578
737
|
const problems = receiptIssues(parsed, options, destination, expected);
|
|
579
738
|
if (problems.length) {
|
|
580
739
|
receiptStatus = 'drift';
|
|
@@ -604,6 +763,7 @@ export async function inspectSkillInstallation(options) {
|
|
|
604
763
|
}
|
|
605
764
|
async function acquireInstallLock(destination) {
|
|
606
765
|
const lockPath = path.join(destination, INSTALL_LOCK_NAME);
|
|
766
|
+
const destinationIdentity = await captureRegularDirectoryPath(destination, destination, 'skill destination');
|
|
607
767
|
let handle;
|
|
608
768
|
try {
|
|
609
769
|
handle = await fsp.open(lockPath, 'wx', 0o600);
|
|
@@ -614,25 +774,60 @@ async function acquireInstallLock(destination) {
|
|
|
614
774
|
}
|
|
615
775
|
throw error;
|
|
616
776
|
}
|
|
777
|
+
let lockIdentity;
|
|
617
778
|
try {
|
|
779
|
+
const opened = await handle.stat();
|
|
780
|
+
if (!opened.isFile())
|
|
781
|
+
throw new Error(`install lock must be a regular file: ${lockPath}`);
|
|
782
|
+
lockIdentity = { path: lockPath, dev: opened.dev, ino: opened.ino, kind: 'file' };
|
|
618
783
|
await handle.writeFile(JSON.stringify({ pid: process.pid, started_at: new Date().toISOString() }, null, 2));
|
|
784
|
+
await revalidateRegularParentPath(destinationIdentity, 'skill destination');
|
|
785
|
+
const current = await fsp.lstat(lockPath);
|
|
786
|
+
if (current.isSymbolicLink()
|
|
787
|
+
|| !current.isFile()
|
|
788
|
+
|| current.dev !== lockIdentity.dev
|
|
789
|
+
|| current.ino !== lockIdentity.ino) {
|
|
790
|
+
throw new Error(`install lock changed identity while being acquired: ${lockPath}`);
|
|
791
|
+
}
|
|
619
792
|
}
|
|
620
793
|
catch (error) {
|
|
621
|
-
|
|
622
|
-
|
|
794
|
+
const cleanupErrors = [];
|
|
795
|
+
try {
|
|
796
|
+
await handle.close();
|
|
797
|
+
}
|
|
798
|
+
catch (closeError) {
|
|
799
|
+
cleanupErrors.push(message(closeError));
|
|
800
|
+
}
|
|
801
|
+
try {
|
|
802
|
+
await revalidateRegularParentPath(destinationIdentity, 'skill destination');
|
|
803
|
+
if (!lockIdentity)
|
|
804
|
+
throw new Error(`install lock identity was not captured: ${lockPath}`);
|
|
805
|
+
await removeRecordedEntry(lockIdentity, 'install lock');
|
|
806
|
+
}
|
|
807
|
+
catch (cleanupError) {
|
|
808
|
+
cleanupErrors.push(message(cleanupError));
|
|
809
|
+
}
|
|
810
|
+
if (cleanupErrors.length) {
|
|
811
|
+
throw new Error(`${message(error)}. Install lock cleanup failed: ${cleanupErrors.join('; ')}`);
|
|
812
|
+
}
|
|
623
813
|
throw error;
|
|
624
814
|
}
|
|
625
|
-
return { handle, lockPath };
|
|
815
|
+
return { handle, lockPath, lockIdentity, destinationIdentity };
|
|
626
816
|
}
|
|
627
817
|
async function moveExistingEntries(root, names, backupRoot, moved) {
|
|
628
818
|
await fsp.mkdir(backupRoot, { recursive: true });
|
|
819
|
+
const backupBoundary = path.dirname(backupRoot);
|
|
629
820
|
for (const name of names) {
|
|
630
821
|
const original = path.join(root, name);
|
|
631
822
|
if (!await exists(original))
|
|
632
823
|
continue;
|
|
633
824
|
const backup = path.join(backupRoot, name);
|
|
634
|
-
await
|
|
635
|
-
|
|
825
|
+
await moveRegularDirectory(original, backup, root, backupBoundary, 'managed install backup', () => moved.push({
|
|
826
|
+
original,
|
|
827
|
+
backup,
|
|
828
|
+
originalBoundary: root,
|
|
829
|
+
backupBoundary
|
|
830
|
+
}));
|
|
636
831
|
}
|
|
637
832
|
}
|
|
638
833
|
async function restoreMovedEntries(entries) {
|
|
@@ -643,7 +838,7 @@ async function restoreMovedEntries(entries) {
|
|
|
643
838
|
throw new Error(`rollback target unexpectedly exists: ${entry.original}`);
|
|
644
839
|
}
|
|
645
840
|
await fsp.mkdir(path.dirname(entry.original), { recursive: true });
|
|
646
|
-
await
|
|
841
|
+
await moveRegularDirectory(entry.backup, entry.original, entry.backupBoundary, entry.originalBoundary, 'managed install rollback');
|
|
647
842
|
}
|
|
648
843
|
catch (error) {
|
|
649
844
|
errors.push(message(error));
|
|
@@ -655,14 +850,41 @@ async function removeInstalledEntries(entries) {
|
|
|
655
850
|
const errors = [];
|
|
656
851
|
for (const entry of [...entries].reverse()) {
|
|
657
852
|
try {
|
|
658
|
-
await
|
|
853
|
+
await removeRecordedEntry(entry, 'rollback removal');
|
|
659
854
|
}
|
|
660
855
|
catch (error) {
|
|
661
|
-
errors.push(`could not remove new install ${entry}: ${message(error)}`);
|
|
856
|
+
errors.push(`could not remove new install ${entry.path}: ${message(error)}`);
|
|
662
857
|
}
|
|
663
858
|
}
|
|
664
859
|
return errors;
|
|
665
860
|
}
|
|
861
|
+
async function captureRecordedEntry(target, kind, label) {
|
|
862
|
+
const stat = await fsp.lstat(target);
|
|
863
|
+
const typeMatches = kind === 'directory'
|
|
864
|
+
? stat.isDirectory() && !stat.isSymbolicLink()
|
|
865
|
+
: stat.isFile() && !stat.isSymbolicLink();
|
|
866
|
+
if (!typeMatches)
|
|
867
|
+
throw new Error(`${label} must be a regular ${kind}: ${target}`);
|
|
868
|
+
return { path: target, dev: stat.dev, ino: stat.ino, kind };
|
|
869
|
+
}
|
|
870
|
+
async function removeRecordedEntry(entry, label) {
|
|
871
|
+
let current;
|
|
872
|
+
try {
|
|
873
|
+
current = await fsp.lstat(entry.path);
|
|
874
|
+
}
|
|
875
|
+
catch (error) {
|
|
876
|
+
if (error.code === 'ENOENT')
|
|
877
|
+
return;
|
|
878
|
+
throw error;
|
|
879
|
+
}
|
|
880
|
+
const typeMatches = entry.kind === 'directory'
|
|
881
|
+
? current.isDirectory() && !current.isSymbolicLink()
|
|
882
|
+
: current.isFile() && !current.isSymbolicLink();
|
|
883
|
+
if (!typeMatches || current.dev !== entry.dev || current.ino !== entry.ino) {
|
|
884
|
+
throw new Error(`${label} identity mismatch; preserved current path: ${entry.path}`);
|
|
885
|
+
}
|
|
886
|
+
await fsp.rm(entry.path, { recursive: entry.kind === 'directory', force: entry.kind === 'file' });
|
|
887
|
+
}
|
|
666
888
|
export async function installSkillSetTransactional(options) {
|
|
667
889
|
const sourceRoot = path.resolve(options.sourceRoot);
|
|
668
890
|
const destination = safeRoot(options.destination, 'skill destination');
|
|
@@ -678,6 +900,7 @@ export async function installSkillSetTransactional(options) {
|
|
|
678
900
|
let result;
|
|
679
901
|
let primaryError;
|
|
680
902
|
try {
|
|
903
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
681
904
|
const recoveryArtifacts = await findInstallRecoveryArtifacts(destination);
|
|
682
905
|
if (recoveryArtifacts.length) {
|
|
683
906
|
throw new Error(`unfinished yam install transaction found: ${recoveryArtifacts.join(', ')}. `
|
|
@@ -687,6 +910,7 @@ export async function installSkillSetTransactional(options) {
|
|
|
687
910
|
const expected = await expectedInstallManifest(sourceRoot, skills);
|
|
688
911
|
const transactionId = options.transactionId || randomUUID();
|
|
689
912
|
const transactionRoot = await fsp.mkdtemp(path.join(destination, '.yam-flow-install-'));
|
|
913
|
+
const transactionEntry = await captureRecordedEntry(transactionRoot, 'directory', 'install transaction root');
|
|
690
914
|
const stagedRoot = path.join(transactionRoot, 'staged');
|
|
691
915
|
const backupRoot = path.join(transactionRoot, 'backup');
|
|
692
916
|
const stagedReceiptPath = path.join(transactionRoot, 'receipt.json');
|
|
@@ -695,15 +919,15 @@ export async function installSkillSetTransactional(options) {
|
|
|
695
919
|
const installedEntries = [];
|
|
696
920
|
let destinationBackups = [];
|
|
697
921
|
let receiptBackedUp = false;
|
|
698
|
-
let receiptInstalled
|
|
922
|
+
let receiptInstalled;
|
|
699
923
|
let mutationStarted = false;
|
|
700
924
|
try {
|
|
701
925
|
await fsp.mkdir(stagedRoot, { recursive: true });
|
|
702
926
|
for (const skill of skills) {
|
|
703
927
|
const stagedSkill = path.join(stagedRoot, skill);
|
|
704
928
|
await fsp.mkdir(stagedSkill, { recursive: true });
|
|
705
|
-
await
|
|
706
|
-
await copyTree(path.join(sourceRoot, 'references'), path.join(stagedSkill, 'references'));
|
|
929
|
+
await copyRegularFile(path.join(sourceRoot, 'skills', skill, 'SKILL.md'), path.join(stagedSkill, 'SKILL.md'), sourceRoot, stagedRoot);
|
|
930
|
+
await copyTree(path.join(sourceRoot, 'references'), path.join(stagedSkill, 'references'), sourceRoot, stagedRoot);
|
|
707
931
|
}
|
|
708
932
|
const stagedManifest = await collectTreeManifest(stagedRoot);
|
|
709
933
|
const stagedIssues = manifestIssues(expected, stagedManifest, 'staged install');
|
|
@@ -729,24 +953,26 @@ export async function installSkillSetTransactional(options) {
|
|
|
729
953
|
const mutationOwnership = await preflightInstallOwnership(destination, options.packageName, skills, cleanupCandidates, replaceSkills);
|
|
730
954
|
const mutationSnapshot = await captureMutationSnapshot(destination, options.packageName, mutationOwnership.replaceNames, replaceSkills);
|
|
731
955
|
await options.failpoint?.({ phase: 'before-mutation' });
|
|
956
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
732
957
|
mutationStarted = true;
|
|
733
958
|
await moveExistingEntries(destination, mutationOwnership.replaceNames, backupRoot, destinationBackups);
|
|
734
959
|
if (await exists(destinationReceiptPath)) {
|
|
735
|
-
await
|
|
736
|
-
receiptBackedUp = true;
|
|
960
|
+
await moveRegularFile(destinationReceiptPath, receiptBackupPath, destination, transactionRoot, 'install receipt backup', () => { receiptBackedUp = true; });
|
|
737
961
|
}
|
|
738
962
|
await verifyMovedMutationSnapshot(destination, mutationSnapshot, destinationBackups, receiptBackedUp, receiptBackupPath);
|
|
739
963
|
await options.failpoint?.({ phase: 'after-backup' });
|
|
964
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
740
965
|
for (const skill of skills) {
|
|
966
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
741
967
|
const target = path.join(destination, skill);
|
|
742
|
-
await
|
|
743
|
-
installedEntries.push(target);
|
|
968
|
+
await moveRegularDirectory(path.join(stagedRoot, skill), target, stagedRoot, destination, 'staged skill install', (entry) => installedEntries.push(entry));
|
|
744
969
|
await options.failpoint?.({ phase: 'skill-installed', skill });
|
|
745
970
|
}
|
|
746
|
-
await
|
|
747
|
-
receiptInstalled =
|
|
971
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
972
|
+
await moveRegularFile(stagedReceiptPath, destinationReceiptPath, transactionRoot, destination, 'staged install receipt', (entry) => { receiptInstalled = entry; });
|
|
748
973
|
await options.failpoint?.({ phase: 'receipt-installed' });
|
|
749
974
|
await options.failpoint?.({ phase: 'before-verify' });
|
|
975
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
750
976
|
const inspection = await inspectSkillInstallation({
|
|
751
977
|
sourceRoot,
|
|
752
978
|
destination,
|
|
@@ -757,15 +983,11 @@ export async function installSkillSetTransactional(options) {
|
|
|
757
983
|
});
|
|
758
984
|
if (!inspection.ok)
|
|
759
985
|
throw new Error(`post-install verification failed: ${inspection.issues.join('; ')}`);
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
}
|
|
766
|
-
catch (error) {
|
|
767
|
-
cleanupWarnings.push(`verified install retained cleanup artifact ${cleanup}: ${message(error)}`);
|
|
768
|
-
}
|
|
986
|
+
try {
|
|
987
|
+
await removeRecordedEntry(transactionEntry, 'install transaction cleanup');
|
|
988
|
+
}
|
|
989
|
+
catch (error) {
|
|
990
|
+
cleanupWarnings.push(`verified install retained cleanup artifact ${transactionRoot}: ${message(error)}`);
|
|
769
991
|
}
|
|
770
992
|
result = {
|
|
771
993
|
receipt,
|
|
@@ -777,16 +999,24 @@ export async function installSkillSetTransactional(options) {
|
|
|
777
999
|
}
|
|
778
1000
|
catch (error) {
|
|
779
1001
|
const rollbackErrors = [];
|
|
780
|
-
|
|
1002
|
+
let destinationStillOwned = true;
|
|
1003
|
+
try {
|
|
1004
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
1005
|
+
}
|
|
1006
|
+
catch (identityError) {
|
|
1007
|
+
destinationStillOwned = false;
|
|
1008
|
+
rollbackErrors.push(`skill destination identity changed; path-based rollback was not attempted: ${message(identityError)}`);
|
|
1009
|
+
}
|
|
1010
|
+
if (mutationStarted && destinationStillOwned) {
|
|
781
1011
|
if (receiptInstalled)
|
|
782
|
-
rollbackErrors.push(...await removeInstalledEntries([
|
|
1012
|
+
rollbackErrors.push(...await removeInstalledEntries([receiptInstalled]));
|
|
783
1013
|
rollbackErrors.push(...await removeInstalledEntries(installedEntries));
|
|
784
1014
|
rollbackErrors.push(...await restoreMovedEntries(destinationBackups));
|
|
785
1015
|
if (receiptBackedUp) {
|
|
786
1016
|
try {
|
|
787
1017
|
if (await exists(destinationReceiptPath))
|
|
788
1018
|
throw new Error(`rollback target unexpectedly exists: ${destinationReceiptPath}`);
|
|
789
|
-
await
|
|
1019
|
+
await moveRegularFile(receiptBackupPath, destinationReceiptPath, transactionRoot, destination, 'install receipt rollback');
|
|
790
1020
|
}
|
|
791
1021
|
catch (receiptError) {
|
|
792
1022
|
rollbackErrors.push(message(receiptError));
|
|
@@ -795,15 +1025,11 @@ export async function installSkillSetTransactional(options) {
|
|
|
795
1025
|
}
|
|
796
1026
|
if (rollbackErrors.length === 0) {
|
|
797
1027
|
const cleanupErrors = [];
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
}
|
|
804
|
-
catch (cleanupError) {
|
|
805
|
-
cleanupErrors.push(`${cleanup}: ${message(cleanupError)}`);
|
|
806
|
-
}
|
|
1028
|
+
try {
|
|
1029
|
+
await removeRecordedEntry(transactionEntry, 'install transaction cleanup');
|
|
1030
|
+
}
|
|
1031
|
+
catch (cleanupError) {
|
|
1032
|
+
cleanupErrors.push(`${transactionRoot}: ${message(cleanupError)}`);
|
|
807
1033
|
}
|
|
808
1034
|
if (cleanupErrors.length) {
|
|
809
1035
|
throw new Error(`yam install failed; previous installation state restored, but transaction cleanup is incomplete: ${message(error)}. `
|
|
@@ -827,7 +1053,8 @@ export async function installSkillSetTransactional(options) {
|
|
|
827
1053
|
lockCleanupErrors.push(message(error));
|
|
828
1054
|
}
|
|
829
1055
|
try {
|
|
830
|
-
await
|
|
1056
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
1057
|
+
await removeRecordedEntry(lock.lockIdentity, 'install lock cleanup');
|
|
831
1058
|
}
|
|
832
1059
|
catch (error) {
|
|
833
1060
|
lockCleanupErrors.push(message(error));
|
|
@@ -860,6 +1087,7 @@ export async function uninstallSkillSetSafely(options) {
|
|
|
860
1087
|
let result;
|
|
861
1088
|
let primaryError;
|
|
862
1089
|
try {
|
|
1090
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
863
1091
|
const recoveryArtifacts = await findInstallRecoveryArtifacts(destination);
|
|
864
1092
|
if (recoveryArtifacts.length) {
|
|
865
1093
|
throw new Error(`unfinished yam install transaction found: ${recoveryArtifacts.join(', ')}. `
|
|
@@ -867,6 +1095,7 @@ export async function uninstallSkillSetSafely(options) {
|
|
|
867
1095
|
}
|
|
868
1096
|
await preflightSafeUninstallOwnership(destination, options.packageName, skills);
|
|
869
1097
|
const transactionRoot = await fsp.mkdtemp(path.join(destination, '.yam-flow-install-uninstall-'));
|
|
1098
|
+
const transactionEntry = await captureRecordedEntry(transactionRoot, 'directory', 'uninstall transaction root');
|
|
870
1099
|
const backupRoot = path.join(transactionRoot, 'backup');
|
|
871
1100
|
const receiptPath = path.join(destination, INSTALL_RECEIPT_NAME);
|
|
872
1101
|
const receiptBackup = path.join(transactionRoot, INSTALL_RECEIPT_NAME);
|
|
@@ -877,13 +1106,15 @@ export async function uninstallSkillSetSafely(options) {
|
|
|
877
1106
|
await preflightSafeUninstallOwnership(destination, options.packageName, skills);
|
|
878
1107
|
const mutationSnapshot = await captureMutationSnapshot(destination, options.packageName, skills, []);
|
|
879
1108
|
await options.failpoint?.({ phase: 'before-mutation' });
|
|
1109
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
880
1110
|
mutationStarted = true;
|
|
881
1111
|
await moveExistingEntries(destination, skills, backupRoot, moved);
|
|
882
|
-
await
|
|
883
|
-
receiptMoved = true;
|
|
1112
|
+
await moveRegularFile(receiptPath, receiptBackup, destination, transactionRoot, 'uninstall receipt backup', () => { receiptMoved = true; });
|
|
884
1113
|
await verifyMovedMutationSnapshot(destination, mutationSnapshot, moved, receiptMoved, receiptBackup);
|
|
885
1114
|
await options.failpoint?.({ phase: 'after-backup' });
|
|
1115
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
886
1116
|
await options.failpoint?.({ phase: 'before-verify' });
|
|
1117
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
887
1118
|
const remaining = [];
|
|
888
1119
|
for (const skill of skills) {
|
|
889
1120
|
if (await exists(path.join(destination, skill)))
|
|
@@ -895,7 +1126,7 @@ export async function uninstallSkillSetSafely(options) {
|
|
|
895
1126
|
throw new Error(`post-uninstall verification found active entries: ${remaining.join(', ')}`);
|
|
896
1127
|
}
|
|
897
1128
|
try {
|
|
898
|
-
await
|
|
1129
|
+
await removeRecordedEntry(transactionEntry, 'uninstall transaction cleanup');
|
|
899
1130
|
}
|
|
900
1131
|
catch (error) {
|
|
901
1132
|
cleanupWarnings.push(`verified uninstall retained cleanup artifact ${transactionRoot}: ${message(error)}`);
|
|
@@ -908,12 +1139,20 @@ export async function uninstallSkillSetSafely(options) {
|
|
|
908
1139
|
}
|
|
909
1140
|
catch (error) {
|
|
910
1141
|
const rollbackErrors = [];
|
|
911
|
-
|
|
1142
|
+
let destinationStillOwned = true;
|
|
1143
|
+
try {
|
|
1144
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
1145
|
+
}
|
|
1146
|
+
catch (identityError) {
|
|
1147
|
+
destinationStillOwned = false;
|
|
1148
|
+
rollbackErrors.push(`skill destination identity changed; path-based rollback was not attempted: ${message(identityError)}`);
|
|
1149
|
+
}
|
|
1150
|
+
if (mutationStarted && destinationStillOwned) {
|
|
912
1151
|
if (receiptMoved) {
|
|
913
1152
|
try {
|
|
914
1153
|
if (await exists(receiptPath))
|
|
915
1154
|
throw new Error(`rollback target unexpectedly exists: ${receiptPath}`);
|
|
916
|
-
await
|
|
1155
|
+
await moveRegularFile(receiptBackup, receiptPath, transactionRoot, destination, 'uninstall receipt rollback');
|
|
917
1156
|
}
|
|
918
1157
|
catch (receiptError) {
|
|
919
1158
|
rollbackErrors.push(message(receiptError));
|
|
@@ -923,7 +1162,7 @@ export async function uninstallSkillSetSafely(options) {
|
|
|
923
1162
|
}
|
|
924
1163
|
if (rollbackErrors.length === 0) {
|
|
925
1164
|
try {
|
|
926
|
-
await
|
|
1165
|
+
await removeRecordedEntry(transactionEntry, 'uninstall transaction cleanup');
|
|
927
1166
|
}
|
|
928
1167
|
catch (cleanupError) {
|
|
929
1168
|
throw new Error(`yam uninstall failed; previous installation state restored, but transaction cleanup is incomplete: ${message(error)}. `
|
|
@@ -946,7 +1185,8 @@ export async function uninstallSkillSetSafely(options) {
|
|
|
946
1185
|
lockCleanupErrors.push(message(error));
|
|
947
1186
|
}
|
|
948
1187
|
try {
|
|
949
|
-
await
|
|
1188
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
1189
|
+
await removeRecordedEntry(lock.lockIdentity, 'install lock cleanup');
|
|
950
1190
|
}
|
|
951
1191
|
catch (error) {
|
|
952
1192
|
lockCleanupErrors.push(message(error));
|