yam-flow 2.6.0 → 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 +22 -0
- package/COMMANDS.md +30 -0
- package/README.md +9 -1
- package/ROADMAP.md +3 -1
- package/dist/bin/yam.js +183 -6
- 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/verification-closure.d.ts +97 -0
- package/dist/lib/verification-closure.js +728 -0
- package/package.json +3 -2
- package/src/bin/yam.ts +179 -5
- package/src/lib/media-provider-receipt.ts +548 -0
- package/src/lib/skill-installation.ts +462 -95
- package/src/lib/verification-closure.ts +738 -0
- package/yam.manifest.json +1 -1
|
@@ -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';
|
|
@@ -117,6 +118,8 @@ export type SafeUninstallResult = {
|
|
|
117
118
|
type MovedEntry = {
|
|
118
119
|
original: string;
|
|
119
120
|
backup: string;
|
|
121
|
+
originalBoundary: string;
|
|
122
|
+
backupBoundary: string;
|
|
120
123
|
};
|
|
121
124
|
|
|
122
125
|
type OwnershipPreflight = {
|
|
@@ -143,6 +146,16 @@ type MutationSnapshot = {
|
|
|
143
146
|
receipt: ReceiptMutationSnapshot;
|
|
144
147
|
};
|
|
145
148
|
|
|
149
|
+
type PathIdentity = {
|
|
150
|
+
path: string;
|
|
151
|
+
dev: number;
|
|
152
|
+
ino: number;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
type InstalledEntry = PathIdentity & {
|
|
156
|
+
kind: 'directory' | 'file';
|
|
157
|
+
};
|
|
158
|
+
|
|
146
159
|
function message(error: unknown) {
|
|
147
160
|
return error instanceof Error ? error.message : String(error);
|
|
148
161
|
}
|
|
@@ -177,6 +190,157 @@ async function exists(target: string) {
|
|
|
177
190
|
}
|
|
178
191
|
}
|
|
179
192
|
|
|
193
|
+
function pathWithinBoundary(boundary: string, target: string) {
|
|
194
|
+
const relative = path.relative(boundary, target);
|
|
195
|
+
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function captureRegularParentPath(boundaryValue: string, targetValue: string, label: string) {
|
|
199
|
+
const boundary = path.resolve(boundaryValue);
|
|
200
|
+
const target = path.resolve(targetValue);
|
|
201
|
+
if (!pathWithinBoundary(boundary, target)) {
|
|
202
|
+
throw new Error(`${label} escapes its trusted path boundary: ${target}`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const targetParent = target === boundary ? boundary : path.dirname(target);
|
|
206
|
+
const relativeParent = path.relative(boundary, targetParent);
|
|
207
|
+
const candidates = [boundary];
|
|
208
|
+
if (relativeParent) {
|
|
209
|
+
let current = boundary;
|
|
210
|
+
for (const segment of relativeParent.split(path.sep)) {
|
|
211
|
+
current = path.join(current, segment);
|
|
212
|
+
candidates.push(current);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const identities: PathIdentity[] = [];
|
|
217
|
+
for (const candidate of candidates) {
|
|
218
|
+
const stat = await fsp.lstat(candidate);
|
|
219
|
+
if (stat.isSymbolicLink()) {
|
|
220
|
+
throw new Error(`${label} has a symlinked parent path segment: ${candidate}`);
|
|
221
|
+
}
|
|
222
|
+
if (!stat.isDirectory()) {
|
|
223
|
+
throw new Error(`${label} parent path segment must be a regular directory: ${candidate}`);
|
|
224
|
+
}
|
|
225
|
+
identities.push({ path: candidate, dev: stat.dev, ino: stat.ino });
|
|
226
|
+
}
|
|
227
|
+
return identities;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function revalidateRegularParentPath(identities: PathIdentity[], label: string) {
|
|
231
|
+
for (const identity of identities) {
|
|
232
|
+
const stat = await fsp.lstat(identity.path);
|
|
233
|
+
if (
|
|
234
|
+
stat.isSymbolicLink()
|
|
235
|
+
|| !stat.isDirectory()
|
|
236
|
+
|| stat.dev !== identity.dev
|
|
237
|
+
|| stat.ino !== identity.ino
|
|
238
|
+
) {
|
|
239
|
+
throw new Error(`${label} parent path changed identity: ${identity.path}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function captureRegularDirectoryPath(boundaryValue: string, directoryValue: string, label: string) {
|
|
245
|
+
const boundary = path.resolve(boundaryValue);
|
|
246
|
+
const directory = path.resolve(directoryValue);
|
|
247
|
+
if (boundary === directory) {
|
|
248
|
+
const stat = await fsp.lstat(directory);
|
|
249
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
250
|
+
throw new Error(`${label} must be a regular directory: ${directory}`);
|
|
251
|
+
}
|
|
252
|
+
return [{ path: directory, dev: stat.dev, ino: stat.ino }];
|
|
253
|
+
}
|
|
254
|
+
const identities = await captureRegularParentPath(boundary, directory, label);
|
|
255
|
+
const stat = await fsp.lstat(directory);
|
|
256
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
257
|
+
throw new Error(`${label} must be a regular directory: ${directory}`);
|
|
258
|
+
}
|
|
259
|
+
identities.push({ path: directory, dev: stat.dev, ino: stat.ino });
|
|
260
|
+
return identities;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function readOnlyNoFollowFlags() {
|
|
264
|
+
const noFollow = process.platform !== 'win32' && typeof fsConstants.O_NOFOLLOW === 'number'
|
|
265
|
+
? fsConstants.O_NOFOLLOW
|
|
266
|
+
: 0;
|
|
267
|
+
return fsConstants.O_RDONLY | noFollow;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function captureRegularFile(file: string, boundary = path.dirname(file)) {
|
|
271
|
+
const parents = await captureRegularParentPath(boundary, file, 'regular file read');
|
|
272
|
+
const before = await fsp.lstat(file);
|
|
273
|
+
if (!before.isFile() || before.isSymbolicLink()) throw new Error(`expected a regular file: ${file}`);
|
|
274
|
+
const handle = await fsp.open(file, readOnlyNoFollowFlags());
|
|
275
|
+
try {
|
|
276
|
+
const opened = await handle.stat();
|
|
277
|
+
if (!opened.isFile()) throw new Error(`expected a regular file: ${file}`);
|
|
278
|
+
const bytes = await handle.readFile();
|
|
279
|
+
const afterOpened = await handle.stat();
|
|
280
|
+
const after = await fsp.lstat(file);
|
|
281
|
+
await revalidateRegularParentPath(parents, 'regular file read');
|
|
282
|
+
if (
|
|
283
|
+
!after.isFile()
|
|
284
|
+
|| after.isSymbolicLink()
|
|
285
|
+
|| before.dev !== opened.dev
|
|
286
|
+
|| before.ino !== opened.ino
|
|
287
|
+
|| afterOpened.dev !== opened.dev
|
|
288
|
+
|| afterOpened.ino !== opened.ino
|
|
289
|
+
|| after.dev !== opened.dev
|
|
290
|
+
|| after.ino !== opened.ino
|
|
291
|
+
) {
|
|
292
|
+
throw new Error(`regular file changed identity while being captured: ${file}`);
|
|
293
|
+
}
|
|
294
|
+
return { bytes, mode: opened.mode & 0o777 };
|
|
295
|
+
} finally {
|
|
296
|
+
await handle.close();
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function readRegularFileBytes(file: string, boundary = path.dirname(file)) {
|
|
301
|
+
return (await captureRegularFile(file, boundary)).bytes;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function copyRegularFile(
|
|
305
|
+
source: string,
|
|
306
|
+
target: string,
|
|
307
|
+
sourceBoundary: string,
|
|
308
|
+
targetBoundary: string
|
|
309
|
+
) {
|
|
310
|
+
const sourceFile = await captureRegularFile(source, sourceBoundary);
|
|
311
|
+
const targetParents = await captureRegularParentPath(targetBoundary, target, 'install copy target');
|
|
312
|
+
const noFollow = process.platform !== 'win32' && typeof fsConstants.O_NOFOLLOW === 'number'
|
|
313
|
+
? fsConstants.O_NOFOLLOW
|
|
314
|
+
: 0;
|
|
315
|
+
const handle = await fsp.open(
|
|
316
|
+
target,
|
|
317
|
+
fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | noFollow,
|
|
318
|
+
sourceFile.mode
|
|
319
|
+
);
|
|
320
|
+
try {
|
|
321
|
+
await handle.writeFile(sourceFile.bytes);
|
|
322
|
+
const opened = await handle.stat();
|
|
323
|
+
if (!opened.isFile()) throw new Error(`install copy target must be a regular file: ${target}`);
|
|
324
|
+
const openedIdentity = { dev: opened.dev, ino: opened.ino };
|
|
325
|
+
const afterOpened = await handle.stat();
|
|
326
|
+
if (afterOpened.dev !== openedIdentity.dev || afterOpened.ino !== openedIdentity.ino) {
|
|
327
|
+
throw new Error(`install copy target changed descriptor identity: ${target}`);
|
|
328
|
+
}
|
|
329
|
+
await revalidateRegularParentPath(targetParents, 'install copy target');
|
|
330
|
+
const installed = await fsp.lstat(target);
|
|
331
|
+
if (
|
|
332
|
+
!installed.isFile()
|
|
333
|
+
|| installed.isSymbolicLink()
|
|
334
|
+
|| installed.dev !== openedIdentity.dev
|
|
335
|
+
|| installed.ino !== openedIdentity.ino
|
|
336
|
+
) {
|
|
337
|
+
throw new Error(`install copy target changed path identity: ${target}`);
|
|
338
|
+
}
|
|
339
|
+
} finally {
|
|
340
|
+
await handle.close();
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
180
344
|
export async function findInstallRecoveryArtifacts(root: string) {
|
|
181
345
|
try {
|
|
182
346
|
const entries = await fsp.readdir(root, { withFileTypes: true });
|
|
@@ -190,12 +354,16 @@ export async function findInstallRecoveryArtifacts(root: string) {
|
|
|
190
354
|
}
|
|
191
355
|
}
|
|
192
356
|
|
|
193
|
-
async function digestFile(
|
|
357
|
+
async function digestFile(
|
|
358
|
+
file: string,
|
|
359
|
+
relativePath: string,
|
|
360
|
+
boundary = path.dirname(file)
|
|
361
|
+
): Promise<InstallFileDigest> {
|
|
194
362
|
const stat = await fsp.lstat(file);
|
|
195
363
|
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
196
364
|
throw new Error(`install source must be a regular file: ${file}`);
|
|
197
365
|
}
|
|
198
|
-
const contents = await
|
|
366
|
+
const contents = await readRegularFileBytes(file, boundary);
|
|
199
367
|
return {
|
|
200
368
|
path: relativePath.split(path.sep).join('/'),
|
|
201
369
|
bytes: contents.byteLength,
|
|
@@ -203,14 +371,16 @@ async function digestFile(file: string, relativePath: string): Promise<InstallFi
|
|
|
203
371
|
};
|
|
204
372
|
}
|
|
205
373
|
|
|
206
|
-
async function collectTreeManifest(
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
374
|
+
async function collectTreeManifest(
|
|
375
|
+
root: string,
|
|
376
|
+
prefix = '',
|
|
377
|
+
boundary = root
|
|
378
|
+
): Promise<InstallFileDigest[]> {
|
|
379
|
+
const rootIdentity = await captureRegularDirectoryPath(boundary, root, 'install tree');
|
|
211
380
|
|
|
212
381
|
const files: InstallFileDigest[] = [];
|
|
213
382
|
async function walk(current: string, relative: string): Promise<void> {
|
|
383
|
+
const directoryIdentity = await captureRegularDirectoryPath(boundary, current, 'install tree');
|
|
214
384
|
const entries = await fsp.readdir(current, { withFileTypes: true });
|
|
215
385
|
entries.sort((left, right) => compareStableText(left.name, right.name));
|
|
216
386
|
for (const entry of entries) {
|
|
@@ -219,26 +389,30 @@ async function collectTreeManifest(root: string, prefix = ''): Promise<InstallFi
|
|
|
219
389
|
if (entry.isDirectory()) {
|
|
220
390
|
await walk(absolute, nextRelative);
|
|
221
391
|
} else if (entry.isFile()) {
|
|
222
|
-
files.push(await digestFile(absolute, path.join(prefix, nextRelative)));
|
|
392
|
+
files.push(await digestFile(absolute, path.join(prefix, nextRelative), boundary));
|
|
223
393
|
} else {
|
|
224
394
|
throw new Error(`unsupported install tree entry: ${absolute}`);
|
|
225
395
|
}
|
|
226
396
|
}
|
|
397
|
+
await revalidateRegularParentPath(directoryIdentity, 'install tree');
|
|
227
398
|
}
|
|
228
399
|
|
|
229
400
|
await walk(root, '');
|
|
401
|
+
await revalidateRegularParentPath(rootIdentity, 'install tree');
|
|
230
402
|
return files.sort((left, right) => compareStableText(left.path, right.path));
|
|
231
403
|
}
|
|
232
404
|
|
|
233
405
|
async function expectedInstallManifest(sourceRoot: string, skills: string[]) {
|
|
406
|
+
const sourceIdentity = await captureRegularDirectoryPath(sourceRoot, sourceRoot, 'install source root');
|
|
234
407
|
const referencesRoot = path.join(sourceRoot, 'references');
|
|
235
|
-
const referenceFiles = await collectTreeManifest(referencesRoot);
|
|
408
|
+
const referenceFiles = await collectTreeManifest(referencesRoot, '', sourceRoot);
|
|
236
409
|
if (referenceFiles.length === 0) throw new Error(`no reference files found: ${referencesRoot}`);
|
|
237
410
|
|
|
238
411
|
const files: InstallFileDigest[] = [];
|
|
239
412
|
for (const skill of skills) {
|
|
240
413
|
const skillFile = path.join(sourceRoot, 'skills', skill, 'SKILL.md');
|
|
241
|
-
const
|
|
414
|
+
const skillBytes = await readRegularFileBytes(skillFile, sourceRoot);
|
|
415
|
+
const skillText = skillBytes.toString('utf8');
|
|
242
416
|
const frontmatter = skillText.match(/^---\n([\s\S]*?)\n---/);
|
|
243
417
|
const name = frontmatter?.[1].match(/^name:\s*([^\n]+)\s*$/m)?.[1]
|
|
244
418
|
?.trim()
|
|
@@ -246,7 +420,11 @@ async function expectedInstallManifest(sourceRoot: string, skills: string[]) {
|
|
|
246
420
|
if (name !== skill) {
|
|
247
421
|
throw new Error(`skill source frontmatter mismatch for ${skill}: ${name || 'missing'}`);
|
|
248
422
|
}
|
|
249
|
-
files.push(
|
|
423
|
+
files.push({
|
|
424
|
+
path: path.join(skill, 'SKILL.md').split(path.sep).join('/'),
|
|
425
|
+
bytes: skillBytes.byteLength,
|
|
426
|
+
sha256: createHash('sha256').update(skillBytes).digest('hex')
|
|
427
|
+
});
|
|
250
428
|
for (const reference of referenceFiles) {
|
|
251
429
|
files.push({
|
|
252
430
|
...reference,
|
|
@@ -254,28 +432,97 @@ async function expectedInstallManifest(sourceRoot: string, skills: string[]) {
|
|
|
254
432
|
});
|
|
255
433
|
}
|
|
256
434
|
}
|
|
435
|
+
await revalidateRegularParentPath(sourceIdentity, 'install source root');
|
|
257
436
|
return files.sort((left, right) => compareStableText(left.path, right.path));
|
|
258
437
|
}
|
|
259
438
|
|
|
260
|
-
async function copyTree(
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
439
|
+
async function copyTree(
|
|
440
|
+
source: string,
|
|
441
|
+
target: string,
|
|
442
|
+
sourceBoundary = source,
|
|
443
|
+
targetBoundary = target
|
|
444
|
+
): Promise<void> {
|
|
445
|
+
const sourceIdentity = await captureRegularDirectoryPath(sourceBoundary, source, 'install source tree');
|
|
446
|
+
const targetParents = await captureRegularParentPath(targetBoundary, target, 'install target tree');
|
|
265
447
|
await fsp.mkdir(target, { recursive: true });
|
|
448
|
+
const targetIdentity = await captureRegularDirectoryPath(targetBoundary, target, 'install target tree');
|
|
449
|
+
await revalidateRegularParentPath(sourceIdentity, 'install source tree');
|
|
450
|
+
await revalidateRegularParentPath(targetParents, 'install target tree');
|
|
266
451
|
const entries = await fsp.readdir(source, { withFileTypes: true });
|
|
267
452
|
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
268
453
|
for (const entry of entries) {
|
|
269
454
|
const from = path.join(source, entry.name);
|
|
270
455
|
const to = path.join(target, entry.name);
|
|
271
456
|
if (entry.isDirectory()) {
|
|
272
|
-
await copyTree(from, to);
|
|
457
|
+
await copyTree(from, to, sourceBoundary, targetBoundary);
|
|
273
458
|
} else if (entry.isFile()) {
|
|
274
|
-
await
|
|
459
|
+
await copyRegularFile(from, to, sourceBoundary, targetBoundary);
|
|
275
460
|
} else {
|
|
276
461
|
throw new Error(`unsupported install source entry: ${from}`);
|
|
277
462
|
}
|
|
278
463
|
}
|
|
464
|
+
await revalidateRegularParentPath(sourceIdentity, 'install source tree');
|
|
465
|
+
await revalidateRegularParentPath(targetIdentity, 'install target tree');
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
async function moveRegularDirectory(
|
|
469
|
+
source: string,
|
|
470
|
+
target: string,
|
|
471
|
+
sourceBoundary: string,
|
|
472
|
+
targetBoundary: string,
|
|
473
|
+
label: string,
|
|
474
|
+
onMoved?: (entry: InstalledEntry) => void
|
|
475
|
+
) {
|
|
476
|
+
const sourceParents = await captureRegularParentPath(sourceBoundary, source, `${label} source`);
|
|
477
|
+
const sourceStat = await fsp.lstat(source);
|
|
478
|
+
if (!sourceStat.isDirectory() || sourceStat.isSymbolicLink()) {
|
|
479
|
+
throw new Error(`${label} source must be a regular directory: ${source}`);
|
|
480
|
+
}
|
|
481
|
+
const targetParents = await captureRegularParentPath(targetBoundary, target, `${label} target`);
|
|
482
|
+
if (await exists(target)) throw new Error(`${label} target unexpectedly exists: ${target}`);
|
|
483
|
+
await fsp.rename(source, target);
|
|
484
|
+
onMoved?.({ path: target, dev: sourceStat.dev, ino: sourceStat.ino, kind: 'directory' });
|
|
485
|
+
await revalidateRegularParentPath(sourceParents, `${label} source`);
|
|
486
|
+
await revalidateRegularParentPath(targetParents, `${label} target`);
|
|
487
|
+
const targetStat = await fsp.lstat(target);
|
|
488
|
+
if (
|
|
489
|
+
!targetStat.isDirectory()
|
|
490
|
+
|| targetStat.isSymbolicLink()
|
|
491
|
+
|| targetStat.dev !== sourceStat.dev
|
|
492
|
+
|| targetStat.ino !== sourceStat.ino
|
|
493
|
+
) {
|
|
494
|
+
throw new Error(`${label} directory changed identity while being moved: ${target}`);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async function moveRegularFile(
|
|
499
|
+
source: string,
|
|
500
|
+
target: string,
|
|
501
|
+
sourceBoundary: string,
|
|
502
|
+
targetBoundary: string,
|
|
503
|
+
label: string,
|
|
504
|
+
onMoved?: (entry: InstalledEntry) => void
|
|
505
|
+
) {
|
|
506
|
+
const sourceParents = await captureRegularParentPath(sourceBoundary, source, `${label} source`);
|
|
507
|
+
const sourceStat = await fsp.lstat(source);
|
|
508
|
+
if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) {
|
|
509
|
+
throw new Error(`${label} source must be a regular file: ${source}`);
|
|
510
|
+
}
|
|
511
|
+
const targetParents = await captureRegularParentPath(targetBoundary, target, `${label} target`);
|
|
512
|
+
if (await exists(target)) throw new Error(`${label} target unexpectedly exists: ${target}`);
|
|
513
|
+
await fsp.rename(source, target);
|
|
514
|
+
onMoved?.({ path: target, dev: sourceStat.dev, ino: sourceStat.ino, kind: 'file' });
|
|
515
|
+
await revalidateRegularParentPath(sourceParents, `${label} source`);
|
|
516
|
+
await revalidateRegularParentPath(targetParents, `${label} target`);
|
|
517
|
+
const targetStat = await fsp.lstat(target);
|
|
518
|
+
if (
|
|
519
|
+
!targetStat.isFile()
|
|
520
|
+
|| targetStat.isSymbolicLink()
|
|
521
|
+
|| targetStat.dev !== sourceStat.dev
|
|
522
|
+
|| targetStat.ino !== sourceStat.ino
|
|
523
|
+
) {
|
|
524
|
+
throw new Error(`${label} file changed identity while being moved: ${target}`);
|
|
525
|
+
}
|
|
279
526
|
}
|
|
280
527
|
|
|
281
528
|
function manifestDigest(files: InstallFileDigest[]) {
|
|
@@ -430,7 +677,7 @@ async function readOwnershipReceipt(
|
|
|
430
677
|
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
431
678
|
return { exists: true, issues: [`install receipt must be a regular file: ${receiptPath}`] };
|
|
432
679
|
}
|
|
433
|
-
const parsed = JSON.parse(await
|
|
680
|
+
const parsed = JSON.parse((await readRegularFileBytes(receiptPath, destination)).toString('utf8'));
|
|
434
681
|
const issues = ownershipReceiptIssues(parsed, packageName, destination);
|
|
435
682
|
return issues.length
|
|
436
683
|
? { exists: true, issues }
|
|
@@ -457,7 +704,7 @@ async function verifyReceiptOwnedSkill(
|
|
|
457
704
|
const target = path.join(root, skill);
|
|
458
705
|
if (!await exists(target)) return [`receipt-owned skill is missing: ${skill}`];
|
|
459
706
|
try {
|
|
460
|
-
const actual = await collectTreeManifest(target, skill);
|
|
707
|
+
const actual = await collectTreeManifest(target, skill, root);
|
|
461
708
|
return manifestIssues(expected, actual, `installed ${skill}`);
|
|
462
709
|
} catch (error) {
|
|
463
710
|
return [`installed ${skill} cannot be verified: ${message(error)}`];
|
|
@@ -552,31 +799,6 @@ async function preflightSafeUninstallOwnership(
|
|
|
552
799
|
return receipt;
|
|
553
800
|
}
|
|
554
801
|
|
|
555
|
-
async function readRegularFileBytes(file: string) {
|
|
556
|
-
const before = await fsp.lstat(file);
|
|
557
|
-
if (!before.isFile() || before.isSymbolicLink()) throw new Error(`expected a regular file: ${file}`);
|
|
558
|
-
const handle = await fsp.open(file, 'r');
|
|
559
|
-
try {
|
|
560
|
-
const opened = await handle.stat();
|
|
561
|
-
if (!opened.isFile()) throw new Error(`expected a regular file: ${file}`);
|
|
562
|
-
const bytes = await handle.readFile();
|
|
563
|
-
const after = await fsp.lstat(file);
|
|
564
|
-
if (
|
|
565
|
-
!after.isFile()
|
|
566
|
-
|| after.isSymbolicLink()
|
|
567
|
-
|| before.dev !== opened.dev
|
|
568
|
-
|| before.ino !== opened.ino
|
|
569
|
-
|| after.dev !== opened.dev
|
|
570
|
-
|| after.ino !== opened.ino
|
|
571
|
-
) {
|
|
572
|
-
throw new Error(`regular file changed identity while being captured: ${file}`);
|
|
573
|
-
}
|
|
574
|
-
return bytes;
|
|
575
|
-
} finally {
|
|
576
|
-
await handle.close();
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
|
|
580
802
|
async function captureReceiptMutationSnapshot(
|
|
581
803
|
destination: string,
|
|
582
804
|
packageName: string
|
|
@@ -584,7 +806,7 @@ async function captureReceiptMutationSnapshot(
|
|
|
584
806
|
const receiptPath = path.join(destination, INSTALL_RECEIPT_NAME);
|
|
585
807
|
let bytes: Buffer;
|
|
586
808
|
try {
|
|
587
|
-
bytes = await readRegularFileBytes(receiptPath);
|
|
809
|
+
bytes = await readRegularFileBytes(receiptPath, destination);
|
|
588
810
|
} catch (error) {
|
|
589
811
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { exists: false };
|
|
590
812
|
throw new Error(`cannot capture install receipt ownership boundary: ${message(error)}`);
|
|
@@ -616,7 +838,7 @@ async function captureMutationSnapshot(
|
|
|
616
838
|
const receipt = await captureReceiptMutationSnapshot(destination, packageName);
|
|
617
839
|
const trees: TreeMutationSnapshot[] = [];
|
|
618
840
|
for (const name of names) {
|
|
619
|
-
const files = await collectTreeManifest(path.join(destination, name), name);
|
|
841
|
+
const files = await collectTreeManifest(path.join(destination, name), name, destination);
|
|
620
842
|
if (!explicitReplacements.includes(name)) {
|
|
621
843
|
if (!receipt.exists) {
|
|
622
844
|
throw new Error(`cannot capture receipt-owned skill without an install receipt: ${name}`);
|
|
@@ -651,7 +873,11 @@ async function verifyMovedMutationSnapshot(
|
|
|
651
873
|
continue;
|
|
652
874
|
}
|
|
653
875
|
try {
|
|
654
|
-
const actual = await collectTreeManifest(
|
|
876
|
+
const actual = await collectTreeManifest(
|
|
877
|
+
movedEntry.backup,
|
|
878
|
+
tree.name,
|
|
879
|
+
path.dirname(movedEntry.backup)
|
|
880
|
+
);
|
|
655
881
|
issues.push(...manifestIssues(tree.files, actual, `moved backup ${tree.name}`));
|
|
656
882
|
} catch (error) {
|
|
657
883
|
issues.push(`moved backup ${tree.name} cannot be verified: ${message(error)}`);
|
|
@@ -666,7 +892,7 @@ async function verifyMovedMutationSnapshot(
|
|
|
666
892
|
);
|
|
667
893
|
} else if (snapshot.receipt.exists && receiptBackedUp) {
|
|
668
894
|
try {
|
|
669
|
-
const bytes = await readRegularFileBytes(receiptBackupPath);
|
|
895
|
+
const bytes = await readRegularFileBytes(receiptBackupPath, path.dirname(receiptBackupPath));
|
|
670
896
|
const sha256 = createHash('sha256').update(bytes).digest('hex');
|
|
671
897
|
if (sha256 !== snapshot.receipt.sha256 || !bytes.equals(snapshot.receipt.bytes)) {
|
|
672
898
|
issues.push('moved install receipt differs from the captured bytes');
|
|
@@ -739,7 +965,7 @@ export async function inspectSkillInstallation(options: InstallationInspectionOp
|
|
|
739
965
|
continue;
|
|
740
966
|
}
|
|
741
967
|
try {
|
|
742
|
-
const installed = (await collectTreeManifest(target, skill));
|
|
968
|
+
const installed = (await collectTreeManifest(target, skill, destination));
|
|
743
969
|
const skillIssues = manifestIssues(expectedForSkill, installed, `installed ${skill}`);
|
|
744
970
|
skillStatuses.push({ skill, status: skillIssues.length ? 'drift' : 'ok', issues: skillIssues });
|
|
745
971
|
issues.push(...skillIssues);
|
|
@@ -756,7 +982,7 @@ export async function inspectSkillInstallation(options: InstallationInspectionOp
|
|
|
756
982
|
issues.push(`install receipt missing: ${receiptPath}`);
|
|
757
983
|
} else {
|
|
758
984
|
try {
|
|
759
|
-
const parsed = JSON.parse(await
|
|
985
|
+
const parsed = JSON.parse((await readRegularFileBytes(receiptPath, destination)).toString('utf8'));
|
|
760
986
|
const problems = receiptIssues(parsed, options, destination, expected);
|
|
761
987
|
if (problems.length) {
|
|
762
988
|
receiptStatus = 'drift';
|
|
@@ -786,6 +1012,7 @@ export async function inspectSkillInstallation(options: InstallationInspectionOp
|
|
|
786
1012
|
|
|
787
1013
|
async function acquireInstallLock(destination: string) {
|
|
788
1014
|
const lockPath = path.join(destination, INSTALL_LOCK_NAME);
|
|
1015
|
+
const destinationIdentity = await captureRegularDirectoryPath(destination, destination, 'skill destination');
|
|
789
1016
|
let handle: fsp.FileHandle;
|
|
790
1017
|
try {
|
|
791
1018
|
handle = await fsp.open(lockPath, 'wx', 0o600);
|
|
@@ -795,24 +1022,64 @@ async function acquireInstallLock(destination: string) {
|
|
|
795
1022
|
}
|
|
796
1023
|
throw error;
|
|
797
1024
|
}
|
|
1025
|
+
let lockIdentity: InstalledEntry | undefined;
|
|
798
1026
|
try {
|
|
1027
|
+
const opened = await handle.stat();
|
|
1028
|
+
if (!opened.isFile()) throw new Error(`install lock must be a regular file: ${lockPath}`);
|
|
1029
|
+
lockIdentity = { path: lockPath, dev: opened.dev, ino: opened.ino, kind: 'file' };
|
|
799
1030
|
await handle.writeFile(JSON.stringify({ pid: process.pid, started_at: new Date().toISOString() }, null, 2));
|
|
1031
|
+
await revalidateRegularParentPath(destinationIdentity, 'skill destination');
|
|
1032
|
+
const current = await fsp.lstat(lockPath);
|
|
1033
|
+
if (
|
|
1034
|
+
current.isSymbolicLink()
|
|
1035
|
+
|| !current.isFile()
|
|
1036
|
+
|| current.dev !== lockIdentity.dev
|
|
1037
|
+
|| current.ino !== lockIdentity.ino
|
|
1038
|
+
) {
|
|
1039
|
+
throw new Error(`install lock changed identity while being acquired: ${lockPath}`);
|
|
1040
|
+
}
|
|
800
1041
|
} catch (error) {
|
|
801
|
-
|
|
802
|
-
|
|
1042
|
+
const cleanupErrors: string[] = [];
|
|
1043
|
+
try {
|
|
1044
|
+
await handle.close();
|
|
1045
|
+
} catch (closeError) {
|
|
1046
|
+
cleanupErrors.push(message(closeError));
|
|
1047
|
+
}
|
|
1048
|
+
try {
|
|
1049
|
+
await revalidateRegularParentPath(destinationIdentity, 'skill destination');
|
|
1050
|
+
if (!lockIdentity) throw new Error(`install lock identity was not captured: ${lockPath}`);
|
|
1051
|
+
await removeRecordedEntry(lockIdentity, 'install lock');
|
|
1052
|
+
} catch (cleanupError) {
|
|
1053
|
+
cleanupErrors.push(message(cleanupError));
|
|
1054
|
+
}
|
|
1055
|
+
if (cleanupErrors.length) {
|
|
1056
|
+
throw new Error(`${message(error)}. Install lock cleanup failed: ${cleanupErrors.join('; ')}`);
|
|
1057
|
+
}
|
|
803
1058
|
throw error;
|
|
804
1059
|
}
|
|
805
|
-
return { handle, lockPath };
|
|
1060
|
+
return { handle, lockPath, lockIdentity, destinationIdentity };
|
|
806
1061
|
}
|
|
807
1062
|
|
|
808
1063
|
async function moveExistingEntries(root: string, names: string[], backupRoot: string, moved: MovedEntry[]) {
|
|
809
1064
|
await fsp.mkdir(backupRoot, { recursive: true });
|
|
1065
|
+
const backupBoundary = path.dirname(backupRoot);
|
|
810
1066
|
for (const name of names) {
|
|
811
1067
|
const original = path.join(root, name);
|
|
812
1068
|
if (!await exists(original)) continue;
|
|
813
1069
|
const backup = path.join(backupRoot, name);
|
|
814
|
-
await
|
|
815
|
-
|
|
1070
|
+
await moveRegularDirectory(
|
|
1071
|
+
original,
|
|
1072
|
+
backup,
|
|
1073
|
+
root,
|
|
1074
|
+
backupBoundary,
|
|
1075
|
+
'managed install backup',
|
|
1076
|
+
() => moved.push({
|
|
1077
|
+
original,
|
|
1078
|
+
backup,
|
|
1079
|
+
originalBoundary: root,
|
|
1080
|
+
backupBoundary
|
|
1081
|
+
})
|
|
1082
|
+
);
|
|
816
1083
|
}
|
|
817
1084
|
}
|
|
818
1085
|
|
|
@@ -824,7 +1091,13 @@ async function restoreMovedEntries(entries: MovedEntry[]) {
|
|
|
824
1091
|
throw new Error(`rollback target unexpectedly exists: ${entry.original}`);
|
|
825
1092
|
}
|
|
826
1093
|
await fsp.mkdir(path.dirname(entry.original), { recursive: true });
|
|
827
|
-
await
|
|
1094
|
+
await moveRegularDirectory(
|
|
1095
|
+
entry.backup,
|
|
1096
|
+
entry.original,
|
|
1097
|
+
entry.backupBoundary,
|
|
1098
|
+
entry.originalBoundary,
|
|
1099
|
+
'managed install rollback'
|
|
1100
|
+
);
|
|
828
1101
|
} catch (error) {
|
|
829
1102
|
errors.push(message(error));
|
|
830
1103
|
}
|
|
@@ -832,18 +1105,44 @@ async function restoreMovedEntries(entries: MovedEntry[]) {
|
|
|
832
1105
|
return errors;
|
|
833
1106
|
}
|
|
834
1107
|
|
|
835
|
-
async function removeInstalledEntries(entries:
|
|
1108
|
+
async function removeInstalledEntries(entries: InstalledEntry[]) {
|
|
836
1109
|
const errors: string[] = [];
|
|
837
1110
|
for (const entry of [...entries].reverse()) {
|
|
838
1111
|
try {
|
|
839
|
-
await
|
|
1112
|
+
await removeRecordedEntry(entry, 'rollback removal');
|
|
840
1113
|
} catch (error) {
|
|
841
|
-
errors.push(`could not remove new install ${entry}: ${message(error)}`);
|
|
1114
|
+
errors.push(`could not remove new install ${entry.path}: ${message(error)}`);
|
|
842
1115
|
}
|
|
843
1116
|
}
|
|
844
1117
|
return errors;
|
|
845
1118
|
}
|
|
846
1119
|
|
|
1120
|
+
async function captureRecordedEntry(target: string, kind: InstalledEntry['kind'], label: string): Promise<InstalledEntry> {
|
|
1121
|
+
const stat = await fsp.lstat(target);
|
|
1122
|
+
const typeMatches = kind === 'directory'
|
|
1123
|
+
? stat.isDirectory() && !stat.isSymbolicLink()
|
|
1124
|
+
: stat.isFile() && !stat.isSymbolicLink();
|
|
1125
|
+
if (!typeMatches) throw new Error(`${label} must be a regular ${kind}: ${target}`);
|
|
1126
|
+
return { path: target, dev: stat.dev, ino: stat.ino, kind };
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
async function removeRecordedEntry(entry: InstalledEntry, label: string) {
|
|
1130
|
+
let current;
|
|
1131
|
+
try {
|
|
1132
|
+
current = await fsp.lstat(entry.path);
|
|
1133
|
+
} catch (error) {
|
|
1134
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return;
|
|
1135
|
+
throw error;
|
|
1136
|
+
}
|
|
1137
|
+
const typeMatches = entry.kind === 'directory'
|
|
1138
|
+
? current.isDirectory() && !current.isSymbolicLink()
|
|
1139
|
+
: current.isFile() && !current.isSymbolicLink();
|
|
1140
|
+
if (!typeMatches || current.dev !== entry.dev || current.ino !== entry.ino) {
|
|
1141
|
+
throw new Error(`${label} identity mismatch; preserved current path: ${entry.path}`);
|
|
1142
|
+
}
|
|
1143
|
+
await fsp.rm(entry.path, { recursive: entry.kind === 'directory', force: entry.kind === 'file' });
|
|
1144
|
+
}
|
|
1145
|
+
|
|
847
1146
|
export async function installSkillSetTransactional(options: TransactionalInstallOptions): Promise<TransactionalInstallResult> {
|
|
848
1147
|
const sourceRoot = path.resolve(options.sourceRoot);
|
|
849
1148
|
const destination = safeRoot(options.destination, 'skill destination');
|
|
@@ -859,6 +1158,7 @@ export async function installSkillSetTransactional(options: TransactionalInstall
|
|
|
859
1158
|
let result: TransactionalInstallResult | undefined;
|
|
860
1159
|
let primaryError: unknown;
|
|
861
1160
|
try {
|
|
1161
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
862
1162
|
const recoveryArtifacts = await findInstallRecoveryArtifacts(destination);
|
|
863
1163
|
if (recoveryArtifacts.length) {
|
|
864
1164
|
throw new Error(
|
|
@@ -876,15 +1176,16 @@ export async function installSkillSetTransactional(options: TransactionalInstall
|
|
|
876
1176
|
const expected = await expectedInstallManifest(sourceRoot, skills);
|
|
877
1177
|
const transactionId = options.transactionId || randomUUID();
|
|
878
1178
|
const transactionRoot = await fsp.mkdtemp(path.join(destination, '.yam-flow-install-'));
|
|
1179
|
+
const transactionEntry = await captureRecordedEntry(transactionRoot, 'directory', 'install transaction root');
|
|
879
1180
|
const stagedRoot = path.join(transactionRoot, 'staged');
|
|
880
1181
|
const backupRoot = path.join(transactionRoot, 'backup');
|
|
881
1182
|
const stagedReceiptPath = path.join(transactionRoot, 'receipt.json');
|
|
882
1183
|
const destinationReceiptPath = path.join(destination, INSTALL_RECEIPT_NAME);
|
|
883
1184
|
const receiptBackupPath = path.join(transactionRoot, 'receipt.backup');
|
|
884
|
-
const installedEntries:
|
|
1185
|
+
const installedEntries: InstalledEntry[] = [];
|
|
885
1186
|
let destinationBackups: MovedEntry[] = [];
|
|
886
1187
|
let receiptBackedUp = false;
|
|
887
|
-
let receiptInstalled
|
|
1188
|
+
let receiptInstalled: InstalledEntry | undefined;
|
|
888
1189
|
let mutationStarted = false;
|
|
889
1190
|
|
|
890
1191
|
try {
|
|
@@ -892,8 +1193,18 @@ export async function installSkillSetTransactional(options: TransactionalInstall
|
|
|
892
1193
|
for (const skill of skills) {
|
|
893
1194
|
const stagedSkill = path.join(stagedRoot, skill);
|
|
894
1195
|
await fsp.mkdir(stagedSkill, { recursive: true });
|
|
895
|
-
await
|
|
896
|
-
|
|
1196
|
+
await copyRegularFile(
|
|
1197
|
+
path.join(sourceRoot, 'skills', skill, 'SKILL.md'),
|
|
1198
|
+
path.join(stagedSkill, 'SKILL.md'),
|
|
1199
|
+
sourceRoot,
|
|
1200
|
+
stagedRoot
|
|
1201
|
+
);
|
|
1202
|
+
await copyTree(
|
|
1203
|
+
path.join(sourceRoot, 'references'),
|
|
1204
|
+
path.join(stagedSkill, 'references'),
|
|
1205
|
+
sourceRoot,
|
|
1206
|
+
stagedRoot
|
|
1207
|
+
);
|
|
897
1208
|
}
|
|
898
1209
|
const stagedManifest = await collectTreeManifest(stagedRoot);
|
|
899
1210
|
const stagedIssues = manifestIssues(expected, stagedManifest, 'staged install');
|
|
@@ -931,11 +1242,18 @@ export async function installSkillSetTransactional(options: TransactionalInstall
|
|
|
931
1242
|
replaceSkills
|
|
932
1243
|
);
|
|
933
1244
|
await options.failpoint?.({ phase: 'before-mutation' });
|
|
1245
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
934
1246
|
mutationStarted = true;
|
|
935
1247
|
await moveExistingEntries(destination, mutationOwnership.replaceNames, backupRoot, destinationBackups);
|
|
936
1248
|
if (await exists(destinationReceiptPath)) {
|
|
937
|
-
await
|
|
938
|
-
|
|
1249
|
+
await moveRegularFile(
|
|
1250
|
+
destinationReceiptPath,
|
|
1251
|
+
receiptBackupPath,
|
|
1252
|
+
destination,
|
|
1253
|
+
transactionRoot,
|
|
1254
|
+
'install receipt backup',
|
|
1255
|
+
() => { receiptBackedUp = true; }
|
|
1256
|
+
);
|
|
939
1257
|
}
|
|
940
1258
|
await verifyMovedMutationSnapshot(
|
|
941
1259
|
destination,
|
|
@@ -945,18 +1263,34 @@ export async function installSkillSetTransactional(options: TransactionalInstall
|
|
|
945
1263
|
receiptBackupPath
|
|
946
1264
|
);
|
|
947
1265
|
await options.failpoint?.({ phase: 'after-backup' });
|
|
1266
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
948
1267
|
|
|
949
1268
|
for (const skill of skills) {
|
|
1269
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
950
1270
|
const target = path.join(destination, skill);
|
|
951
|
-
await
|
|
952
|
-
|
|
1271
|
+
await moveRegularDirectory(
|
|
1272
|
+
path.join(stagedRoot, skill),
|
|
1273
|
+
target,
|
|
1274
|
+
stagedRoot,
|
|
1275
|
+
destination,
|
|
1276
|
+
'staged skill install',
|
|
1277
|
+
(entry) => installedEntries.push(entry)
|
|
1278
|
+
);
|
|
953
1279
|
await options.failpoint?.({ phase: 'skill-installed', skill });
|
|
954
1280
|
}
|
|
955
|
-
await
|
|
956
|
-
|
|
1281
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
1282
|
+
await moveRegularFile(
|
|
1283
|
+
stagedReceiptPath,
|
|
1284
|
+
destinationReceiptPath,
|
|
1285
|
+
transactionRoot,
|
|
1286
|
+
destination,
|
|
1287
|
+
'staged install receipt',
|
|
1288
|
+
(entry) => { receiptInstalled = entry; }
|
|
1289
|
+
);
|
|
957
1290
|
await options.failpoint?.({ phase: 'receipt-installed' });
|
|
958
1291
|
|
|
959
1292
|
await options.failpoint?.({ phase: 'before-verify' });
|
|
1293
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
960
1294
|
const inspection = await inspectSkillInstallation({
|
|
961
1295
|
sourceRoot,
|
|
962
1296
|
destination,
|
|
@@ -967,13 +1301,10 @@ export async function installSkillSetTransactional(options: TransactionalInstall
|
|
|
967
1301
|
});
|
|
968
1302
|
if (!inspection.ok) throw new Error(`post-install verification failed: ${inspection.issues.join('; ')}`);
|
|
969
1303
|
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
} catch (error) {
|
|
975
|
-
cleanupWarnings.push(`verified install retained cleanup artifact ${cleanup}: ${message(error)}`);
|
|
976
|
-
}
|
|
1304
|
+
try {
|
|
1305
|
+
await removeRecordedEntry(transactionEntry, 'install transaction cleanup');
|
|
1306
|
+
} catch (error) {
|
|
1307
|
+
cleanupWarnings.push(`verified install retained cleanup artifact ${transactionRoot}: ${message(error)}`);
|
|
977
1308
|
}
|
|
978
1309
|
result = {
|
|
979
1310
|
receipt,
|
|
@@ -984,14 +1315,27 @@ export async function installSkillSetTransactional(options: TransactionalInstall
|
|
|
984
1315
|
};
|
|
985
1316
|
} catch (error) {
|
|
986
1317
|
const rollbackErrors: string[] = [];
|
|
987
|
-
|
|
988
|
-
|
|
1318
|
+
let destinationStillOwned = true;
|
|
1319
|
+
try {
|
|
1320
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
1321
|
+
} catch (identityError) {
|
|
1322
|
+
destinationStillOwned = false;
|
|
1323
|
+
rollbackErrors.push(`skill destination identity changed; path-based rollback was not attempted: ${message(identityError)}`);
|
|
1324
|
+
}
|
|
1325
|
+
if (mutationStarted && destinationStillOwned) {
|
|
1326
|
+
if (receiptInstalled) rollbackErrors.push(...await removeInstalledEntries([receiptInstalled]));
|
|
989
1327
|
rollbackErrors.push(...await removeInstalledEntries(installedEntries));
|
|
990
1328
|
rollbackErrors.push(...await restoreMovedEntries(destinationBackups));
|
|
991
1329
|
if (receiptBackedUp) {
|
|
992
1330
|
try {
|
|
993
1331
|
if (await exists(destinationReceiptPath)) throw new Error(`rollback target unexpectedly exists: ${destinationReceiptPath}`);
|
|
994
|
-
await
|
|
1332
|
+
await moveRegularFile(
|
|
1333
|
+
receiptBackupPath,
|
|
1334
|
+
destinationReceiptPath,
|
|
1335
|
+
transactionRoot,
|
|
1336
|
+
destination,
|
|
1337
|
+
'install receipt rollback'
|
|
1338
|
+
);
|
|
995
1339
|
} catch (receiptError) {
|
|
996
1340
|
rollbackErrors.push(message(receiptError));
|
|
997
1341
|
}
|
|
@@ -1000,13 +1344,10 @@ export async function installSkillSetTransactional(options: TransactionalInstall
|
|
|
1000
1344
|
|
|
1001
1345
|
if (rollbackErrors.length === 0) {
|
|
1002
1346
|
const cleanupErrors: string[] = [];
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
} catch (cleanupError) {
|
|
1008
|
-
cleanupErrors.push(`${cleanup}: ${message(cleanupError)}`);
|
|
1009
|
-
}
|
|
1347
|
+
try {
|
|
1348
|
+
await removeRecordedEntry(transactionEntry, 'install transaction cleanup');
|
|
1349
|
+
} catch (cleanupError) {
|
|
1350
|
+
cleanupErrors.push(`${transactionRoot}: ${message(cleanupError)}`);
|
|
1010
1351
|
}
|
|
1011
1352
|
if (cleanupErrors.length) {
|
|
1012
1353
|
throw new Error(
|
|
@@ -1033,7 +1374,8 @@ export async function installSkillSetTransactional(options: TransactionalInstall
|
|
|
1033
1374
|
lockCleanupErrors.push(message(error));
|
|
1034
1375
|
}
|
|
1035
1376
|
try {
|
|
1036
|
-
await
|
|
1377
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
1378
|
+
await removeRecordedEntry(lock.lockIdentity, 'install lock cleanup');
|
|
1037
1379
|
} catch (error) {
|
|
1038
1380
|
lockCleanupErrors.push(message(error));
|
|
1039
1381
|
}
|
|
@@ -1063,6 +1405,7 @@ export async function uninstallSkillSetSafely(options: SafeUninstallOptions): Pr
|
|
|
1063
1405
|
let result: SafeUninstallResult | undefined;
|
|
1064
1406
|
let primaryError: unknown;
|
|
1065
1407
|
try {
|
|
1408
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
1066
1409
|
const recoveryArtifacts = await findInstallRecoveryArtifacts(destination);
|
|
1067
1410
|
if (recoveryArtifacts.length) {
|
|
1068
1411
|
throw new Error(
|
|
@@ -1073,6 +1416,7 @@ export async function uninstallSkillSetSafely(options: SafeUninstallOptions): Pr
|
|
|
1073
1416
|
await preflightSafeUninstallOwnership(destination, options.packageName, skills);
|
|
1074
1417
|
|
|
1075
1418
|
const transactionRoot = await fsp.mkdtemp(path.join(destination, '.yam-flow-install-uninstall-'));
|
|
1419
|
+
const transactionEntry = await captureRecordedEntry(transactionRoot, 'directory', 'uninstall transaction root');
|
|
1076
1420
|
const backupRoot = path.join(transactionRoot, 'backup');
|
|
1077
1421
|
const receiptPath = path.join(destination, INSTALL_RECEIPT_NAME);
|
|
1078
1422
|
const receiptBackup = path.join(transactionRoot, INSTALL_RECEIPT_NAME);
|
|
@@ -1088,10 +1432,17 @@ export async function uninstallSkillSetSafely(options: SafeUninstallOptions): Pr
|
|
|
1088
1432
|
[]
|
|
1089
1433
|
);
|
|
1090
1434
|
await options.failpoint?.({ phase: 'before-mutation' });
|
|
1435
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
1091
1436
|
mutationStarted = true;
|
|
1092
1437
|
await moveExistingEntries(destination, skills, backupRoot, moved);
|
|
1093
|
-
await
|
|
1094
|
-
|
|
1438
|
+
await moveRegularFile(
|
|
1439
|
+
receiptPath,
|
|
1440
|
+
receiptBackup,
|
|
1441
|
+
destination,
|
|
1442
|
+
transactionRoot,
|
|
1443
|
+
'uninstall receipt backup',
|
|
1444
|
+
() => { receiptMoved = true; }
|
|
1445
|
+
);
|
|
1095
1446
|
await verifyMovedMutationSnapshot(
|
|
1096
1447
|
destination,
|
|
1097
1448
|
mutationSnapshot,
|
|
@@ -1100,7 +1451,9 @@ export async function uninstallSkillSetSafely(options: SafeUninstallOptions): Pr
|
|
|
1100
1451
|
receiptBackup
|
|
1101
1452
|
);
|
|
1102
1453
|
await options.failpoint?.({ phase: 'after-backup' });
|
|
1454
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
1103
1455
|
await options.failpoint?.({ phase: 'before-verify' });
|
|
1456
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
1104
1457
|
|
|
1105
1458
|
const remaining = [];
|
|
1106
1459
|
for (const skill of skills) {
|
|
@@ -1112,7 +1465,7 @@ export async function uninstallSkillSetSafely(options: SafeUninstallOptions): Pr
|
|
|
1112
1465
|
}
|
|
1113
1466
|
|
|
1114
1467
|
try {
|
|
1115
|
-
await
|
|
1468
|
+
await removeRecordedEntry(transactionEntry, 'uninstall transaction cleanup');
|
|
1116
1469
|
} catch (error) {
|
|
1117
1470
|
cleanupWarnings.push(`verified uninstall retained cleanup artifact ${transactionRoot}: ${message(error)}`);
|
|
1118
1471
|
}
|
|
@@ -1123,11 +1476,24 @@ export async function uninstallSkillSetSafely(options: SafeUninstallOptions): Pr
|
|
|
1123
1476
|
};
|
|
1124
1477
|
} catch (error) {
|
|
1125
1478
|
const rollbackErrors: string[] = [];
|
|
1126
|
-
|
|
1479
|
+
let destinationStillOwned = true;
|
|
1480
|
+
try {
|
|
1481
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
1482
|
+
} catch (identityError) {
|
|
1483
|
+
destinationStillOwned = false;
|
|
1484
|
+
rollbackErrors.push(`skill destination identity changed; path-based rollback was not attempted: ${message(identityError)}`);
|
|
1485
|
+
}
|
|
1486
|
+
if (mutationStarted && destinationStillOwned) {
|
|
1127
1487
|
if (receiptMoved) {
|
|
1128
1488
|
try {
|
|
1129
1489
|
if (await exists(receiptPath)) throw new Error(`rollback target unexpectedly exists: ${receiptPath}`);
|
|
1130
|
-
await
|
|
1490
|
+
await moveRegularFile(
|
|
1491
|
+
receiptBackup,
|
|
1492
|
+
receiptPath,
|
|
1493
|
+
transactionRoot,
|
|
1494
|
+
destination,
|
|
1495
|
+
'uninstall receipt rollback'
|
|
1496
|
+
);
|
|
1131
1497
|
} catch (receiptError) {
|
|
1132
1498
|
rollbackErrors.push(message(receiptError));
|
|
1133
1499
|
}
|
|
@@ -1136,7 +1502,7 @@ export async function uninstallSkillSetSafely(options: SafeUninstallOptions): Pr
|
|
|
1136
1502
|
}
|
|
1137
1503
|
if (rollbackErrors.length === 0) {
|
|
1138
1504
|
try {
|
|
1139
|
-
await
|
|
1505
|
+
await removeRecordedEntry(transactionEntry, 'uninstall transaction cleanup');
|
|
1140
1506
|
} catch (cleanupError) {
|
|
1141
1507
|
throw new Error(
|
|
1142
1508
|
`yam uninstall failed; previous installation state restored, but transaction cleanup is incomplete: ${message(error)}. `
|
|
@@ -1161,7 +1527,8 @@ export async function uninstallSkillSetSafely(options: SafeUninstallOptions): Pr
|
|
|
1161
1527
|
lockCleanupErrors.push(message(error));
|
|
1162
1528
|
}
|
|
1163
1529
|
try {
|
|
1164
|
-
await
|
|
1530
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
1531
|
+
await removeRecordedEntry(lock.lockIdentity, 'install lock cleanup');
|
|
1165
1532
|
} catch (error) {
|
|
1166
1533
|
lockCleanupErrors.push(message(error));
|
|
1167
1534
|
}
|