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.
- package/CHANGELOG.md +22 -0
- package/COMMANDS.md +3 -1
- package/README.md +17 -4
- package/ROADMAP.md +2 -2
- package/dist/bin/yam.js +604 -57
- package/dist/lib/skill-installation.d.ts +18 -1
- package/dist/lib/skill-installation.js +433 -38
- package/package.json +3 -2
- package/src/bin/yam.ts +625 -55
- package/src/lib/skill-installation.ts +559 -38
- package/yam.manifest.json +2 -2
|
@@ -36,7 +36,14 @@ export type InstallReceipt = {
|
|
|
36
36
|
};
|
|
37
37
|
|
|
38
38
|
export type InstallFailpointEvent = {
|
|
39
|
-
phase:
|
|
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.
|
|
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
|
|
282
|
-
if (!receipt || typeof receipt !== 'object') return
|
|
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
|
-
|
|
831
|
+
if (options.codexMirror) safeRoot(options.codexMirror, 'Codex mirror');
|
|
450
832
|
const skills = unique(options.skills);
|
|
451
|
-
const
|
|
452
|
-
|
|
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
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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,
|
|
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
|
|
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
|
|
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}
|
|
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
|
+
}
|