yam-flow 2.3.2 → 2.4.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.
@@ -36,7 +36,14 @@ export type InstallReceipt = {
36
36
  };
37
37
 
38
38
  export type InstallFailpointEvent = {
39
- phase: 'after-stage' | 'after-backup' | 'skill-installed' | 'receipt-installed' | 'mirror-cleaned' | 'before-verify';
39
+ phase:
40
+ | 'after-stage'
41
+ | 'before-mutation'
42
+ | 'after-backup'
43
+ | 'skill-installed'
44
+ | 'receipt-installed'
45
+ | 'mirror-cleaned'
46
+ | 'before-verify';
40
47
  skill?: string;
41
48
  };
42
49
 
@@ -47,6 +54,7 @@ export type TransactionalInstallOptions = {
47
54
  packageName: string;
48
55
  version: string;
49
56
  skills: string[];
57
+ replaceSkills?: string[];
50
58
  legacySkills?: string[];
51
59
  retiredSkills?: string[];
52
60
  now?: () => Date;
@@ -87,11 +95,53 @@ export type TransactionalInstallResult = {
87
95
  cleanupWarnings: string[];
88
96
  };
89
97
 
98
+ export type SafeUninstallOptions = {
99
+ destination: string;
100
+ codexMirror?: string;
101
+ packageName: string;
102
+ skills: string[];
103
+ failpoint?: (event: SafeUninstallFailpointEvent) => void | Promise<void>;
104
+ };
105
+
106
+ export type SafeUninstallFailpointEvent = {
107
+ phase: 'before-mutation' | 'after-backup' | 'before-verify';
108
+ };
109
+
110
+ export type SafeUninstallResult = {
111
+ receiptPath: string;
112
+ removedSkills: string[];
113
+ cleanupWarnings: string[];
114
+ };
115
+
90
116
  type MovedEntry = {
91
117
  original: string;
92
118
  backup: string;
93
119
  };
94
120
 
121
+ type OwnershipPreflight = {
122
+ receipt?: InstallReceipt;
123
+ replaceNames: string[];
124
+ };
125
+
126
+ type TreeMutationSnapshot = {
127
+ name: string;
128
+ files: InstallFileDigest[];
129
+ };
130
+
131
+ type ReceiptMutationSnapshot =
132
+ | { exists: false }
133
+ | {
134
+ exists: true;
135
+ bytes: Buffer;
136
+ sha256: string;
137
+ receipt: InstallReceipt;
138
+ };
139
+
140
+ type MutationSnapshot = {
141
+ trees: TreeMutationSnapshot[];
142
+ receipt: ReceiptMutationSnapshot;
143
+ };
144
+
95
145
  function message(error: unknown) {
96
146
  return error instanceof Error ? error.message : String(error);
97
147
  }
@@ -118,9 +168,10 @@ function safeRoot(value: string, label: string) {
118
168
 
119
169
  async function exists(target: string) {
120
170
  try {
121
- await fsp.access(target);
171
+ await fsp.lstat(target);
122
172
  return true;
123
- } catch {
173
+ } catch (error) {
174
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
124
175
  return false;
125
176
  }
126
177
  }
@@ -272,25 +323,356 @@ function isFileDigest(value: unknown): value is InstallFileDigest {
272
323
  && /^[a-f0-9]{64}$/.test(file.sha256);
273
324
  }
274
325
 
326
+ function isSafeManifestPath(value: string) {
327
+ if (!value || value.includes('\\') || path.posix.isAbsolute(value)) return false;
328
+ const parts = value.split('/');
329
+ return parts.every((part) => part && part !== '.' && part !== '..')
330
+ && path.posix.normalize(value) === value;
331
+ }
332
+
333
+ function ownershipReceiptIssues(
334
+ receipt: unknown,
335
+ packageName: string,
336
+ destination: string
337
+ ) {
338
+ const issues: string[] = [];
339
+ if (!receipt || typeof receipt !== 'object') return ['install receipt must be a JSON object'];
340
+ const value = receipt as Record<string, any>;
341
+ if (value.schema !== INSTALL_RECEIPT_SCHEMA) issues.push(`install receipt schema mismatch: ${value.schema || 'missing'}`);
342
+ if (value.package?.name !== packageName) issues.push(`install receipt package mismatch: ${value.package?.name || 'missing'}`);
343
+ if (typeof value.package?.version !== 'string' || !value.package.version) issues.push('install receipt package version missing');
344
+ if (value.source?.kind !== 'package-bundled-skills') issues.push('install receipt source kind mismatch');
345
+ if (value.source?.identity !== `${value.package?.name}@${value.package?.version}`) {
346
+ issues.push('install receipt source identity mismatch');
347
+ }
348
+ if (typeof value.destination !== 'string' || path.resolve(value.destination) !== destination) {
349
+ issues.push('install receipt destination mismatch');
350
+ }
351
+ if (typeof value.installed_at !== 'string' || Number.isNaN(Date.parse(value.installed_at))) {
352
+ issues.push('install receipt timestamp missing or invalid');
353
+ }
354
+ if (typeof value.transaction_id !== 'string' || !value.transaction_id) {
355
+ issues.push('install receipt transaction id missing');
356
+ }
357
+ if (
358
+ !Array.isArray(value.skills)
359
+ || value.skills.length === 0
360
+ || !value.skills.every((skill: unknown) => typeof skill === 'string' && /^[a-z0-9][a-z0-9-]*$/.test(skill))
361
+ || unique(value.skills).length !== value.skills.length
362
+ ) {
363
+ issues.push('install receipt skill inventory is malformed');
364
+ }
365
+ if (value.integrity?.algorithm !== 'sha256') issues.push('install receipt integrity algorithm mismatch');
366
+ if (!Array.isArray(value.integrity?.files) || !value.integrity.files.every(isFileDigest)) {
367
+ issues.push('install receipt file manifest is malformed');
368
+ return issues;
369
+ }
370
+
371
+ const files = value.integrity.files as InstallFileDigest[];
372
+ const paths = files.map((file) => file.path);
373
+ if (files.some((file) => !isSafeManifestPath(file.path))) {
374
+ issues.push('install receipt file manifest contains an unsafe path');
375
+ }
376
+ if (unique(paths).length !== paths.length) {
377
+ issues.push('install receipt file manifest contains duplicate paths');
378
+ }
379
+ if (value.integrity?.file_count !== files.length) {
380
+ issues.push('install receipt file count does not match its manifest');
381
+ }
382
+ if (value.integrity?.source_digest !== manifestDigest(files)) {
383
+ issues.push('install receipt source digest does not match its manifest');
384
+ }
385
+ if (Array.isArray(value.skills)) {
386
+ const inventory = new Set<string>(value.skills);
387
+ for (const file of files) {
388
+ if (!inventory.has(file.path.split('/')[0])) {
389
+ issues.push(`install receipt file is outside its skill inventory: ${file.path}`);
390
+ }
391
+ }
392
+ for (const skill of inventory) {
393
+ if (!files.some((file) => file.path.startsWith(`${skill}/`))) {
394
+ issues.push(`install receipt has no file manifest for skill: ${skill}`);
395
+ }
396
+ }
397
+ }
398
+ return issues;
399
+ }
400
+
401
+ async function readOwnershipReceipt(
402
+ destination: string,
403
+ packageName: string
404
+ ): Promise<{ exists: boolean; receipt?: InstallReceipt; issues: string[] }> {
405
+ const receiptPath = path.join(destination, INSTALL_RECEIPT_NAME);
406
+ if (!await exists(receiptPath)) {
407
+ return { exists: false, issues: [`install receipt missing: ${receiptPath}`] };
408
+ }
409
+ try {
410
+ const stat = await fsp.lstat(receiptPath);
411
+ if (!stat.isFile() || stat.isSymbolicLink()) {
412
+ return { exists: true, issues: [`install receipt must be a regular file: ${receiptPath}`] };
413
+ }
414
+ const parsed = JSON.parse(await fsp.readFile(receiptPath, 'utf8'));
415
+ const issues = ownershipReceiptIssues(parsed, packageName, destination);
416
+ return issues.length
417
+ ? { exists: true, issues }
418
+ : { exists: true, receipt: parsed as InstallReceipt, issues: [] };
419
+ } catch (error) {
420
+ return { exists: true, issues: [`install receipt unreadable: ${message(error)}`] };
421
+ }
422
+ }
423
+
424
+ function receiptFilesForSkill(receipt: InstallReceipt, skill: string) {
425
+ return receipt.integrity.files.filter((file) => file.path.startsWith(`${skill}/`));
426
+ }
427
+
428
+ async function verifyReceiptOwnedSkill(
429
+ root: string,
430
+ skill: string,
431
+ receipt: InstallReceipt
432
+ ) {
433
+ if (!receipt.skills.includes(skill)) {
434
+ return [`install receipt does not own active skill: ${skill}`];
435
+ }
436
+ const expected = receiptFilesForSkill(receipt, skill);
437
+ if (expected.length === 0) return [`install receipt has no file manifest for skill: ${skill}`];
438
+ const target = path.join(root, skill);
439
+ if (!await exists(target)) return [`receipt-owned skill is missing: ${skill}`];
440
+ try {
441
+ const actual = await collectTreeManifest(target, skill);
442
+ return manifestIssues(expected, actual, `installed ${skill}`);
443
+ } catch (error) {
444
+ return [`installed ${skill} cannot be verified: ${message(error)}`];
445
+ }
446
+ }
447
+
448
+ async function preflightInstallOwnership(
449
+ destination: string,
450
+ packageName: string,
451
+ skills: string[],
452
+ cleanupCandidates: string[],
453
+ replaceSkills: string[]
454
+ ): Promise<OwnershipPreflight> {
455
+ const invalidOverrides = replaceSkills.filter((skill) => !skills.includes(skill));
456
+ if (invalidOverrides.length) {
457
+ throw new Error(`replacement override must name an active skill: ${invalidOverrides.join(', ')}`);
458
+ }
459
+ const existingActive: string[] = [];
460
+ for (const skill of skills) {
461
+ if (await exists(path.join(destination, skill))) existingActive.push(skill);
462
+ }
463
+ const receiptState = await readOwnershipReceipt(destination, packageName);
464
+ const conflicts: string[] = [];
465
+ if (receiptState.exists && !receiptState.receipt) {
466
+ conflicts.push(`existing install receipt is unproven: ${receiptState.issues.join('; ')}`);
467
+ }
468
+ for (const skill of existingActive) {
469
+ if (replaceSkills.includes(skill)) continue;
470
+ if (!receiptState.receipt) {
471
+ conflicts.push(
472
+ `${skill} is user-owned or cannot be proven yam-owned: ${receiptState.issues.join('; ')}`
473
+ );
474
+ continue;
475
+ }
476
+ const ownershipIssues = await verifyReceiptOwnedSkill(destination, skill, receiptState.receipt);
477
+ if (ownershipIssues.length) {
478
+ conflicts.push(`${skill} is user-owned or locally modified: ${ownershipIssues.join('; ')}`);
479
+ }
480
+ }
481
+ if (conflicts.length) {
482
+ throw new Error(
483
+ `active skill ownership conflict; no files were changed: ${conflicts.join('; ')}. `
484
+ + 'Use an explicit per-skill replacement override only after reviewing the existing files.'
485
+ );
486
+ }
487
+
488
+ const replaceNames = [...existingActive];
489
+ if (receiptState.receipt) {
490
+ for (const skill of cleanupCandidates) {
491
+ if (!await exists(path.join(destination, skill))) continue;
492
+ const ownershipIssues = await verifyReceiptOwnedSkill(destination, skill, receiptState.receipt);
493
+ if (ownershipIssues.length === 0) replaceNames.push(skill);
494
+ }
495
+ }
496
+ return {
497
+ receipt: receiptState.receipt,
498
+ replaceNames: unique(replaceNames)
499
+ };
500
+ }
501
+
502
+ async function preflightSafeUninstallOwnership(
503
+ destination: string,
504
+ packageName: string,
505
+ skills: string[]
506
+ ) {
507
+ const receiptState = await readOwnershipReceipt(destination, packageName);
508
+ if (!receiptState.receipt) {
509
+ throw new Error(
510
+ `safe uninstall cannot prove yam ownership; no files were changed: ${receiptState.issues.join('; ')}`
511
+ );
512
+ }
513
+ const receipt = receiptState.receipt;
514
+ const inventoryMatches = receipt.skills.length === skills.length
515
+ && skills.every((skill) => receipt.skills.includes(skill));
516
+ if (!inventoryMatches) {
517
+ throw new Error(
518
+ 'safe uninstall cannot prove ownership of the complete active skill inventory; no files were changed: '
519
+ + `receipt has [${receipt.skills.join(', ')}], expected [${skills.join(', ')}]`
520
+ );
521
+ }
522
+
523
+ const conflicts: string[] = [];
524
+ for (const skill of skills) {
525
+ const ownershipIssues = await verifyReceiptOwnedSkill(destination, skill, receipt);
526
+ if (ownershipIssues.length) conflicts.push(`${skill}: ${ownershipIssues.join('; ')}`);
527
+ }
528
+ if (conflicts.length) {
529
+ throw new Error(
530
+ `safe uninstall found a user-owned or locally modified active skill; no files were changed: ${conflicts.join('; ')}`
531
+ );
532
+ }
533
+ return receipt;
534
+ }
535
+
536
+ async function readRegularFileBytes(file: string) {
537
+ const before = await fsp.lstat(file);
538
+ if (!before.isFile() || before.isSymbolicLink()) throw new Error(`expected a regular file: ${file}`);
539
+ const handle = await fsp.open(file, 'r');
540
+ try {
541
+ const opened = await handle.stat();
542
+ if (!opened.isFile()) throw new Error(`expected a regular file: ${file}`);
543
+ const bytes = await handle.readFile();
544
+ const after = await fsp.lstat(file);
545
+ if (
546
+ !after.isFile()
547
+ || after.isSymbolicLink()
548
+ || before.dev !== opened.dev
549
+ || before.ino !== opened.ino
550
+ || after.dev !== opened.dev
551
+ || after.ino !== opened.ino
552
+ ) {
553
+ throw new Error(`regular file changed identity while being captured: ${file}`);
554
+ }
555
+ return bytes;
556
+ } finally {
557
+ await handle.close();
558
+ }
559
+ }
560
+
561
+ async function captureReceiptMutationSnapshot(
562
+ destination: string,
563
+ packageName: string
564
+ ): Promise<ReceiptMutationSnapshot> {
565
+ const receiptPath = path.join(destination, INSTALL_RECEIPT_NAME);
566
+ let bytes: Buffer;
567
+ try {
568
+ bytes = await readRegularFileBytes(receiptPath);
569
+ } catch (error) {
570
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { exists: false };
571
+ throw new Error(`cannot capture install receipt ownership boundary: ${message(error)}`);
572
+ }
573
+ let parsed: unknown;
574
+ try {
575
+ parsed = JSON.parse(bytes.toString('utf8'));
576
+ } catch (error) {
577
+ throw new Error(`cannot capture malformed install receipt: ${message(error)}`);
578
+ }
579
+ const issues = ownershipReceiptIssues(parsed, packageName, destination);
580
+ if (issues.length) {
581
+ throw new Error(`cannot capture unproven install receipt: ${issues.join('; ')}`);
582
+ }
583
+ return {
584
+ exists: true,
585
+ bytes,
586
+ sha256: createHash('sha256').update(bytes).digest('hex'),
587
+ receipt: parsed as InstallReceipt
588
+ };
589
+ }
590
+
591
+ async function captureMutationSnapshot(
592
+ destination: string,
593
+ packageName: string,
594
+ names: string[],
595
+ explicitReplacements: string[]
596
+ ): Promise<MutationSnapshot> {
597
+ const receipt = await captureReceiptMutationSnapshot(destination, packageName);
598
+ const trees: TreeMutationSnapshot[] = [];
599
+ for (const name of names) {
600
+ const files = await collectTreeManifest(path.join(destination, name), name);
601
+ if (!explicitReplacements.includes(name)) {
602
+ if (!receipt.exists) {
603
+ throw new Error(`cannot capture receipt-owned skill without an install receipt: ${name}`);
604
+ }
605
+ if (!receipt.receipt.skills.includes(name)) {
606
+ throw new Error(`captured install receipt does not own skill: ${name}`);
607
+ }
608
+ const issues = manifestIssues(receiptFilesForSkill(receipt.receipt, name), files, `captured ${name}`);
609
+ if (issues.length) {
610
+ throw new Error(`captured managed skill changed after ownership preflight: ${issues.join('; ')}`);
611
+ }
612
+ }
613
+ trees.push({ name, files });
614
+ }
615
+ return { trees, receipt };
616
+ }
617
+
618
+ async function verifyMovedMutationSnapshot(
619
+ destination: string,
620
+ snapshot: MutationSnapshot,
621
+ moved: MovedEntry[],
622
+ receiptBackedUp: boolean,
623
+ receiptBackupPath: string
624
+ ) {
625
+ const issues: string[] = [];
626
+ const movedByOriginal = new Map(moved.map((entry) => [entry.original, entry]));
627
+ for (const tree of snapshot.trees) {
628
+ const original = path.join(destination, tree.name);
629
+ const movedEntry = movedByOriginal.get(original);
630
+ if (!movedEntry) {
631
+ issues.push(`captured entry was not moved: ${tree.name}`);
632
+ continue;
633
+ }
634
+ try {
635
+ const actual = await collectTreeManifest(movedEntry.backup, tree.name);
636
+ issues.push(...manifestIssues(tree.files, actual, `moved backup ${tree.name}`));
637
+ } catch (error) {
638
+ issues.push(`moved backup ${tree.name} cannot be verified: ${message(error)}`);
639
+ }
640
+ }
641
+
642
+ if (snapshot.receipt.exists !== receiptBackedUp) {
643
+ issues.push(
644
+ snapshot.receipt.exists
645
+ ? 'captured install receipt was not moved'
646
+ : 'an install receipt appeared after the ownership snapshot'
647
+ );
648
+ } else if (snapshot.receipt.exists && receiptBackedUp) {
649
+ try {
650
+ const bytes = await readRegularFileBytes(receiptBackupPath);
651
+ const sha256 = createHash('sha256').update(bytes).digest('hex');
652
+ if (sha256 !== snapshot.receipt.sha256 || !bytes.equals(snapshot.receipt.bytes)) {
653
+ issues.push('moved install receipt differs from the captured bytes');
654
+ }
655
+ } catch (error) {
656
+ issues.push(`moved install receipt cannot be verified: ${message(error)}`);
657
+ }
658
+ }
659
+ if (issues.length) {
660
+ throw new Error(`ownership snapshot mismatch after backup: ${issues.join('; ')}`);
661
+ }
662
+ }
663
+
275
664
  function receiptIssues(
276
665
  receipt: unknown,
277
666
  options: InstallationInspectionOptions,
278
667
  destination: string,
279
668
  expected: InstallFileDigest[]
280
669
  ) {
281
- const issues: string[] = [];
282
- if (!receipt || typeof receipt !== 'object') return ['install receipt must be a JSON object'];
670
+ const issues = ownershipReceiptIssues(receipt, options.packageName, destination);
671
+ if (!receipt || typeof receipt !== 'object') return issues;
283
672
  const value = receipt as Record<string, any>;
284
- if (value.schema !== INSTALL_RECEIPT_SCHEMA) issues.push(`install receipt schema mismatch: ${value.schema || 'missing'}`);
285
- if (value.package?.name !== options.packageName) issues.push(`install receipt package mismatch: ${value.package?.name || 'missing'}`);
286
673
  if (value.package?.version !== options.version) issues.push(`install receipt version drift: ${value.package?.version || 'missing'} != ${options.version}`);
287
- if (value.source?.kind !== 'package-bundled-skills') issues.push('install receipt source kind mismatch');
288
674
  if (value.source?.identity !== `${options.packageName}@${options.version}`) issues.push('install receipt source identity drift');
289
- if (typeof value.destination !== 'string' || path.resolve(value.destination) !== destination) issues.push('install receipt destination drift');
290
- if (typeof value.installed_at !== 'string' || Number.isNaN(Date.parse(value.installed_at))) issues.push('install receipt timestamp missing or invalid');
291
- if (typeof value.transaction_id !== 'string' || !value.transaction_id) issues.push('install receipt transaction id missing');
292
675
  if (JSON.stringify(value.skills) !== JSON.stringify(options.skills)) issues.push('install receipt skill inventory drift');
293
- if (value.integrity?.algorithm !== 'sha256') issues.push('install receipt integrity algorithm mismatch');
294
676
  if (value.integrity?.file_count !== expected.length) issues.push('install receipt file count drift');
295
677
  const expectedDigest = manifestDigest(expected);
296
678
  if (value.integrity?.source_digest !== expectedDigest) issues.push('install receipt source digest drift');
@@ -446,10 +828,11 @@ async function removeInstalledEntries(entries: string[]) {
446
828
  export async function installSkillSetTransactional(options: TransactionalInstallOptions): Promise<TransactionalInstallResult> {
447
829
  const sourceRoot = path.resolve(options.sourceRoot);
448
830
  const destination = safeRoot(options.destination, 'skill destination');
449
- const mirror = options.codexMirror ? safeRoot(options.codexMirror, 'Codex mirror') : undefined;
831
+ if (options.codexMirror) safeRoot(options.codexMirror, 'Codex mirror');
450
832
  const skills = unique(options.skills);
451
- const managedNames = unique([...skills, ...(options.legacySkills || []), ...(options.retiredSkills || [])]);
452
- validateManagedNames(managedNames);
833
+ const cleanupCandidates = unique([...(options.legacySkills || []), ...(options.retiredSkills || [])]);
834
+ const replaceSkills = unique(options.replaceSkills || []);
835
+ validateManagedNames([...skills, ...cleanupCandidates, ...replaceSkills]);
453
836
  await fsp.mkdir(destination, { recursive: true });
454
837
 
455
838
  const lock = await acquireInstallLock(destination);
@@ -457,16 +840,20 @@ export async function installSkillSetTransactional(options: TransactionalInstall
457
840
  let result: TransactionalInstallResult | undefined;
458
841
  let primaryError: unknown;
459
842
  try {
460
- const recoveryArtifacts = [
461
- ...await findInstallRecoveryArtifacts(destination),
462
- ...(mirror && mirror !== destination ? await findInstallRecoveryArtifacts(mirror) : [])
463
- ];
843
+ const recoveryArtifacts = await findInstallRecoveryArtifacts(destination);
464
844
  if (recoveryArtifacts.length) {
465
845
  throw new Error(
466
846
  `unfinished yam install transaction found: ${recoveryArtifacts.join(', ')}. `
467
847
  + 'Inspect and preserve any backup state before removing the transaction artifact and retrying.'
468
848
  );
469
849
  }
850
+ await preflightInstallOwnership(
851
+ destination,
852
+ options.packageName,
853
+ skills,
854
+ cleanupCandidates,
855
+ replaceSkills
856
+ );
470
857
  const expected = await expectedInstallManifest(sourceRoot, skills);
471
858
  const transactionId = options.transactionId || randomUUID();
472
859
  const transactionRoot = await fsp.mkdtemp(path.join(destination, '.yam-flow-install-'));
@@ -475,11 +862,8 @@ export async function installSkillSetTransactional(options: TransactionalInstall
475
862
  const stagedReceiptPath = path.join(transactionRoot, 'receipt.json');
476
863
  const destinationReceiptPath = path.join(destination, INSTALL_RECEIPT_NAME);
477
864
  const receiptBackupPath = path.join(transactionRoot, 'receipt.backup');
478
- let mirrorTransactionRoot: string | undefined;
479
- let mirrorBackupRoot: string | undefined;
480
865
  const installedEntries: string[] = [];
481
866
  let destinationBackups: MovedEntry[] = [];
482
- let mirrorBackups: MovedEntry[] = [];
483
867
  let receiptBackedUp = false;
484
868
  let receiptInstalled = false;
485
869
  let mutationStarted = false;
@@ -514,18 +898,33 @@ export async function installSkillSetTransactional(options: TransactionalInstall
514
898
  await fsp.writeFile(stagedReceiptPath, `${JSON.stringify(receipt, null, 2)}\n`, { mode: 0o600 });
515
899
  await options.failpoint?.({ phase: 'after-stage' });
516
900
 
517
- if (mirror && mirror !== destination && await exists(mirror)) {
518
- mirrorTransactionRoot = await fsp.mkdtemp(path.join(mirror, '.yam-flow-install-'));
519
- await fsp.chmod(mirrorTransactionRoot, 0o700);
520
- mirrorBackupRoot = path.join(mirrorTransactionRoot, 'backup');
521
- }
522
-
901
+ const mutationOwnership = await preflightInstallOwnership(
902
+ destination,
903
+ options.packageName,
904
+ skills,
905
+ cleanupCandidates,
906
+ replaceSkills
907
+ );
908
+ const mutationSnapshot = await captureMutationSnapshot(
909
+ destination,
910
+ options.packageName,
911
+ mutationOwnership.replaceNames,
912
+ replaceSkills
913
+ );
914
+ await options.failpoint?.({ phase: 'before-mutation' });
523
915
  mutationStarted = true;
524
- await moveExistingEntries(destination, managedNames, backupRoot, destinationBackups);
916
+ await moveExistingEntries(destination, mutationOwnership.replaceNames, backupRoot, destinationBackups);
525
917
  if (await exists(destinationReceiptPath)) {
526
918
  await fsp.rename(destinationReceiptPath, receiptBackupPath);
527
919
  receiptBackedUp = true;
528
920
  }
921
+ await verifyMovedMutationSnapshot(
922
+ destination,
923
+ mutationSnapshot,
924
+ destinationBackups,
925
+ receiptBackedUp,
926
+ receiptBackupPath
927
+ );
529
928
  await options.failpoint?.({ phase: 'after-backup' });
530
929
 
531
930
  for (const skill of skills) {
@@ -538,11 +937,6 @@ export async function installSkillSetTransactional(options: TransactionalInstall
538
937
  receiptInstalled = true;
539
938
  await options.failpoint?.({ phase: 'receipt-installed' });
540
939
 
541
- if (mirror && mirrorBackupRoot) {
542
- await moveExistingEntries(mirror, managedNames, mirrorBackupRoot, mirrorBackups);
543
- await options.failpoint?.({ phase: 'mirror-cleaned' });
544
- }
545
-
546
940
  await options.failpoint?.({ phase: 'before-verify' });
547
941
  const inspection = await inspectSkillInstallation({
548
942
  sourceRoot,
@@ -554,7 +948,7 @@ export async function installSkillSetTransactional(options: TransactionalInstall
554
948
  });
555
949
  if (!inspection.ok) throw new Error(`post-install verification failed: ${inspection.issues.join('; ')}`);
556
950
 
557
- for (const cleanup of [transactionRoot, mirrorTransactionRoot]) {
951
+ for (const cleanup of [transactionRoot]) {
558
952
  if (!cleanup) continue;
559
953
  try {
560
954
  await fsp.rm(cleanup, { recursive: true, force: true });
@@ -583,12 +977,11 @@ export async function installSkillSetTransactional(options: TransactionalInstall
583
977
  rollbackErrors.push(message(receiptError));
584
978
  }
585
979
  }
586
- rollbackErrors.push(...await restoreMovedEntries(mirrorBackups));
587
980
  }
588
981
 
589
982
  if (rollbackErrors.length === 0) {
590
983
  const cleanupErrors: string[] = [];
591
- for (const cleanup of [transactionRoot, mirrorTransactionRoot]) {
984
+ for (const cleanup of [transactionRoot]) {
592
985
  if (!cleanup) continue;
593
986
  try {
594
987
  await fsp.rm(cleanup, { recursive: true, force: true });
@@ -606,7 +999,7 @@ export async function installSkillSetTransactional(options: TransactionalInstall
606
999
  }
607
1000
  throw new Error(
608
1001
  `yam install failed and rollback is incomplete: ${message(error)}. `
609
- + `Recovery artifacts were kept at ${transactionRoot}${mirrorTransactionRoot ? ` and ${mirrorTransactionRoot}` : ''}. `
1002
+ + `Recovery artifacts were kept at ${transactionRoot}. `
610
1003
  + `Rollback errors: ${rollbackErrors.join('; ')}`
611
1004
  );
612
1005
  }
@@ -636,3 +1029,131 @@ export async function installSkillSetTransactional(options: TransactionalInstall
636
1029
  result.cleanupWarnings = cleanupWarnings;
637
1030
  return result;
638
1031
  }
1032
+
1033
+ export async function uninstallSkillSetSafely(options: SafeUninstallOptions): Promise<SafeUninstallResult> {
1034
+ const destination = safeRoot(options.destination, 'skill destination');
1035
+ if (options.codexMirror) safeRoot(options.codexMirror, 'Codex mirror');
1036
+ const skills = unique(options.skills);
1037
+ validateManagedNames(skills);
1038
+ if (!await exists(destination)) {
1039
+ throw new Error(`safe uninstall requires a verified yam install receipt: ${path.join(destination, INSTALL_RECEIPT_NAME)}`);
1040
+ }
1041
+
1042
+ const lock = await acquireInstallLock(destination);
1043
+ const cleanupWarnings: string[] = [];
1044
+ let result: SafeUninstallResult | undefined;
1045
+ let primaryError: unknown;
1046
+ try {
1047
+ const recoveryArtifacts = await findInstallRecoveryArtifacts(destination);
1048
+ if (recoveryArtifacts.length) {
1049
+ throw new Error(
1050
+ `unfinished yam install transaction found: ${recoveryArtifacts.join(', ')}. `
1051
+ + 'Inspect and preserve any backup state before removing the transaction artifact and retrying.'
1052
+ );
1053
+ }
1054
+ await preflightSafeUninstallOwnership(destination, options.packageName, skills);
1055
+
1056
+ const transactionRoot = await fsp.mkdtemp(path.join(destination, '.yam-flow-install-uninstall-'));
1057
+ const backupRoot = path.join(transactionRoot, 'backup');
1058
+ const receiptPath = path.join(destination, INSTALL_RECEIPT_NAME);
1059
+ const receiptBackup = path.join(transactionRoot, INSTALL_RECEIPT_NAME);
1060
+ const moved: MovedEntry[] = [];
1061
+ let receiptMoved = false;
1062
+ let mutationStarted = false;
1063
+ try {
1064
+ await preflightSafeUninstallOwnership(destination, options.packageName, skills);
1065
+ const mutationSnapshot = await captureMutationSnapshot(
1066
+ destination,
1067
+ options.packageName,
1068
+ skills,
1069
+ []
1070
+ );
1071
+ await options.failpoint?.({ phase: 'before-mutation' });
1072
+ mutationStarted = true;
1073
+ await moveExistingEntries(destination, skills, backupRoot, moved);
1074
+ await fsp.rename(receiptPath, receiptBackup);
1075
+ receiptMoved = true;
1076
+ await verifyMovedMutationSnapshot(
1077
+ destination,
1078
+ mutationSnapshot,
1079
+ moved,
1080
+ receiptMoved,
1081
+ receiptBackup
1082
+ );
1083
+ await options.failpoint?.({ phase: 'after-backup' });
1084
+ await options.failpoint?.({ phase: 'before-verify' });
1085
+
1086
+ const remaining = [];
1087
+ for (const skill of skills) {
1088
+ if (await exists(path.join(destination, skill))) remaining.push(skill);
1089
+ }
1090
+ if (await exists(receiptPath)) remaining.push(INSTALL_RECEIPT_NAME);
1091
+ if (remaining.length) {
1092
+ throw new Error(`post-uninstall verification found active entries: ${remaining.join(', ')}`);
1093
+ }
1094
+
1095
+ try {
1096
+ await fsp.rm(transactionRoot, { recursive: true, force: true });
1097
+ } catch (error) {
1098
+ cleanupWarnings.push(`verified uninstall retained cleanup artifact ${transactionRoot}: ${message(error)}`);
1099
+ }
1100
+ result = {
1101
+ receiptPath,
1102
+ removedSkills: skills,
1103
+ cleanupWarnings
1104
+ };
1105
+ } catch (error) {
1106
+ const rollbackErrors: string[] = [];
1107
+ if (mutationStarted) {
1108
+ if (receiptMoved) {
1109
+ try {
1110
+ if (await exists(receiptPath)) throw new Error(`rollback target unexpectedly exists: ${receiptPath}`);
1111
+ await fsp.rename(receiptBackup, receiptPath);
1112
+ } catch (receiptError) {
1113
+ rollbackErrors.push(message(receiptError));
1114
+ }
1115
+ }
1116
+ rollbackErrors.push(...await restoreMovedEntries(moved));
1117
+ }
1118
+ if (rollbackErrors.length === 0) {
1119
+ try {
1120
+ await fsp.rm(transactionRoot, { recursive: true, force: true });
1121
+ } catch (cleanupError) {
1122
+ throw new Error(
1123
+ `yam uninstall failed; previous installation state restored, but transaction cleanup is incomplete: ${message(error)}. `
1124
+ + `Cleanup error: ${message(cleanupError)}`
1125
+ );
1126
+ }
1127
+ throw new Error(`yam uninstall failed; previous installation state restored: ${message(error)}`);
1128
+ }
1129
+ throw new Error(
1130
+ `yam uninstall failed and rollback is incomplete: ${message(error)}. `
1131
+ + `Recovery artifacts were kept at ${transactionRoot}. Rollback errors: ${rollbackErrors.join('; ')}`
1132
+ );
1133
+ }
1134
+ } catch (error) {
1135
+ primaryError = error;
1136
+ }
1137
+
1138
+ const lockCleanupErrors: string[] = [];
1139
+ try {
1140
+ await lock.handle.close();
1141
+ } catch (error) {
1142
+ lockCleanupErrors.push(message(error));
1143
+ }
1144
+ try {
1145
+ await fsp.rm(lock.lockPath, { force: true });
1146
+ } catch (error) {
1147
+ lockCleanupErrors.push(message(error));
1148
+ }
1149
+ if (lockCleanupErrors.length) {
1150
+ const warning = `install lock cleanup failed at ${lock.lockPath}: ${lockCleanupErrors.join('; ')}`;
1151
+ if (primaryError) primaryError = new Error(`${message(primaryError)}. ${warning}`);
1152
+ else cleanupWarnings.push(warning);
1153
+ }
1154
+
1155
+ if (primaryError) throw primaryError;
1156
+ if (!result) throw new Error('yam uninstall ended without a result');
1157
+ result.cleanupWarnings = cleanupWarnings;
1158
+ return result;
1159
+ }