yam-flow 2.3.2 → 2.5.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.
@@ -29,7 +29,7 @@ export type InstallReceipt = {
29
29
  };
30
30
  };
31
31
  export type InstallFailpointEvent = {
32
- phase: 'after-stage' | 'after-backup' | 'skill-installed' | 'receipt-installed' | 'mirror-cleaned' | 'before-verify';
32
+ phase: 'after-stage' | 'before-mutation' | 'after-backup' | 'skill-installed' | 'receipt-installed' | 'mirror-cleaned' | 'before-verify';
33
33
  skill?: string;
34
34
  };
35
35
  export type TransactionalInstallOptions = {
@@ -39,6 +39,7 @@ export type TransactionalInstallOptions = {
39
39
  packageName: string;
40
40
  version: string;
41
41
  skills: string[];
42
+ replaceSkills?: string[];
42
43
  legacySkills?: string[];
43
44
  retiredSkills?: string[];
44
45
  now?: () => Date;
@@ -71,6 +72,22 @@ export type TransactionalInstallResult = {
71
72
  sourceDigest: string;
72
73
  cleanupWarnings: string[];
73
74
  };
75
+ export type SafeUninstallOptions = {
76
+ destination: string;
77
+ codexMirror?: string;
78
+ packageName: string;
79
+ skills: string[];
80
+ failpoint?: (event: SafeUninstallFailpointEvent) => void | Promise<void>;
81
+ };
82
+ export type SafeUninstallFailpointEvent = {
83
+ phase: 'before-mutation' | 'after-backup' | 'before-verify';
84
+ };
85
+ export type SafeUninstallResult = {
86
+ receiptPath: string;
87
+ removedSkills: string[];
88
+ cleanupWarnings: string[];
89
+ };
74
90
  export declare function findInstallRecoveryArtifacts(root: string): Promise<string[]>;
75
91
  export declare function inspectSkillInstallation(options: InstallationInspectionOptions): Promise<InstallationInspection>;
76
92
  export declare function installSkillSetTransactional(options: TransactionalInstallOptions): Promise<TransactionalInstallResult>;
93
+ export declare function uninstallSkillSetSafely(options: SafeUninstallOptions): Promise<SafeUninstallResult>;
@@ -27,10 +27,12 @@ function safeRoot(value, label) {
27
27
  }
28
28
  async function exists(target) {
29
29
  try {
30
- await fsp.access(target);
30
+ await fsp.lstat(target);
31
31
  return true;
32
32
  }
33
- catch {
33
+ catch (error) {
34
+ if (error.code !== 'ENOENT')
35
+ throw error;
34
36
  return false;
35
37
  }
36
38
  }
@@ -179,31 +181,314 @@ function isFileDigest(value) {
179
181
  && typeof file.sha256 === 'string'
180
182
  && /^[a-f0-9]{64}$/.test(file.sha256);
181
183
  }
182
- function receiptIssues(receipt, options, destination, expected) {
184
+ function isSafeManifestPath(value) {
185
+ if (!value || value.includes('\\') || path.posix.isAbsolute(value))
186
+ return false;
187
+ const parts = value.split('/');
188
+ return parts.every((part) => part && part !== '.' && part !== '..')
189
+ && path.posix.normalize(value) === value;
190
+ }
191
+ function ownershipReceiptIssues(receipt, packageName, destination) {
183
192
  const issues = [];
184
193
  if (!receipt || typeof receipt !== 'object')
185
194
  return ['install receipt must be a JSON object'];
186
195
  const value = receipt;
187
196
  if (value.schema !== INSTALL_RECEIPT_SCHEMA)
188
197
  issues.push(`install receipt schema mismatch: ${value.schema || 'missing'}`);
189
- if (value.package?.name !== options.packageName)
198
+ if (value.package?.name !== packageName)
190
199
  issues.push(`install receipt package mismatch: ${value.package?.name || 'missing'}`);
191
- if (value.package?.version !== options.version)
192
- issues.push(`install receipt version drift: ${value.package?.version || 'missing'} != ${options.version}`);
200
+ if (typeof value.package?.version !== 'string' || !value.package.version)
201
+ issues.push('install receipt package version missing');
193
202
  if (value.source?.kind !== 'package-bundled-skills')
194
203
  issues.push('install receipt source kind mismatch');
195
- if (value.source?.identity !== `${options.packageName}@${options.version}`)
196
- issues.push('install receipt source identity drift');
197
- if (typeof value.destination !== 'string' || path.resolve(value.destination) !== destination)
198
- issues.push('install receipt destination drift');
199
- if (typeof value.installed_at !== 'string' || Number.isNaN(Date.parse(value.installed_at)))
204
+ if (value.source?.identity !== `${value.package?.name}@${value.package?.version}`) {
205
+ issues.push('install receipt source identity mismatch');
206
+ }
207
+ if (typeof value.destination !== 'string' || path.resolve(value.destination) !== destination) {
208
+ issues.push('install receipt destination mismatch');
209
+ }
210
+ if (typeof value.installed_at !== 'string' || Number.isNaN(Date.parse(value.installed_at))) {
200
211
  issues.push('install receipt timestamp missing or invalid');
201
- if (typeof value.transaction_id !== 'string' || !value.transaction_id)
212
+ }
213
+ if (typeof value.transaction_id !== 'string' || !value.transaction_id) {
202
214
  issues.push('install receipt transaction id missing');
203
- if (JSON.stringify(value.skills) !== JSON.stringify(options.skills))
204
- issues.push('install receipt skill inventory drift');
215
+ }
216
+ if (!Array.isArray(value.skills)
217
+ || value.skills.length === 0
218
+ || !value.skills.every((skill) => typeof skill === 'string' && /^[a-z0-9][a-z0-9-]*$/.test(skill))
219
+ || unique(value.skills).length !== value.skills.length) {
220
+ issues.push('install receipt skill inventory is malformed');
221
+ }
205
222
  if (value.integrity?.algorithm !== 'sha256')
206
223
  issues.push('install receipt integrity algorithm mismatch');
224
+ if (!Array.isArray(value.integrity?.files) || !value.integrity.files.every(isFileDigest)) {
225
+ issues.push('install receipt file manifest is malformed');
226
+ return issues;
227
+ }
228
+ const files = value.integrity.files;
229
+ const paths = files.map((file) => file.path);
230
+ if (files.some((file) => !isSafeManifestPath(file.path))) {
231
+ issues.push('install receipt file manifest contains an unsafe path');
232
+ }
233
+ if (unique(paths).length !== paths.length) {
234
+ issues.push('install receipt file manifest contains duplicate paths');
235
+ }
236
+ if (value.integrity?.file_count !== files.length) {
237
+ issues.push('install receipt file count does not match its manifest');
238
+ }
239
+ if (value.integrity?.source_digest !== manifestDigest(files)) {
240
+ issues.push('install receipt source digest does not match its manifest');
241
+ }
242
+ if (Array.isArray(value.skills)) {
243
+ const inventory = new Set(value.skills);
244
+ for (const file of files) {
245
+ if (!inventory.has(file.path.split('/')[0])) {
246
+ issues.push(`install receipt file is outside its skill inventory: ${file.path}`);
247
+ }
248
+ }
249
+ for (const skill of inventory) {
250
+ if (!files.some((file) => file.path.startsWith(`${skill}/`))) {
251
+ issues.push(`install receipt has no file manifest for skill: ${skill}`);
252
+ }
253
+ }
254
+ }
255
+ return issues;
256
+ }
257
+ async function readOwnershipReceipt(destination, packageName) {
258
+ const receiptPath = path.join(destination, INSTALL_RECEIPT_NAME);
259
+ if (!await exists(receiptPath)) {
260
+ return { exists: false, issues: [`install receipt missing: ${receiptPath}`] };
261
+ }
262
+ try {
263
+ const stat = await fsp.lstat(receiptPath);
264
+ if (!stat.isFile() || stat.isSymbolicLink()) {
265
+ return { exists: true, issues: [`install receipt must be a regular file: ${receiptPath}`] };
266
+ }
267
+ const parsed = JSON.parse(await fsp.readFile(receiptPath, 'utf8'));
268
+ const issues = ownershipReceiptIssues(parsed, packageName, destination);
269
+ return issues.length
270
+ ? { exists: true, issues }
271
+ : { exists: true, receipt: parsed, issues: [] };
272
+ }
273
+ catch (error) {
274
+ return { exists: true, issues: [`install receipt unreadable: ${message(error)}`] };
275
+ }
276
+ }
277
+ function receiptFilesForSkill(receipt, skill) {
278
+ return receipt.integrity.files.filter((file) => file.path.startsWith(`${skill}/`));
279
+ }
280
+ async function verifyReceiptOwnedSkill(root, skill, receipt) {
281
+ if (!receipt.skills.includes(skill)) {
282
+ return [`install receipt does not own active skill: ${skill}`];
283
+ }
284
+ const expected = receiptFilesForSkill(receipt, skill);
285
+ if (expected.length === 0)
286
+ return [`install receipt has no file manifest for skill: ${skill}`];
287
+ const target = path.join(root, skill);
288
+ if (!await exists(target))
289
+ return [`receipt-owned skill is missing: ${skill}`];
290
+ try {
291
+ const actual = await collectTreeManifest(target, skill);
292
+ return manifestIssues(expected, actual, `installed ${skill}`);
293
+ }
294
+ catch (error) {
295
+ return [`installed ${skill} cannot be verified: ${message(error)}`];
296
+ }
297
+ }
298
+ async function preflightInstallOwnership(destination, packageName, skills, cleanupCandidates, replaceSkills) {
299
+ const invalidOverrides = replaceSkills.filter((skill) => !skills.includes(skill));
300
+ if (invalidOverrides.length) {
301
+ throw new Error(`replacement override must name an active skill: ${invalidOverrides.join(', ')}`);
302
+ }
303
+ const existingActive = [];
304
+ for (const skill of skills) {
305
+ if (await exists(path.join(destination, skill)))
306
+ existingActive.push(skill);
307
+ }
308
+ const receiptState = await readOwnershipReceipt(destination, packageName);
309
+ const conflicts = [];
310
+ if (receiptState.exists && !receiptState.receipt) {
311
+ conflicts.push(`existing install receipt is unproven: ${receiptState.issues.join('; ')}`);
312
+ }
313
+ for (const skill of existingActive) {
314
+ if (replaceSkills.includes(skill))
315
+ continue;
316
+ if (!receiptState.receipt) {
317
+ conflicts.push(`${skill} is user-owned or cannot be proven yam-owned: ${receiptState.issues.join('; ')}`);
318
+ continue;
319
+ }
320
+ const ownershipIssues = await verifyReceiptOwnedSkill(destination, skill, receiptState.receipt);
321
+ if (ownershipIssues.length) {
322
+ conflicts.push(`${skill} is user-owned or locally modified: ${ownershipIssues.join('; ')}`);
323
+ }
324
+ }
325
+ if (conflicts.length) {
326
+ throw new Error(`active skill ownership conflict; no files were changed: ${conflicts.join('; ')}. `
327
+ + 'Use an explicit per-skill replacement override only after reviewing the existing files.');
328
+ }
329
+ const replaceNames = [...existingActive];
330
+ if (receiptState.receipt) {
331
+ for (const skill of cleanupCandidates) {
332
+ if (!await exists(path.join(destination, skill)))
333
+ continue;
334
+ const ownershipIssues = await verifyReceiptOwnedSkill(destination, skill, receiptState.receipt);
335
+ if (ownershipIssues.length === 0)
336
+ replaceNames.push(skill);
337
+ }
338
+ }
339
+ return {
340
+ receipt: receiptState.receipt,
341
+ replaceNames: unique(replaceNames)
342
+ };
343
+ }
344
+ async function preflightSafeUninstallOwnership(destination, packageName, skills) {
345
+ const receiptState = await readOwnershipReceipt(destination, packageName);
346
+ if (!receiptState.receipt) {
347
+ throw new Error(`safe uninstall cannot prove yam ownership; no files were changed: ${receiptState.issues.join('; ')}`);
348
+ }
349
+ const receipt = receiptState.receipt;
350
+ const inventoryMatches = receipt.skills.length === skills.length
351
+ && skills.every((skill) => receipt.skills.includes(skill));
352
+ if (!inventoryMatches) {
353
+ throw new Error('safe uninstall cannot prove ownership of the complete active skill inventory; no files were changed: '
354
+ + `receipt has [${receipt.skills.join(', ')}], expected [${skills.join(', ')}]`);
355
+ }
356
+ const conflicts = [];
357
+ for (const skill of skills) {
358
+ const ownershipIssues = await verifyReceiptOwnedSkill(destination, skill, receipt);
359
+ if (ownershipIssues.length)
360
+ conflicts.push(`${skill}: ${ownershipIssues.join('; ')}`);
361
+ }
362
+ if (conflicts.length) {
363
+ throw new Error(`safe uninstall found a user-owned or locally modified active skill; no files were changed: ${conflicts.join('; ')}`);
364
+ }
365
+ return receipt;
366
+ }
367
+ async function readRegularFileBytes(file) {
368
+ const before = await fsp.lstat(file);
369
+ if (!before.isFile() || before.isSymbolicLink())
370
+ throw new Error(`expected a regular file: ${file}`);
371
+ const handle = await fsp.open(file, 'r');
372
+ try {
373
+ const opened = await handle.stat();
374
+ if (!opened.isFile())
375
+ throw new Error(`expected a regular file: ${file}`);
376
+ const bytes = await handle.readFile();
377
+ const after = await fsp.lstat(file);
378
+ if (!after.isFile()
379
+ || after.isSymbolicLink()
380
+ || before.dev !== opened.dev
381
+ || before.ino !== opened.ino
382
+ || after.dev !== opened.dev
383
+ || after.ino !== opened.ino) {
384
+ throw new Error(`regular file changed identity while being captured: ${file}`);
385
+ }
386
+ return bytes;
387
+ }
388
+ finally {
389
+ await handle.close();
390
+ }
391
+ }
392
+ async function captureReceiptMutationSnapshot(destination, packageName) {
393
+ const receiptPath = path.join(destination, INSTALL_RECEIPT_NAME);
394
+ let bytes;
395
+ try {
396
+ bytes = await readRegularFileBytes(receiptPath);
397
+ }
398
+ catch (error) {
399
+ if (error.code === 'ENOENT')
400
+ return { exists: false };
401
+ throw new Error(`cannot capture install receipt ownership boundary: ${message(error)}`);
402
+ }
403
+ let parsed;
404
+ try {
405
+ parsed = JSON.parse(bytes.toString('utf8'));
406
+ }
407
+ catch (error) {
408
+ throw new Error(`cannot capture malformed install receipt: ${message(error)}`);
409
+ }
410
+ const issues = ownershipReceiptIssues(parsed, packageName, destination);
411
+ if (issues.length) {
412
+ throw new Error(`cannot capture unproven install receipt: ${issues.join('; ')}`);
413
+ }
414
+ return {
415
+ exists: true,
416
+ bytes,
417
+ sha256: createHash('sha256').update(bytes).digest('hex'),
418
+ receipt: parsed
419
+ };
420
+ }
421
+ async function captureMutationSnapshot(destination, packageName, names, explicitReplacements) {
422
+ const receipt = await captureReceiptMutationSnapshot(destination, packageName);
423
+ const trees = [];
424
+ for (const name of names) {
425
+ const files = await collectTreeManifest(path.join(destination, name), name);
426
+ if (!explicitReplacements.includes(name)) {
427
+ if (!receipt.exists) {
428
+ throw new Error(`cannot capture receipt-owned skill without an install receipt: ${name}`);
429
+ }
430
+ if (!receipt.receipt.skills.includes(name)) {
431
+ throw new Error(`captured install receipt does not own skill: ${name}`);
432
+ }
433
+ const issues = manifestIssues(receiptFilesForSkill(receipt.receipt, name), files, `captured ${name}`);
434
+ if (issues.length) {
435
+ throw new Error(`captured managed skill changed after ownership preflight: ${issues.join('; ')}`);
436
+ }
437
+ }
438
+ trees.push({ name, files });
439
+ }
440
+ return { trees, receipt };
441
+ }
442
+ async function verifyMovedMutationSnapshot(destination, snapshot, moved, receiptBackedUp, receiptBackupPath) {
443
+ const issues = [];
444
+ const movedByOriginal = new Map(moved.map((entry) => [entry.original, entry]));
445
+ for (const tree of snapshot.trees) {
446
+ const original = path.join(destination, tree.name);
447
+ const movedEntry = movedByOriginal.get(original);
448
+ if (!movedEntry) {
449
+ issues.push(`captured entry was not moved: ${tree.name}`);
450
+ continue;
451
+ }
452
+ try {
453
+ const actual = await collectTreeManifest(movedEntry.backup, tree.name);
454
+ issues.push(...manifestIssues(tree.files, actual, `moved backup ${tree.name}`));
455
+ }
456
+ catch (error) {
457
+ issues.push(`moved backup ${tree.name} cannot be verified: ${message(error)}`);
458
+ }
459
+ }
460
+ if (snapshot.receipt.exists !== receiptBackedUp) {
461
+ issues.push(snapshot.receipt.exists
462
+ ? 'captured install receipt was not moved'
463
+ : 'an install receipt appeared after the ownership snapshot');
464
+ }
465
+ else if (snapshot.receipt.exists && receiptBackedUp) {
466
+ try {
467
+ const bytes = await readRegularFileBytes(receiptBackupPath);
468
+ const sha256 = createHash('sha256').update(bytes).digest('hex');
469
+ if (sha256 !== snapshot.receipt.sha256 || !bytes.equals(snapshot.receipt.bytes)) {
470
+ issues.push('moved install receipt differs from the captured bytes');
471
+ }
472
+ }
473
+ catch (error) {
474
+ issues.push(`moved install receipt cannot be verified: ${message(error)}`);
475
+ }
476
+ }
477
+ if (issues.length) {
478
+ throw new Error(`ownership snapshot mismatch after backup: ${issues.join('; ')}`);
479
+ }
480
+ }
481
+ function receiptIssues(receipt, options, destination, expected) {
482
+ const issues = ownershipReceiptIssues(receipt, options.packageName, destination);
483
+ if (!receipt || typeof receipt !== 'object')
484
+ return issues;
485
+ const value = receipt;
486
+ if (value.package?.version !== options.version)
487
+ issues.push(`install receipt version drift: ${value.package?.version || 'missing'} != ${options.version}`);
488
+ if (value.source?.identity !== `${options.packageName}@${options.version}`)
489
+ issues.push('install receipt source identity drift');
490
+ if (JSON.stringify(value.skills) !== JSON.stringify(options.skills))
491
+ issues.push('install receipt skill inventory drift');
207
492
  if (value.integrity?.file_count !== expected.length)
208
493
  issues.push('install receipt file count drift');
209
494
  const expectedDigest = manifestDigest(expected);
@@ -363,24 +648,24 @@ async function removeInstalledEntries(entries) {
363
648
  export async function installSkillSetTransactional(options) {
364
649
  const sourceRoot = path.resolve(options.sourceRoot);
365
650
  const destination = safeRoot(options.destination, 'skill destination');
366
- const mirror = options.codexMirror ? safeRoot(options.codexMirror, 'Codex mirror') : undefined;
651
+ if (options.codexMirror)
652
+ safeRoot(options.codexMirror, 'Codex mirror');
367
653
  const skills = unique(options.skills);
368
- const managedNames = unique([...skills, ...(options.legacySkills || []), ...(options.retiredSkills || [])]);
369
- validateManagedNames(managedNames);
654
+ const cleanupCandidates = unique([...(options.legacySkills || []), ...(options.retiredSkills || [])]);
655
+ const replaceSkills = unique(options.replaceSkills || []);
656
+ validateManagedNames([...skills, ...cleanupCandidates, ...replaceSkills]);
370
657
  await fsp.mkdir(destination, { recursive: true });
371
658
  const lock = await acquireInstallLock(destination);
372
659
  const cleanupWarnings = [];
373
660
  let result;
374
661
  let primaryError;
375
662
  try {
376
- const recoveryArtifacts = [
377
- ...await findInstallRecoveryArtifacts(destination),
378
- ...(mirror && mirror !== destination ? await findInstallRecoveryArtifacts(mirror) : [])
379
- ];
663
+ const recoveryArtifacts = await findInstallRecoveryArtifacts(destination);
380
664
  if (recoveryArtifacts.length) {
381
665
  throw new Error(`unfinished yam install transaction found: ${recoveryArtifacts.join(', ')}. `
382
666
  + 'Inspect and preserve any backup state before removing the transaction artifact and retrying.');
383
667
  }
668
+ await preflightInstallOwnership(destination, options.packageName, skills, cleanupCandidates, replaceSkills);
384
669
  const expected = await expectedInstallManifest(sourceRoot, skills);
385
670
  const transactionId = options.transactionId || randomUUID();
386
671
  const transactionRoot = await fsp.mkdtemp(path.join(destination, '.yam-flow-install-'));
@@ -389,11 +674,8 @@ export async function installSkillSetTransactional(options) {
389
674
  const stagedReceiptPath = path.join(transactionRoot, 'receipt.json');
390
675
  const destinationReceiptPath = path.join(destination, INSTALL_RECEIPT_NAME);
391
676
  const receiptBackupPath = path.join(transactionRoot, 'receipt.backup');
392
- let mirrorTransactionRoot;
393
- let mirrorBackupRoot;
394
677
  const installedEntries = [];
395
678
  let destinationBackups = [];
396
- let mirrorBackups = [];
397
679
  let receiptBackedUp = false;
398
680
  let receiptInstalled = false;
399
681
  let mutationStarted = false;
@@ -426,17 +708,16 @@ export async function installSkillSetTransactional(options) {
426
708
  };
427
709
  await fsp.writeFile(stagedReceiptPath, `${JSON.stringify(receipt, null, 2)}\n`, { mode: 0o600 });
428
710
  await options.failpoint?.({ phase: 'after-stage' });
429
- if (mirror && mirror !== destination && await exists(mirror)) {
430
- mirrorTransactionRoot = await fsp.mkdtemp(path.join(mirror, '.yam-flow-install-'));
431
- await fsp.chmod(mirrorTransactionRoot, 0o700);
432
- mirrorBackupRoot = path.join(mirrorTransactionRoot, 'backup');
433
- }
711
+ const mutationOwnership = await preflightInstallOwnership(destination, options.packageName, skills, cleanupCandidates, replaceSkills);
712
+ const mutationSnapshot = await captureMutationSnapshot(destination, options.packageName, mutationOwnership.replaceNames, replaceSkills);
713
+ await options.failpoint?.({ phase: 'before-mutation' });
434
714
  mutationStarted = true;
435
- await moveExistingEntries(destination, managedNames, backupRoot, destinationBackups);
715
+ await moveExistingEntries(destination, mutationOwnership.replaceNames, backupRoot, destinationBackups);
436
716
  if (await exists(destinationReceiptPath)) {
437
717
  await fsp.rename(destinationReceiptPath, receiptBackupPath);
438
718
  receiptBackedUp = true;
439
719
  }
720
+ await verifyMovedMutationSnapshot(destination, mutationSnapshot, destinationBackups, receiptBackedUp, receiptBackupPath);
440
721
  await options.failpoint?.({ phase: 'after-backup' });
441
722
  for (const skill of skills) {
442
723
  const target = path.join(destination, skill);
@@ -447,10 +728,6 @@ export async function installSkillSetTransactional(options) {
447
728
  await fsp.rename(stagedReceiptPath, destinationReceiptPath);
448
729
  receiptInstalled = true;
449
730
  await options.failpoint?.({ phase: 'receipt-installed' });
450
- if (mirror && mirrorBackupRoot) {
451
- await moveExistingEntries(mirror, managedNames, mirrorBackupRoot, mirrorBackups);
452
- await options.failpoint?.({ phase: 'mirror-cleaned' });
453
- }
454
731
  await options.failpoint?.({ phase: 'before-verify' });
455
732
  const inspection = await inspectSkillInstallation({
456
733
  sourceRoot,
@@ -462,7 +739,7 @@ export async function installSkillSetTransactional(options) {
462
739
  });
463
740
  if (!inspection.ok)
464
741
  throw new Error(`post-install verification failed: ${inspection.issues.join('; ')}`);
465
- for (const cleanup of [transactionRoot, mirrorTransactionRoot]) {
742
+ for (const cleanup of [transactionRoot]) {
466
743
  if (!cleanup)
467
744
  continue;
468
745
  try {
@@ -497,11 +774,10 @@ export async function installSkillSetTransactional(options) {
497
774
  rollbackErrors.push(message(receiptError));
498
775
  }
499
776
  }
500
- rollbackErrors.push(...await restoreMovedEntries(mirrorBackups));
501
777
  }
502
778
  if (rollbackErrors.length === 0) {
503
779
  const cleanupErrors = [];
504
- for (const cleanup of [transactionRoot, mirrorTransactionRoot]) {
780
+ for (const cleanup of [transactionRoot]) {
505
781
  if (!cleanup)
506
782
  continue;
507
783
  try {
@@ -518,7 +794,7 @@ export async function installSkillSetTransactional(options) {
518
794
  throw new Error(`yam install failed; previous installation state restored: ${message(error)}`);
519
795
  }
520
796
  throw new Error(`yam install failed and rollback is incomplete: ${message(error)}. `
521
- + `Recovery artifacts were kept at ${transactionRoot}${mirrorTransactionRoot ? ` and ${mirrorTransactionRoot}` : ''}. `
797
+ + `Recovery artifacts were kept at ${transactionRoot}. `
522
798
  + `Rollback errors: ${rollbackErrors.join('; ')}`);
523
799
  }
524
800
  }
@@ -552,3 +828,122 @@ export async function installSkillSetTransactional(options) {
552
828
  result.cleanupWarnings = cleanupWarnings;
553
829
  return result;
554
830
  }
831
+ export async function uninstallSkillSetSafely(options) {
832
+ const destination = safeRoot(options.destination, 'skill destination');
833
+ if (options.codexMirror)
834
+ safeRoot(options.codexMirror, 'Codex mirror');
835
+ const skills = unique(options.skills);
836
+ validateManagedNames(skills);
837
+ if (!await exists(destination)) {
838
+ throw new Error(`safe uninstall requires a verified yam install receipt: ${path.join(destination, INSTALL_RECEIPT_NAME)}`);
839
+ }
840
+ const lock = await acquireInstallLock(destination);
841
+ const cleanupWarnings = [];
842
+ let result;
843
+ let primaryError;
844
+ try {
845
+ const recoveryArtifacts = await findInstallRecoveryArtifacts(destination);
846
+ if (recoveryArtifacts.length) {
847
+ throw new Error(`unfinished yam install transaction found: ${recoveryArtifacts.join(', ')}. `
848
+ + 'Inspect and preserve any backup state before removing the transaction artifact and retrying.');
849
+ }
850
+ await preflightSafeUninstallOwnership(destination, options.packageName, skills);
851
+ const transactionRoot = await fsp.mkdtemp(path.join(destination, '.yam-flow-install-uninstall-'));
852
+ const backupRoot = path.join(transactionRoot, 'backup');
853
+ const receiptPath = path.join(destination, INSTALL_RECEIPT_NAME);
854
+ const receiptBackup = path.join(transactionRoot, INSTALL_RECEIPT_NAME);
855
+ const moved = [];
856
+ let receiptMoved = false;
857
+ let mutationStarted = false;
858
+ try {
859
+ await preflightSafeUninstallOwnership(destination, options.packageName, skills);
860
+ const mutationSnapshot = await captureMutationSnapshot(destination, options.packageName, skills, []);
861
+ await options.failpoint?.({ phase: 'before-mutation' });
862
+ mutationStarted = true;
863
+ await moveExistingEntries(destination, skills, backupRoot, moved);
864
+ await fsp.rename(receiptPath, receiptBackup);
865
+ receiptMoved = true;
866
+ await verifyMovedMutationSnapshot(destination, mutationSnapshot, moved, receiptMoved, receiptBackup);
867
+ await options.failpoint?.({ phase: 'after-backup' });
868
+ await options.failpoint?.({ phase: 'before-verify' });
869
+ const remaining = [];
870
+ for (const skill of skills) {
871
+ if (await exists(path.join(destination, skill)))
872
+ remaining.push(skill);
873
+ }
874
+ if (await exists(receiptPath))
875
+ remaining.push(INSTALL_RECEIPT_NAME);
876
+ if (remaining.length) {
877
+ throw new Error(`post-uninstall verification found active entries: ${remaining.join(', ')}`);
878
+ }
879
+ try {
880
+ await fsp.rm(transactionRoot, { recursive: true, force: true });
881
+ }
882
+ catch (error) {
883
+ cleanupWarnings.push(`verified uninstall retained cleanup artifact ${transactionRoot}: ${message(error)}`);
884
+ }
885
+ result = {
886
+ receiptPath,
887
+ removedSkills: skills,
888
+ cleanupWarnings
889
+ };
890
+ }
891
+ catch (error) {
892
+ const rollbackErrors = [];
893
+ if (mutationStarted) {
894
+ if (receiptMoved) {
895
+ try {
896
+ if (await exists(receiptPath))
897
+ throw new Error(`rollback target unexpectedly exists: ${receiptPath}`);
898
+ await fsp.rename(receiptBackup, receiptPath);
899
+ }
900
+ catch (receiptError) {
901
+ rollbackErrors.push(message(receiptError));
902
+ }
903
+ }
904
+ rollbackErrors.push(...await restoreMovedEntries(moved));
905
+ }
906
+ if (rollbackErrors.length === 0) {
907
+ try {
908
+ await fsp.rm(transactionRoot, { recursive: true, force: true });
909
+ }
910
+ catch (cleanupError) {
911
+ throw new Error(`yam uninstall failed; previous installation state restored, but transaction cleanup is incomplete: ${message(error)}. `
912
+ + `Cleanup error: ${message(cleanupError)}`);
913
+ }
914
+ throw new Error(`yam uninstall failed; previous installation state restored: ${message(error)}`);
915
+ }
916
+ throw new Error(`yam uninstall failed and rollback is incomplete: ${message(error)}. `
917
+ + `Recovery artifacts were kept at ${transactionRoot}. Rollback errors: ${rollbackErrors.join('; ')}`);
918
+ }
919
+ }
920
+ catch (error) {
921
+ primaryError = error;
922
+ }
923
+ const lockCleanupErrors = [];
924
+ try {
925
+ await lock.handle.close();
926
+ }
927
+ catch (error) {
928
+ lockCleanupErrors.push(message(error));
929
+ }
930
+ try {
931
+ await fsp.rm(lock.lockPath, { force: true });
932
+ }
933
+ catch (error) {
934
+ lockCleanupErrors.push(message(error));
935
+ }
936
+ if (lockCleanupErrors.length) {
937
+ const warning = `install lock cleanup failed at ${lock.lockPath}: ${lockCleanupErrors.join('; ')}`;
938
+ if (primaryError)
939
+ primaryError = new Error(`${message(primaryError)}. ${warning}`);
940
+ else
941
+ cleanupWarnings.push(warning);
942
+ }
943
+ if (primaryError)
944
+ throw primaryError;
945
+ if (!result)
946
+ throw new Error('yam uninstall ended without a result');
947
+ result.cleanupWarnings = cleanupWarnings;
948
+ return result;
949
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yam-flow",
3
- "version": "2.3.2",
3
+ "version": "2.5.0",
4
4
  "description": "Progressive proof-first agent flow: start fast, deepen deliberately, stay honest by design.",
5
5
  "type": "module",
6
6
  "author": "0kim0bos",
@@ -43,7 +43,7 @@
43
43
  ],
44
44
  "scripts": {
45
45
  "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
46
- "build": "npm run clean && tsc -p tsconfig.json",
46
+ "build": "npm run clean && tsc -p tsconfig.json && node -e \"require('node:fs').chmodSync('dist/bin/yam.js',0o755)\"",
47
47
  "typecheck": "tsc -p tsconfig.json --noEmit",
48
48
  "install:skills": "node ./dist/bin/yam.js install",
49
49
  "uninstall:skills": "node ./dist/bin/yam.js uninstall",
@@ -52,7 +52,7 @@
52
52
  "doctor": "node ./dist/bin/yam.js doctor",
53
53
  "list": "node ./dist/bin/yam.js list",
54
54
  "examples": "node ./dist/bin/yam.js examples",
55
- "verify:self": "npm run build && node --check ./dist/bin/yam.js && node --check ./dist/lib/trust-kernel.js && node --check ./dist/lib/skill-installation.js && node ./scripts/install-transaction-smoke.mjs && node ./dist/bin/yam.js verify && node ./dist/bin/yam.js doctor",
55
+ "verify:self": "npm run build && node --check ./dist/bin/yam.js && node --check ./dist/lib/trust-kernel.js && node --check ./dist/lib/skill-installation.js && node --check ./dist/lib/external-updates.js && npm run hook-latency-smoke && node ./scripts/external-updates-smoke.mjs && node ./scripts/install-transaction-smoke.mjs && node ./dist/bin/yam.js verify && node ./dist/bin/yam.js doctor",
56
56
  "prepare": "npm run build",
57
57
  "prepack": "npm run verify:self",
58
58
  "prepublishOnly": "npm run verify:self",
@@ -64,6 +64,8 @@
64
64
  "dist:freshness": "node ./scripts/check-dist-freshness.mjs",
65
65
  "registry:check": "node ./scripts/check-registry-version.mjs",
66
66
  "cli-smoke": "node ./scripts/cli-smoke.mjs",
67
+ "external-updates-smoke": "npm run build && node ./scripts/external-updates-smoke.mjs",
68
+ "hook-latency-smoke": "node ./scripts/hook-latency-smoke.mjs",
67
69
  "install-transaction-smoke": "npm run build && node ./scripts/install-transaction-smoke.mjs",
68
70
  "release:check": "npm run typecheck && npm run forbidden-names:check && npm run verify:self && npm run dist:freshness && npm run package-boundary:check && npm run registry:check && npm run cli-smoke",
69
71
  "budget": "node ./dist/bin/yam.js budget",