yam-flow 2.7.0 → 2.8.1
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/AGENTS.md +5 -0
- package/CHANGELOG.md +41 -0
- package/COMMANDS.md +52 -5
- package/README.md +40 -8
- package/ROADMAP.md +17 -6
- package/dist/bin/yam.js +462 -74
- package/dist/lib/external-updates.d.ts +9 -0
- package/dist/lib/external-updates.js +232 -4
- package/dist/lib/next-step.d.ts +61 -0
- package/dist/lib/next-step.js +340 -0
- package/dist/lib/release-auth-readiness.d.ts +81 -0
- package/dist/lib/release-auth-readiness.js +106 -0
- package/dist/lib/release-registry-status.d.ts +18 -0
- package/dist/lib/release-registry-status.js +37 -0
- package/dist/lib/scout-receipt.d.ts +89 -0
- package/dist/lib/scout-receipt.js +542 -0
- package/dist/lib/security-check.d.ts +98 -0
- package/dist/lib/security-check.js +262 -0
- package/dist/lib/security-integration.d.ts +8 -0
- package/dist/lib/security-integration.js +428 -0
- package/dist/lib/skill-installation.d.ts +30 -0
- package/dist/lib/skill-installation.js +470 -82
- package/dist/lib/trust-kernel.d.ts +2 -0
- package/dist/lib/trust-kernel.js +21 -2
- package/package.json +26 -4
- package/references/final-report.md +17 -1
- package/references/next-step.md +67 -0
- package/references/scout-wide-scan.md +16 -0
- package/references/security-check.md +131 -0
- package/references/study-note.md +10 -2
- package/references/tool-trust-layer.md +2 -0
- package/skills/deep/SKILL.md +6 -0
- package/skills/mission/SKILL.md +6 -0
- package/skills/quick/SKILL.md +5 -0
- package/skills/scout/SKILL.md +19 -9
- package/skills/ueye/SKILL.md +5 -0
- package/src/bin/yam.ts +465 -72
- package/src/lib/external-updates.ts +262 -4
- package/src/lib/next-step.ts +397 -0
- package/src/lib/release-auth-readiness.ts +148 -0
- package/src/lib/release-registry-status.ts +55 -0
- package/src/lib/scout-receipt.ts +593 -0
- package/src/lib/security-check.ts +184 -0
- package/src/lib/security-integration.ts +299 -0
- package/src/lib/skill-installation.ts +542 -87
- package/src/lib/trust-kernel.ts +25 -2
- package/templates/security-check-note.md +42 -0
- package/templates/security-gate.yml +44 -0
- package/yam.manifest.json +4 -3
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
-
import { constants as fsConstants } from 'node:fs';
|
|
2
|
+
import { constants as fsConstants, type BigIntStats } from 'node:fs';
|
|
3
3
|
import fsp from 'node:fs/promises';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { compareStableText } from './stable-order.js';
|
|
@@ -8,6 +8,7 @@ export const INSTALL_RECEIPT_NAME = '.yam-flow-install-receipt.json';
|
|
|
8
8
|
export const INSTALL_LOCK_NAME = '.yam-flow-install.lock';
|
|
9
9
|
export const INSTALL_RECEIPT_SCHEMA = 'yam.install-receipt.v1';
|
|
10
10
|
export const INSTALL_TRANSACTION_PREFIX = '.yam-flow-install-';
|
|
11
|
+
const TRANSACTION_OWNER_MARKER = '.yam-flow-transaction-owner';
|
|
11
12
|
|
|
12
13
|
export type InstallFileDigest = {
|
|
13
14
|
path: string;
|
|
@@ -97,6 +98,37 @@ export type TransactionalInstallResult = {
|
|
|
97
98
|
cleanupWarnings: string[];
|
|
98
99
|
};
|
|
99
100
|
|
|
101
|
+
export type SkillInstallPlanOperation = {
|
|
102
|
+
action: 'create' | 'replace_managed' | 'replace_explicitly_authorized' | 'remove_retired' | 'write_receipt';
|
|
103
|
+
skill: string;
|
|
104
|
+
path: string;
|
|
105
|
+
reason: string;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export type SkillInstallPlan = {
|
|
109
|
+
schema: 'yam.install-plan.v1';
|
|
110
|
+
generated_at: string;
|
|
111
|
+
mutation_authorized: false;
|
|
112
|
+
package: {
|
|
113
|
+
name: string;
|
|
114
|
+
version: string;
|
|
115
|
+
};
|
|
116
|
+
source: {
|
|
117
|
+
root: string;
|
|
118
|
+
digest: string;
|
|
119
|
+
file_count: number;
|
|
120
|
+
};
|
|
121
|
+
destination: string;
|
|
122
|
+
receipt_path: string;
|
|
123
|
+
operations: SkillInstallPlanOperation[];
|
|
124
|
+
preserved_paths: string[];
|
|
125
|
+
blockers: string[];
|
|
126
|
+
ready: boolean;
|
|
127
|
+
truth_status: 'verified' | 'blocked';
|
|
128
|
+
plan_digest: string;
|
|
129
|
+
next_action: string;
|
|
130
|
+
};
|
|
131
|
+
|
|
100
132
|
export type SafeUninstallOptions = {
|
|
101
133
|
destination: string;
|
|
102
134
|
codexMirror?: string;
|
|
@@ -152,8 +184,19 @@ type PathIdentity = {
|
|
|
152
184
|
ino: number;
|
|
153
185
|
};
|
|
154
186
|
|
|
155
|
-
type InstalledEntry =
|
|
187
|
+
type InstalledEntry = {
|
|
188
|
+
path: string;
|
|
189
|
+
dev: bigint;
|
|
190
|
+
ino: bigint;
|
|
156
191
|
kind: 'directory' | 'file';
|
|
192
|
+
birthtimeNs?: bigint;
|
|
193
|
+
handle?: fsp.FileHandle;
|
|
194
|
+
contentsSha256?: string;
|
|
195
|
+
treeDigest?: string;
|
|
196
|
+
ownershipMarker?: {
|
|
197
|
+
name: string;
|
|
198
|
+
sha256: string;
|
|
199
|
+
};
|
|
157
200
|
};
|
|
158
201
|
|
|
159
202
|
function message(error: unknown) {
|
|
@@ -267,31 +310,68 @@ function readOnlyNoFollowFlags() {
|
|
|
267
310
|
return fsConstants.O_RDONLY | noFollow;
|
|
268
311
|
}
|
|
269
312
|
|
|
313
|
+
async function confirmWindowsPathForHandle(
|
|
314
|
+
target: string,
|
|
315
|
+
opened: BigIntStats,
|
|
316
|
+
before: BigIntStats,
|
|
317
|
+
label: string
|
|
318
|
+
) {
|
|
319
|
+
if (before.birthtimeNs <= 0n) {
|
|
320
|
+
throw new Error(`${label} requires a positive birthtime on Windows: ${target}`);
|
|
321
|
+
}
|
|
322
|
+
let pathHandle: fsp.FileHandle | undefined;
|
|
323
|
+
try {
|
|
324
|
+
pathHandle = await fsp.open(target, fsConstants.O_RDONLY);
|
|
325
|
+
const pathOpened = await pathHandle.stat({ bigint: true });
|
|
326
|
+
const confirmed = await fsp.lstat(target, { bigint: true });
|
|
327
|
+
if (
|
|
328
|
+
!opened.isFile()
|
|
329
|
+
|| !pathOpened.isFile()
|
|
330
|
+
|| pathOpened.dev !== opened.dev
|
|
331
|
+
|| pathOpened.ino !== opened.ino
|
|
332
|
+
|| confirmed.isSymbolicLink()
|
|
333
|
+
|| !confirmed.isFile()
|
|
334
|
+
|| confirmed.dev !== before.dev
|
|
335
|
+
|| confirmed.ino !== before.ino
|
|
336
|
+
|| confirmed.birthtimeNs !== before.birthtimeNs
|
|
337
|
+
) {
|
|
338
|
+
throw new Error(`${label} changed identity while confirming Windows path ownership: ${target}`);
|
|
339
|
+
}
|
|
340
|
+
return confirmed;
|
|
341
|
+
} finally {
|
|
342
|
+
if (pathHandle) await pathHandle.close();
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
270
346
|
async function captureRegularFile(file: string, boundary = path.dirname(file)) {
|
|
271
347
|
const parents = await captureRegularParentPath(boundary, file, 'regular file read');
|
|
272
|
-
const before = await fsp.lstat(file);
|
|
348
|
+
const before = await fsp.lstat(file, { bigint: true });
|
|
273
349
|
if (!before.isFile() || before.isSymbolicLink()) throw new Error(`expected a regular file: ${file}`);
|
|
274
350
|
const handle = await fsp.open(file, readOnlyNoFollowFlags());
|
|
275
351
|
try {
|
|
276
|
-
const opened = await handle.stat();
|
|
352
|
+
const opened = await handle.stat({ bigint: true });
|
|
277
353
|
if (!opened.isFile()) throw new Error(`expected a regular file: ${file}`);
|
|
278
354
|
const bytes = await handle.readFile();
|
|
279
|
-
const afterOpened = await handle.stat();
|
|
280
|
-
const after =
|
|
355
|
+
const afterOpened = await handle.stat({ bigint: true });
|
|
356
|
+
const after = process.platform === 'win32'
|
|
357
|
+
? await confirmWindowsPathForHandle(file, opened, before, 'regular file')
|
|
358
|
+
: await fsp.lstat(file, { bigint: true });
|
|
281
359
|
await revalidateRegularParentPath(parents, 'regular file read');
|
|
282
360
|
if (
|
|
283
361
|
!after.isFile()
|
|
284
362
|
|| after.isSymbolicLink()
|
|
285
|
-
|| before.dev !== opened.dev
|
|
286
|
-
|| before.ino !== opened.ino
|
|
287
363
|
|| afterOpened.dev !== opened.dev
|
|
288
364
|
|| afterOpened.ino !== opened.ino
|
|
289
|
-
||
|
|
290
|
-
|
|
365
|
+
|| (process.platform !== 'win32' && (
|
|
366
|
+
before.dev !== opened.dev
|
|
367
|
+
|| before.ino !== opened.ino
|
|
368
|
+
|| after.dev !== opened.dev
|
|
369
|
+
|| after.ino !== opened.ino
|
|
370
|
+
))
|
|
291
371
|
) {
|
|
292
372
|
throw new Error(`regular file changed identity while being captured: ${file}`);
|
|
293
373
|
}
|
|
294
|
-
return { bytes, mode: opened.mode &
|
|
374
|
+
return { bytes, mode: Number(opened.mode & 0o777n) };
|
|
295
375
|
} finally {
|
|
296
376
|
await handle.close();
|
|
297
377
|
}
|
|
@@ -319,20 +399,25 @@ async function copyRegularFile(
|
|
|
319
399
|
);
|
|
320
400
|
try {
|
|
321
401
|
await handle.writeFile(sourceFile.bytes);
|
|
322
|
-
const opened = await handle.stat();
|
|
402
|
+
const opened = await handle.stat({ bigint: true });
|
|
323
403
|
if (!opened.isFile()) throw new Error(`install copy target must be a regular file: ${target}`);
|
|
324
404
|
const openedIdentity = { dev: opened.dev, ino: opened.ino };
|
|
325
|
-
const afterOpened = await handle.stat();
|
|
405
|
+
const afterOpened = await handle.stat({ bigint: true });
|
|
326
406
|
if (afterOpened.dev !== openedIdentity.dev || afterOpened.ino !== openedIdentity.ino) {
|
|
327
407
|
throw new Error(`install copy target changed descriptor identity: ${target}`);
|
|
328
408
|
}
|
|
329
409
|
await revalidateRegularParentPath(targetParents, 'install copy target');
|
|
330
|
-
const
|
|
410
|
+
const beforeInstalled = await fsp.lstat(target, { bigint: true });
|
|
411
|
+
const installed = process.platform === 'win32'
|
|
412
|
+
? await confirmWindowsPathForHandle(target, opened, beforeInstalled, 'install copy target')
|
|
413
|
+
: beforeInstalled;
|
|
331
414
|
if (
|
|
332
415
|
!installed.isFile()
|
|
333
416
|
|| installed.isSymbolicLink()
|
|
334
|
-
||
|
|
335
|
-
|
|
417
|
+
|| (process.platform !== 'win32' && (
|
|
418
|
+
installed.dev !== openedIdentity.dev
|
|
419
|
+
|| installed.ino !== openedIdentity.ino
|
|
420
|
+
))
|
|
336
421
|
) {
|
|
337
422
|
throw new Error(`install copy target changed path identity: ${target}`);
|
|
338
423
|
}
|
|
@@ -413,8 +498,8 @@ async function expectedInstallManifest(sourceRoot: string, skills: string[]) {
|
|
|
413
498
|
const skillFile = path.join(sourceRoot, 'skills', skill, 'SKILL.md');
|
|
414
499
|
const skillBytes = await readRegularFileBytes(skillFile, sourceRoot);
|
|
415
500
|
const skillText = skillBytes.toString('utf8');
|
|
416
|
-
const frontmatter = skillText.match(/^---\n([\s\S]*?)\n
|
|
417
|
-
const name = frontmatter?.[1].match(/^name:\s*([^\n]+)\s*$/m)?.[1]
|
|
501
|
+
const frontmatter = skillText.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
502
|
+
const name = frontmatter?.[1].match(/^name:\s*([^\r\n]+)\s*$/m)?.[1]
|
|
418
503
|
?.trim()
|
|
419
504
|
.replace(/^["']|["']$/g, '');
|
|
420
505
|
if (name !== skill) {
|
|
@@ -471,8 +556,9 @@ async function moveRegularDirectory(
|
|
|
471
556
|
sourceBoundary: string,
|
|
472
557
|
targetBoundary: string,
|
|
473
558
|
label: string,
|
|
474
|
-
onMoved?: (
|
|
475
|
-
|
|
559
|
+
onMoved?: () => void,
|
|
560
|
+
identityOwner?: InstalledEntry[]
|
|
561
|
+
): Promise<InstalledEntry | undefined> {
|
|
476
562
|
const sourceParents = await captureRegularParentPath(sourceBoundary, source, `${label} source`);
|
|
477
563
|
const sourceStat = await fsp.lstat(source);
|
|
478
564
|
if (!sourceStat.isDirectory() || sourceStat.isSymbolicLink()) {
|
|
@@ -480,18 +566,31 @@ async function moveRegularDirectory(
|
|
|
480
566
|
}
|
|
481
567
|
const targetParents = await captureRegularParentPath(targetBoundary, target, `${label} target`);
|
|
482
568
|
if (await exists(target)) throw new Error(`${label} target unexpectedly exists: ${target}`);
|
|
483
|
-
await
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
569
|
+
const recorded = identityOwner ? await captureRecordedEntry(source, 'directory', label) : undefined;
|
|
570
|
+
let identityTransferred = false;
|
|
571
|
+
try {
|
|
572
|
+
await fsp.rename(source, target);
|
|
573
|
+
if (!recorded) onMoved?.();
|
|
574
|
+
if (recorded) {
|
|
575
|
+
recorded.path = target;
|
|
576
|
+
identityOwner?.push(recorded);
|
|
577
|
+
identityTransferred = true;
|
|
578
|
+
}
|
|
579
|
+
await revalidateRegularParentPath(sourceParents, `${label} source`);
|
|
580
|
+
await revalidateRegularParentPath(targetParents, `${label} target`);
|
|
581
|
+
const targetStat = await fsp.lstat(target);
|
|
582
|
+
if (
|
|
583
|
+
!targetStat.isDirectory()
|
|
584
|
+
|| targetStat.isSymbolicLink()
|
|
585
|
+
|| targetStat.dev !== sourceStat.dev
|
|
586
|
+
|| targetStat.ino !== sourceStat.ino
|
|
587
|
+
) {
|
|
588
|
+
throw new Error(`${label} directory changed identity while being moved: ${target}`);
|
|
589
|
+
}
|
|
590
|
+
return recorded;
|
|
591
|
+
} catch (error) {
|
|
592
|
+
if (recorded && !identityTransferred) await closeRecordedEntry(recorded);
|
|
593
|
+
throw error;
|
|
495
594
|
}
|
|
496
595
|
}
|
|
497
596
|
|
|
@@ -501,8 +600,9 @@ async function moveRegularFile(
|
|
|
501
600
|
sourceBoundary: string,
|
|
502
601
|
targetBoundary: string,
|
|
503
602
|
label: string,
|
|
504
|
-
onMoved?: (
|
|
505
|
-
|
|
603
|
+
onMoved?: () => void,
|
|
604
|
+
identityOwner?: InstalledEntry[]
|
|
605
|
+
): Promise<InstalledEntry | undefined> {
|
|
506
606
|
const sourceParents = await captureRegularParentPath(sourceBoundary, source, `${label} source`);
|
|
507
607
|
const sourceStat = await fsp.lstat(source);
|
|
508
608
|
if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) {
|
|
@@ -510,18 +610,31 @@ async function moveRegularFile(
|
|
|
510
610
|
}
|
|
511
611
|
const targetParents = await captureRegularParentPath(targetBoundary, target, `${label} target`);
|
|
512
612
|
if (await exists(target)) throw new Error(`${label} target unexpectedly exists: ${target}`);
|
|
513
|
-
await
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
613
|
+
const recorded = identityOwner ? await captureRecordedEntry(source, 'file', label) : undefined;
|
|
614
|
+
let identityTransferred = false;
|
|
615
|
+
try {
|
|
616
|
+
await fsp.rename(source, target);
|
|
617
|
+
if (!recorded) onMoved?.();
|
|
618
|
+
if (recorded) {
|
|
619
|
+
recorded.path = target;
|
|
620
|
+
identityOwner?.push(recorded);
|
|
621
|
+
identityTransferred = true;
|
|
622
|
+
}
|
|
623
|
+
await revalidateRegularParentPath(sourceParents, `${label} source`);
|
|
624
|
+
await revalidateRegularParentPath(targetParents, `${label} target`);
|
|
625
|
+
const targetStat = await fsp.lstat(target);
|
|
626
|
+
if (
|
|
627
|
+
!targetStat.isFile()
|
|
628
|
+
|| targetStat.isSymbolicLink()
|
|
629
|
+
|| targetStat.dev !== sourceStat.dev
|
|
630
|
+
|| targetStat.ino !== sourceStat.ino
|
|
631
|
+
) {
|
|
632
|
+
throw new Error(`${label} file changed identity while being moved: ${target}`);
|
|
633
|
+
}
|
|
634
|
+
return recorded;
|
|
635
|
+
} catch (error) {
|
|
636
|
+
if (recorded && !identityTransferred) await closeRecordedEntry(recorded);
|
|
637
|
+
throw error;
|
|
525
638
|
}
|
|
526
639
|
}
|
|
527
640
|
|
|
@@ -1024,33 +1137,37 @@ async function acquireInstallLock(destination: string) {
|
|
|
1024
1137
|
}
|
|
1025
1138
|
let lockIdentity: InstalledEntry | undefined;
|
|
1026
1139
|
try {
|
|
1027
|
-
const
|
|
1140
|
+
const lockContents = JSON.stringify({ pid: process.pid, started_at: new Date().toISOString() }, null, 2);
|
|
1141
|
+
await handle.writeFile(lockContents);
|
|
1142
|
+
const opened = await handle.stat({ bigint: true });
|
|
1028
1143
|
if (!opened.isFile()) throw new Error(`install lock must be a regular file: ${lockPath}`);
|
|
1029
|
-
lockIdentity =
|
|
1030
|
-
|
|
1144
|
+
lockIdentity = await captureLockIdentity(lockPath, opened);
|
|
1145
|
+
lockIdentity.contentsSha256 = createHash('sha256').update(lockContents).digest('hex');
|
|
1031
1146
|
await revalidateRegularParentPath(destinationIdentity, 'skill destination');
|
|
1032
|
-
const current = await fsp.lstat(lockPath);
|
|
1147
|
+
const current = await fsp.lstat(lockPath, { bigint: true });
|
|
1033
1148
|
if (
|
|
1034
1149
|
current.isSymbolicLink()
|
|
1035
1150
|
|| !current.isFile()
|
|
1036
1151
|
|| current.dev !== lockIdentity.dev
|
|
1037
1152
|
|| current.ino !== lockIdentity.ino
|
|
1153
|
+
|| (lockIdentity.birthtimeNs !== undefined && current.birthtimeNs !== lockIdentity.birthtimeNs)
|
|
1038
1154
|
) {
|
|
1039
1155
|
throw new Error(`install lock changed identity while being acquired: ${lockPath}`);
|
|
1040
1156
|
}
|
|
1041
1157
|
} catch (error) {
|
|
1042
1158
|
const cleanupErrors: string[] = [];
|
|
1043
|
-
|
|
1044
|
-
await handle.close();
|
|
1045
|
-
} catch (closeError) {
|
|
1046
|
-
cleanupErrors.push(message(closeError));
|
|
1047
|
-
}
|
|
1159
|
+
cleanupErrors.push(...await closeFileHandle(handle, lockPath));
|
|
1048
1160
|
try {
|
|
1049
1161
|
await revalidateRegularParentPath(destinationIdentity, 'skill destination');
|
|
1050
1162
|
if (!lockIdentity) throw new Error(`install lock identity was not captured: ${lockPath}`);
|
|
1051
1163
|
await removeRecordedEntry(lockIdentity, 'install lock');
|
|
1052
1164
|
} catch (cleanupError) {
|
|
1053
1165
|
cleanupErrors.push(message(cleanupError));
|
|
1166
|
+
} finally {
|
|
1167
|
+
const closeErrors = lockIdentity
|
|
1168
|
+
? await closeRecordedEntries([lockIdentity])
|
|
1169
|
+
: [];
|
|
1170
|
+
cleanupErrors.push(...closeErrors);
|
|
1054
1171
|
}
|
|
1055
1172
|
if (cleanupErrors.length) {
|
|
1056
1173
|
throw new Error(`${message(error)}. Install lock cleanup failed: ${cleanupErrors.join('; ')}`);
|
|
@@ -1117,30 +1234,355 @@ async function removeInstalledEntries(entries: InstalledEntry[]) {
|
|
|
1117
1234
|
return errors;
|
|
1118
1235
|
}
|
|
1119
1236
|
|
|
1120
|
-
async function captureRecordedEntry(
|
|
1121
|
-
|
|
1237
|
+
async function captureRecordedEntry(
|
|
1238
|
+
target: string,
|
|
1239
|
+
kind: InstalledEntry['kind'],
|
|
1240
|
+
label: string,
|
|
1241
|
+
ownershipMarker?: InstalledEntry['ownershipMarker']
|
|
1242
|
+
): Promise<InstalledEntry> {
|
|
1243
|
+
const noFollow = process.platform !== 'win32' && typeof fsConstants.O_NOFOLLOW === 'number'
|
|
1244
|
+
? fsConstants.O_NOFOLLOW
|
|
1245
|
+
: 0;
|
|
1246
|
+
const directoryOnly = kind === 'directory'
|
|
1247
|
+
&& process.platform !== 'win32'
|
|
1248
|
+
&& typeof fsConstants.O_DIRECTORY === 'number'
|
|
1249
|
+
? fsConstants.O_DIRECTORY
|
|
1250
|
+
: 0;
|
|
1251
|
+
let handle: fsp.FileHandle | undefined;
|
|
1252
|
+
try {
|
|
1253
|
+
if (process.platform !== 'win32') {
|
|
1254
|
+
handle = await fsp.open(target, fsConstants.O_RDONLY | noFollow | directoryOnly);
|
|
1255
|
+
}
|
|
1256
|
+
if (handle) return await captureRecordedEntryFromHandle(target, kind, label, handle);
|
|
1257
|
+
const stat = await fsp.lstat(target, { bigint: true });
|
|
1258
|
+
const typeMatches = kind === 'directory'
|
|
1259
|
+
? stat.isDirectory() && !stat.isSymbolicLink()
|
|
1260
|
+
: stat.isFile() && !stat.isSymbolicLink();
|
|
1261
|
+
if (!typeMatches || stat.birthtimeNs <= 0n) {
|
|
1262
|
+
throw new Error(`${label} must be one stable regular ${kind} with a positive birthtime: ${target}`);
|
|
1263
|
+
}
|
|
1264
|
+
const entry: InstalledEntry = {
|
|
1265
|
+
path: target,
|
|
1266
|
+
dev: stat.dev,
|
|
1267
|
+
ino: stat.ino,
|
|
1268
|
+
kind,
|
|
1269
|
+
birthtimeNs: stat.birthtimeNs,
|
|
1270
|
+
ownershipMarker
|
|
1271
|
+
};
|
|
1272
|
+
if (kind === 'file') {
|
|
1273
|
+
const bytes = await readRegularFileBytes(target, path.dirname(target));
|
|
1274
|
+
entry.contentsSha256 = createHash('sha256').update(bytes).digest('hex');
|
|
1275
|
+
} else if (!ownershipMarker) {
|
|
1276
|
+
entry.treeDigest = manifestDigest(await collectTreeManifest(target));
|
|
1277
|
+
}
|
|
1278
|
+
return entry;
|
|
1279
|
+
} catch (error) {
|
|
1280
|
+
if (handle) await handle.close().catch(() => {});
|
|
1281
|
+
throw error;
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
async function captureRecordedEntryFromHandle(
|
|
1286
|
+
target: string,
|
|
1287
|
+
kind: InstalledEntry['kind'],
|
|
1288
|
+
label: string,
|
|
1289
|
+
handle: fsp.FileHandle
|
|
1290
|
+
): Promise<InstalledEntry> {
|
|
1291
|
+
const opened = await handle.stat({ bigint: true });
|
|
1292
|
+
const current = await fsp.lstat(target, { bigint: true });
|
|
1122
1293
|
const typeMatches = kind === 'directory'
|
|
1123
|
-
?
|
|
1124
|
-
:
|
|
1125
|
-
|
|
1126
|
-
|
|
1294
|
+
? opened.isDirectory() && current.isDirectory() && !current.isSymbolicLink()
|
|
1295
|
+
: opened.isFile() && current.isFile() && !current.isSymbolicLink();
|
|
1296
|
+
const birthtimeAvailable = opened.birthtimeNs > 0n && current.birthtimeNs > 0n;
|
|
1297
|
+
if (
|
|
1298
|
+
!typeMatches
|
|
1299
|
+
|| opened.dev !== current.dev
|
|
1300
|
+
|| opened.ino !== current.ino
|
|
1301
|
+
|| (birthtimeAvailable && opened.birthtimeNs !== current.birthtimeNs)
|
|
1302
|
+
) {
|
|
1303
|
+
throw new Error(`${label} must be one stable regular ${kind}: ${target}`);
|
|
1304
|
+
}
|
|
1305
|
+
return {
|
|
1306
|
+
path: target,
|
|
1307
|
+
dev: opened.dev,
|
|
1308
|
+
ino: opened.ino,
|
|
1309
|
+
kind,
|
|
1310
|
+
birthtimeNs: birthtimeAvailable ? opened.birthtimeNs : undefined,
|
|
1311
|
+
handle
|
|
1312
|
+
};
|
|
1127
1313
|
}
|
|
1128
1314
|
|
|
1129
|
-
async function
|
|
1130
|
-
|
|
1315
|
+
async function captureLockIdentity(target: string, created: BigIntStats): Promise<InstalledEntry> {
|
|
1316
|
+
const current = await fsp.lstat(target, { bigint: true });
|
|
1317
|
+
if (
|
|
1318
|
+
!created.isFile()
|
|
1319
|
+
|| current.isSymbolicLink()
|
|
1320
|
+
|| !current.isFile()
|
|
1321
|
+
|| (process.platform !== 'win32' && (
|
|
1322
|
+
created.dev !== current.dev
|
|
1323
|
+
|| created.ino !== current.ino
|
|
1324
|
+
))
|
|
1325
|
+
) {
|
|
1326
|
+
throw new Error(`install lock changed identity after exclusive creation: ${target}`);
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
let pinHandle: fsp.FileHandle | undefined;
|
|
1131
1330
|
try {
|
|
1132
|
-
|
|
1331
|
+
if (process.platform !== 'win32') {
|
|
1332
|
+
const noFollow = typeof fsConstants.O_NOFOLLOW === 'number' ? fsConstants.O_NOFOLLOW : 0;
|
|
1333
|
+
pinHandle = await fsp.open(target, fsConstants.O_RDONLY | noFollow);
|
|
1334
|
+
const pinned = await pinHandle.stat({ bigint: true });
|
|
1335
|
+
if (
|
|
1336
|
+
!pinned.isFile()
|
|
1337
|
+
|| pinned.dev !== created.dev
|
|
1338
|
+
|| pinned.ino !== created.ino
|
|
1339
|
+
) {
|
|
1340
|
+
throw new Error(`install lock pin descriptor does not match exclusive creation: ${target}`);
|
|
1341
|
+
}
|
|
1342
|
+
} else {
|
|
1343
|
+
const confirmed = await confirmWindowsPathForHandle(target, created, current, 'install lock');
|
|
1344
|
+
return {
|
|
1345
|
+
path: target,
|
|
1346
|
+
dev: confirmed.dev,
|
|
1347
|
+
ino: confirmed.ino,
|
|
1348
|
+
kind: 'file',
|
|
1349
|
+
birthtimeNs: confirmed.birthtimeNs
|
|
1350
|
+
};
|
|
1351
|
+
}
|
|
1352
|
+
return {
|
|
1353
|
+
path: target,
|
|
1354
|
+
dev: created.dev,
|
|
1355
|
+
ino: created.ino,
|
|
1356
|
+
kind: 'file',
|
|
1357
|
+
handle: pinHandle
|
|
1358
|
+
};
|
|
1133
1359
|
} catch (error) {
|
|
1134
|
-
if (
|
|
1360
|
+
if (pinHandle) await pinHandle.close().catch(() => {});
|
|
1135
1361
|
throw error;
|
|
1136
1362
|
}
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
async function removeRecordedEntry(entry: InstalledEntry, label: string) {
|
|
1366
|
+
try {
|
|
1367
|
+
let current;
|
|
1368
|
+
try {
|
|
1369
|
+
current = await fsp.lstat(entry.path, { bigint: true });
|
|
1370
|
+
} catch (error) {
|
|
1371
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return;
|
|
1372
|
+
throw error;
|
|
1373
|
+
}
|
|
1374
|
+
const opened = entry.handle ? await entry.handle.stat({ bigint: true }) : undefined;
|
|
1375
|
+
const typeMatches = entry.kind === 'directory'
|
|
1376
|
+
? current.isDirectory() && !current.isSymbolicLink()
|
|
1377
|
+
: current.isFile() && !current.isSymbolicLink();
|
|
1378
|
+
if (
|
|
1379
|
+
!typeMatches
|
|
1380
|
+
|| current.dev !== entry.dev
|
|
1381
|
+
|| current.ino !== entry.ino
|
|
1382
|
+
|| (entry.birthtimeNs !== undefined && current.birthtimeNs !== entry.birthtimeNs)
|
|
1383
|
+
|| (opened && (
|
|
1384
|
+
opened.dev !== current.dev
|
|
1385
|
+
|| opened.ino !== current.ino
|
|
1386
|
+
|| (entry.birthtimeNs !== undefined && opened.birthtimeNs !== current.birthtimeNs)
|
|
1387
|
+
))
|
|
1388
|
+
) {
|
|
1389
|
+
throw new Error(`${label} identity mismatch; preserved current path: ${entry.path}`);
|
|
1390
|
+
}
|
|
1391
|
+
if (entry.contentsSha256) {
|
|
1392
|
+
try {
|
|
1393
|
+
const bytes = await readRegularFileBytes(entry.path, path.dirname(entry.path));
|
|
1394
|
+
const currentSha256 = createHash('sha256').update(bytes).digest('hex');
|
|
1395
|
+
if (currentSha256 !== entry.contentsSha256) throw new Error('content digest changed');
|
|
1396
|
+
} catch (error) {
|
|
1397
|
+
throw new Error(`${label} identity mismatch (content: ${message(error)}); preserved current path: ${entry.path}`);
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
if (entry.treeDigest) {
|
|
1401
|
+
try {
|
|
1402
|
+
const currentTreeDigest = manifestDigest(await collectTreeManifest(entry.path));
|
|
1403
|
+
if (currentTreeDigest !== entry.treeDigest) throw new Error('tree digest changed');
|
|
1404
|
+
} catch (error) {
|
|
1405
|
+
throw new Error(`${label} identity mismatch (tree: ${message(error)}); preserved current path: ${entry.path}`);
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
if (entry.ownershipMarker) {
|
|
1409
|
+
try {
|
|
1410
|
+
const markerPath = path.join(entry.path, entry.ownershipMarker.name);
|
|
1411
|
+
const markerBytes = await readRegularFileBytes(markerPath, entry.path);
|
|
1412
|
+
const markerSha256 = createHash('sha256').update(markerBytes).digest('hex');
|
|
1413
|
+
if (markerSha256 !== entry.ownershipMarker.sha256) throw new Error('ownership marker digest changed');
|
|
1414
|
+
} catch (error) {
|
|
1415
|
+
throw new Error(`${label} identity mismatch (ownership marker: ${message(error)}); preserved current path: ${entry.path}`);
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
await fsp.rm(entry.path, { recursive: entry.kind === 'directory', force: entry.kind === 'file' });
|
|
1419
|
+
} finally {
|
|
1420
|
+
await closeRecordedEntry(entry);
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
async function captureTransactionRoot(target: string, label: string) {
|
|
1425
|
+
const markerContents = `${randomUUID()}\n`;
|
|
1426
|
+
const markerPath = path.join(target, TRANSACTION_OWNER_MARKER);
|
|
1427
|
+
await fsp.writeFile(markerPath, markerContents, { flag: 'wx', mode: 0o600 });
|
|
1428
|
+
return captureRecordedEntry(target, 'directory', label, {
|
|
1429
|
+
name: TRANSACTION_OWNER_MARKER,
|
|
1430
|
+
sha256: createHash('sha256').update(markerContents).digest('hex')
|
|
1431
|
+
});
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
async function closeRecordedEntry(entry: InstalledEntry) {
|
|
1435
|
+
const handle = entry.handle;
|
|
1436
|
+
entry.handle = undefined;
|
|
1437
|
+
if (handle) await handle.close();
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
async function closeRecordedEntries(entries: Array<InstalledEntry | undefined>) {
|
|
1441
|
+
const errors: string[] = [];
|
|
1442
|
+
for (const entry of entries) {
|
|
1443
|
+
if (!entry) continue;
|
|
1444
|
+
try {
|
|
1445
|
+
await closeRecordedEntry(entry);
|
|
1446
|
+
} catch (error) {
|
|
1447
|
+
errors.push(`${entry.path}: ${message(error)}`);
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
return errors;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
async function closeFileHandle(handle: fsp.FileHandle, pathLabel: string) {
|
|
1454
|
+
try {
|
|
1455
|
+
await handle.close();
|
|
1456
|
+
return [];
|
|
1457
|
+
} catch (error) {
|
|
1458
|
+
return [`${pathLabel}: ${message(error)}`];
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
export async function planSkillSetInstallation(options: TransactionalInstallOptions): Promise<SkillInstallPlan> {
|
|
1463
|
+
const sourceRoot = path.resolve(options.sourceRoot);
|
|
1464
|
+
const destination = safeRoot(options.destination, 'skill destination');
|
|
1465
|
+
const codexMirror = options.codexMirror ? safeRoot(options.codexMirror, 'Codex mirror') : '';
|
|
1466
|
+
const skills = unique(options.skills);
|
|
1467
|
+
const cleanupCandidates = unique([...(options.legacySkills || []), ...(options.retiredSkills || [])]);
|
|
1468
|
+
const replaceSkills = unique(options.replaceSkills || []);
|
|
1469
|
+
validateManagedNames([...skills, ...cleanupCandidates, ...replaceSkills]);
|
|
1470
|
+
|
|
1471
|
+
const expected = await expectedInstallManifest(sourceRoot, skills);
|
|
1472
|
+
const sourceDigest = manifestDigest(expected);
|
|
1473
|
+
const receiptPath = path.join(destination, INSTALL_RECEIPT_NAME);
|
|
1474
|
+
const operations: SkillInstallPlanOperation[] = [];
|
|
1475
|
+
const blockers = await nonMutatingInstallGateBlockers(destination);
|
|
1476
|
+
|
|
1477
|
+
let ownership: OwnershipPreflight | undefined;
|
|
1478
|
+
try {
|
|
1479
|
+
if (blockers.length) throw new Error(blockers[0]);
|
|
1480
|
+
ownership = await preflightInstallOwnership(
|
|
1481
|
+
destination,
|
|
1482
|
+
options.packageName,
|
|
1483
|
+
skills,
|
|
1484
|
+
cleanupCandidates,
|
|
1485
|
+
replaceSkills
|
|
1486
|
+
);
|
|
1487
|
+
} catch (error) {
|
|
1488
|
+
if (!blockers.length) blockers.push(message(error));
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
if (ownership) {
|
|
1492
|
+
const replaceNames = new Set(ownership.replaceNames);
|
|
1493
|
+
for (const skill of skills) {
|
|
1494
|
+
const target = path.join(destination, skill);
|
|
1495
|
+
const existing = await exists(target);
|
|
1496
|
+
operations.push({
|
|
1497
|
+
action: !existing
|
|
1498
|
+
? 'create'
|
|
1499
|
+
: replaceSkills.includes(skill)
|
|
1500
|
+
? 'replace_explicitly_authorized'
|
|
1501
|
+
: 'replace_managed',
|
|
1502
|
+
skill,
|
|
1503
|
+
path: target,
|
|
1504
|
+
reason: !existing
|
|
1505
|
+
? 'active skill is not installed'
|
|
1506
|
+
: replaceSkills.includes(skill)
|
|
1507
|
+
? 'operator supplied an explicit per-skill replacement override'
|
|
1508
|
+
: 'existing skill ownership and complete content integrity match the yam receipt'
|
|
1509
|
+
});
|
|
1510
|
+
}
|
|
1511
|
+
for (const skill of cleanupCandidates) {
|
|
1512
|
+
if (!replaceNames.has(skill) || skills.includes(skill)) continue;
|
|
1513
|
+
operations.push({
|
|
1514
|
+
action: 'remove_retired',
|
|
1515
|
+
skill,
|
|
1516
|
+
path: path.join(destination, skill),
|
|
1517
|
+
reason: 'retired or legacy skill is still proven yam-owned by the current receipt'
|
|
1518
|
+
});
|
|
1519
|
+
}
|
|
1520
|
+
operations.push({
|
|
1521
|
+
action: 'write_receipt',
|
|
1522
|
+
skill: '',
|
|
1523
|
+
path: receiptPath,
|
|
1524
|
+
reason: ownership.receipt ? 'replace the verified yam ownership receipt' : 'create the yam ownership receipt'
|
|
1525
|
+
});
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
const preservedPaths = codexMirror && codexMirror !== destination ? [codexMirror] : [];
|
|
1529
|
+
const replaceNames = new Set(ownership?.replaceNames || []);
|
|
1530
|
+
for (const skill of cleanupCandidates) {
|
|
1531
|
+
if (replaceNames.has(skill) || !await exists(path.join(destination, skill))) continue;
|
|
1532
|
+
preservedPaths.push(path.join(destination, skill));
|
|
1142
1533
|
}
|
|
1143
|
-
|
|
1534
|
+
preservedPaths.sort(compareStableText);
|
|
1535
|
+
const planBasis = {
|
|
1536
|
+
package: { name: options.packageName, version: options.version },
|
|
1537
|
+
source: { root: sourceRoot, digest: sourceDigest, file_count: expected.length },
|
|
1538
|
+
destination,
|
|
1539
|
+
receipt_path: receiptPath,
|
|
1540
|
+
operations,
|
|
1541
|
+
preserved_paths: preservedPaths,
|
|
1542
|
+
blockers
|
|
1543
|
+
};
|
|
1544
|
+
const planDigest = createHash('sha256').update(JSON.stringify(planBasis)).digest('hex');
|
|
1545
|
+
const ready = blockers.length === 0;
|
|
1546
|
+
return {
|
|
1547
|
+
schema: 'yam.install-plan.v1',
|
|
1548
|
+
generated_at: (options.now || (() => new Date()))().toISOString(),
|
|
1549
|
+
mutation_authorized: false,
|
|
1550
|
+
...planBasis,
|
|
1551
|
+
ready,
|
|
1552
|
+
truth_status: ready ? 'verified' : 'blocked',
|
|
1553
|
+
plan_digest: planDigest,
|
|
1554
|
+
next_action: ready
|
|
1555
|
+
? 'review this non-mutating plan, then run `yam install` without --dry-run to authorize the transaction'
|
|
1556
|
+
: 'resolve the listed lock, recovery, destination, or ownership blocker without deleting unreviewed state, then regenerate the plan'
|
|
1557
|
+
};
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
async function nonMutatingInstallGateBlockers(destination: string) {
|
|
1561
|
+
let stat;
|
|
1562
|
+
try {
|
|
1563
|
+
stat = await fsp.lstat(destination);
|
|
1564
|
+
} catch (error) {
|
|
1565
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
|
|
1566
|
+
return [`skill destination cannot be inspected: ${message(error)}`];
|
|
1567
|
+
}
|
|
1568
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
1569
|
+
return [`skill destination must be a regular physical directory: ${destination}`];
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
const blockers: string[] = [];
|
|
1573
|
+
const lockPath = path.join(destination, INSTALL_LOCK_NAME);
|
|
1574
|
+
if (await exists(lockPath)) {
|
|
1575
|
+
blockers.push(`another yam install may be active; lock exists at ${lockPath}`);
|
|
1576
|
+
}
|
|
1577
|
+
try {
|
|
1578
|
+
const recoveryArtifacts = await findInstallRecoveryArtifacts(destination);
|
|
1579
|
+
if (recoveryArtifacts.length) {
|
|
1580
|
+
blockers.push(`unfinished yam install transaction requires inspection: ${recoveryArtifacts.join(', ')}`);
|
|
1581
|
+
}
|
|
1582
|
+
} catch (error) {
|
|
1583
|
+
blockers.push(`install recovery state cannot be inspected: ${message(error)}`);
|
|
1584
|
+
}
|
|
1585
|
+
return blockers;
|
|
1144
1586
|
}
|
|
1145
1587
|
|
|
1146
1588
|
export async function installSkillSetTransactional(options: TransactionalInstallOptions): Promise<TransactionalInstallResult> {
|
|
@@ -1176,13 +1618,14 @@ export async function installSkillSetTransactional(options: TransactionalInstall
|
|
|
1176
1618
|
const expected = await expectedInstallManifest(sourceRoot, skills);
|
|
1177
1619
|
const transactionId = options.transactionId || randomUUID();
|
|
1178
1620
|
const transactionRoot = await fsp.mkdtemp(path.join(destination, '.yam-flow-install-'));
|
|
1179
|
-
const transactionEntry = await
|
|
1621
|
+
const transactionEntry = await captureTransactionRoot(transactionRoot, 'install transaction root');
|
|
1180
1622
|
const stagedRoot = path.join(transactionRoot, 'staged');
|
|
1181
1623
|
const backupRoot = path.join(transactionRoot, 'backup');
|
|
1182
1624
|
const stagedReceiptPath = path.join(transactionRoot, 'receipt.json');
|
|
1183
1625
|
const destinationReceiptPath = path.join(destination, INSTALL_RECEIPT_NAME);
|
|
1184
1626
|
const receiptBackupPath = path.join(transactionRoot, 'receipt.backup');
|
|
1185
1627
|
const installedEntries: InstalledEntry[] = [];
|
|
1628
|
+
const receiptInstalledEntries: InstalledEntry[] = [];
|
|
1186
1629
|
let destinationBackups: MovedEntry[] = [];
|
|
1187
1630
|
let receiptBackedUp = false;
|
|
1188
1631
|
let receiptInstalled: InstalledEntry | undefined;
|
|
@@ -1268,25 +1711,31 @@ export async function installSkillSetTransactional(options: TransactionalInstall
|
|
|
1268
1711
|
for (const skill of skills) {
|
|
1269
1712
|
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
1270
1713
|
const target = path.join(destination, skill);
|
|
1271
|
-
await moveRegularDirectory(
|
|
1714
|
+
const installedEntry = await moveRegularDirectory(
|
|
1272
1715
|
path.join(stagedRoot, skill),
|
|
1273
1716
|
target,
|
|
1274
1717
|
stagedRoot,
|
|
1275
1718
|
destination,
|
|
1276
1719
|
'staged skill install',
|
|
1277
|
-
|
|
1720
|
+
undefined,
|
|
1721
|
+
installedEntries
|
|
1278
1722
|
);
|
|
1723
|
+
if (!installedEntry) throw new Error(`staged skill install identity was not recorded: ${target}`);
|
|
1279
1724
|
await options.failpoint?.({ phase: 'skill-installed', skill });
|
|
1280
1725
|
}
|
|
1281
1726
|
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
1282
|
-
await moveRegularFile(
|
|
1727
|
+
receiptInstalled = await moveRegularFile(
|
|
1283
1728
|
stagedReceiptPath,
|
|
1284
1729
|
destinationReceiptPath,
|
|
1285
1730
|
transactionRoot,
|
|
1286
1731
|
destination,
|
|
1287
1732
|
'staged install receipt',
|
|
1288
|
-
|
|
1733
|
+
undefined,
|
|
1734
|
+
receiptInstalledEntries
|
|
1289
1735
|
);
|
|
1736
|
+
if (!receiptInstalled) {
|
|
1737
|
+
throw new Error(`staged install receipt identity was not recorded: ${destinationReceiptPath}`);
|
|
1738
|
+
}
|
|
1290
1739
|
await options.failpoint?.({ phase: 'receipt-installed' });
|
|
1291
1740
|
|
|
1292
1741
|
await options.failpoint?.({ phase: 'before-verify' });
|
|
@@ -1301,6 +1750,11 @@ export async function installSkillSetTransactional(options: TransactionalInstall
|
|
|
1301
1750
|
});
|
|
1302
1751
|
if (!inspection.ok) throw new Error(`post-install verification failed: ${inspection.issues.join('; ')}`);
|
|
1303
1752
|
|
|
1753
|
+
const ownershipHandleErrors = await closeRecordedEntries([receiptInstalled, ...installedEntries]);
|
|
1754
|
+
if (ownershipHandleErrors.length) {
|
|
1755
|
+
cleanupWarnings.push(`verified install identity handle cleanup failed: ${ownershipHandleErrors.join('; ')}`);
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1304
1758
|
try {
|
|
1305
1759
|
await removeRecordedEntry(transactionEntry, 'install transaction cleanup');
|
|
1306
1760
|
} catch (error) {
|
|
@@ -1315,6 +1769,7 @@ export async function installSkillSetTransactional(options: TransactionalInstall
|
|
|
1315
1769
|
};
|
|
1316
1770
|
} catch (error) {
|
|
1317
1771
|
const rollbackErrors: string[] = [];
|
|
1772
|
+
receiptInstalled ||= receiptInstalledEntries[0];
|
|
1318
1773
|
let destinationStillOwned = true;
|
|
1319
1774
|
try {
|
|
1320
1775
|
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
@@ -1357,6 +1812,8 @@ export async function installSkillSetTransactional(options: TransactionalInstall
|
|
|
1357
1812
|
}
|
|
1358
1813
|
throw new Error(`yam install failed; previous installation state restored: ${message(error)}`);
|
|
1359
1814
|
}
|
|
1815
|
+
const retainedHandleErrors = await closeRecordedEntries([transactionEntry, receiptInstalled, ...installedEntries]);
|
|
1816
|
+
rollbackErrors.push(...retainedHandleErrors.map((item) => `identity handle cleanup failed: ${item}`));
|
|
1360
1817
|
throw new Error(
|
|
1361
1818
|
`yam install failed and rollback is incomplete: ${message(error)}. `
|
|
1362
1819
|
+ `Recovery artifacts were kept at ${transactionRoot}. `
|
|
@@ -1368,16 +1825,14 @@ export async function installSkillSetTransactional(options: TransactionalInstall
|
|
|
1368
1825
|
}
|
|
1369
1826
|
|
|
1370
1827
|
const lockCleanupErrors: string[] = [];
|
|
1371
|
-
|
|
1372
|
-
await lock.handle.close();
|
|
1373
|
-
} catch (error) {
|
|
1374
|
-
lockCleanupErrors.push(message(error));
|
|
1375
|
-
}
|
|
1828
|
+
lockCleanupErrors.push(...await closeFileHandle(lock.handle, lock.lockPath));
|
|
1376
1829
|
try {
|
|
1377
1830
|
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
1378
1831
|
await removeRecordedEntry(lock.lockIdentity, 'install lock cleanup');
|
|
1379
1832
|
} catch (error) {
|
|
1380
1833
|
lockCleanupErrors.push(message(error));
|
|
1834
|
+
} finally {
|
|
1835
|
+
lockCleanupErrors.push(...await closeRecordedEntries([lock.lockIdentity]));
|
|
1381
1836
|
}
|
|
1382
1837
|
if (lockCleanupErrors.length) {
|
|
1383
1838
|
const warning = `install lock cleanup failed at ${lock.lockPath}: ${lockCleanupErrors.join('; ')}`;
|
|
@@ -1416,7 +1871,7 @@ export async function uninstallSkillSetSafely(options: SafeUninstallOptions): Pr
|
|
|
1416
1871
|
await preflightSafeUninstallOwnership(destination, options.packageName, skills);
|
|
1417
1872
|
|
|
1418
1873
|
const transactionRoot = await fsp.mkdtemp(path.join(destination, '.yam-flow-install-uninstall-'));
|
|
1419
|
-
const transactionEntry = await
|
|
1874
|
+
const transactionEntry = await captureTransactionRoot(transactionRoot, 'uninstall transaction root');
|
|
1420
1875
|
const backupRoot = path.join(transactionRoot, 'backup');
|
|
1421
1876
|
const receiptPath = path.join(destination, INSTALL_RECEIPT_NAME);
|
|
1422
1877
|
const receiptBackup = path.join(transactionRoot, INSTALL_RECEIPT_NAME);
|
|
@@ -1511,6 +1966,8 @@ export async function uninstallSkillSetSafely(options: SafeUninstallOptions): Pr
|
|
|
1511
1966
|
}
|
|
1512
1967
|
throw new Error(`yam uninstall failed; previous installation state restored: ${message(error)}`);
|
|
1513
1968
|
}
|
|
1969
|
+
const retainedHandleErrors = await closeRecordedEntries([transactionEntry]);
|
|
1970
|
+
rollbackErrors.push(...retainedHandleErrors.map((item) => `identity handle cleanup failed: ${item}`));
|
|
1514
1971
|
throw new Error(
|
|
1515
1972
|
`yam uninstall failed and rollback is incomplete: ${message(error)}. `
|
|
1516
1973
|
+ `Recovery artifacts were kept at ${transactionRoot}. Rollback errors: ${rollbackErrors.join('; ')}`
|
|
@@ -1521,16 +1978,14 @@ export async function uninstallSkillSetSafely(options: SafeUninstallOptions): Pr
|
|
|
1521
1978
|
}
|
|
1522
1979
|
|
|
1523
1980
|
const lockCleanupErrors: string[] = [];
|
|
1524
|
-
|
|
1525
|
-
await lock.handle.close();
|
|
1526
|
-
} catch (error) {
|
|
1527
|
-
lockCleanupErrors.push(message(error));
|
|
1528
|
-
}
|
|
1981
|
+
lockCleanupErrors.push(...await closeFileHandle(lock.handle, lock.lockPath));
|
|
1529
1982
|
try {
|
|
1530
1983
|
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
1531
1984
|
await removeRecordedEntry(lock.lockIdentity, 'install lock cleanup');
|
|
1532
1985
|
} catch (error) {
|
|
1533
1986
|
lockCleanupErrors.push(message(error));
|
|
1987
|
+
} finally {
|
|
1988
|
+
lockCleanupErrors.push(...await closeRecordedEntries([lock.lockIdentity]));
|
|
1534
1989
|
}
|
|
1535
1990
|
if (lockCleanupErrors.length) {
|
|
1536
1991
|
const warning = `install lock cleanup failed at ${lock.lockPath}: ${lockCleanupErrors.join('; ')}`;
|