yam-flow 2.6.0 → 2.8.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/AGENTS.md +1 -0
- package/CHANGELOG.md +51 -0
- package/COMMANDS.md +72 -5
- package/README.md +49 -9
- package/ROADMAP.md +20 -7
- package/dist/bin/yam.js +616 -68
- package/dist/lib/external-updates.d.ts +9 -0
- package/dist/lib/external-updates.js +232 -4
- package/dist/lib/media-provider-receipt.d.ts +79 -0
- package/dist/lib/media-provider-receipt.js +498 -0
- 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/skill-installation.d.ts +30 -0
- package/dist/lib/skill-installation.js +738 -110
- package/dist/lib/trust-kernel.d.ts +2 -0
- package/dist/lib/trust-kernel.js +21 -2
- package/dist/lib/verification-closure.d.ts +97 -0
- package/dist/lib/verification-closure.js +728 -0
- package/package.json +23 -3
- package/references/final-report.md +15 -1
- package/references/next-step.md +67 -0
- package/references/scout-wide-scan.md +16 -0
- package/references/study-note.md +4 -2
- package/references/tool-trust-layer.md +2 -0
- package/skills/deep/SKILL.md +2 -0
- package/skills/mission/SKILL.md +2 -0
- package/skills/quick/SKILL.md +1 -0
- package/skills/scout/SKILL.md +19 -9
- package/skills/ueye/SKILL.md +1 -0
- package/src/bin/yam.ts +615 -64
- package/src/lib/external-updates.ts +262 -4
- package/src/lib/media-provider-receipt.ts +548 -0
- 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/skill-installation.ts +930 -108
- package/src/lib/trust-kernel.ts +25 -2
- package/src/lib/verification-closure.ts +738 -0
- package/yam.manifest.json +4 -3
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { constants as fsConstants } from 'node:fs';
|
|
2
3
|
import fsp from 'node:fs/promises';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { compareStableText } from './stable-order.js';
|
|
@@ -6,6 +7,7 @@ export const INSTALL_RECEIPT_NAME = '.yam-flow-install-receipt.json';
|
|
|
6
7
|
export const INSTALL_LOCK_NAME = '.yam-flow-install.lock';
|
|
7
8
|
export const INSTALL_RECEIPT_SCHEMA = 'yam.install-receipt.v1';
|
|
8
9
|
export const INSTALL_TRANSACTION_PREFIX = '.yam-flow-install-';
|
|
10
|
+
const TRANSACTION_OWNER_MARKER = '.yam-flow-transaction-owner';
|
|
9
11
|
function message(error) {
|
|
10
12
|
return error instanceof Error ? error.message : String(error);
|
|
11
13
|
}
|
|
@@ -37,6 +39,169 @@ async function exists(target) {
|
|
|
37
39
|
return false;
|
|
38
40
|
}
|
|
39
41
|
}
|
|
42
|
+
function pathWithinBoundary(boundary, target) {
|
|
43
|
+
const relative = path.relative(boundary, target);
|
|
44
|
+
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
|
|
45
|
+
}
|
|
46
|
+
async function captureRegularParentPath(boundaryValue, targetValue, label) {
|
|
47
|
+
const boundary = path.resolve(boundaryValue);
|
|
48
|
+
const target = path.resolve(targetValue);
|
|
49
|
+
if (!pathWithinBoundary(boundary, target)) {
|
|
50
|
+
throw new Error(`${label} escapes its trusted path boundary: ${target}`);
|
|
51
|
+
}
|
|
52
|
+
const targetParent = target === boundary ? boundary : path.dirname(target);
|
|
53
|
+
const relativeParent = path.relative(boundary, targetParent);
|
|
54
|
+
const candidates = [boundary];
|
|
55
|
+
if (relativeParent) {
|
|
56
|
+
let current = boundary;
|
|
57
|
+
for (const segment of relativeParent.split(path.sep)) {
|
|
58
|
+
current = path.join(current, segment);
|
|
59
|
+
candidates.push(current);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const identities = [];
|
|
63
|
+
for (const candidate of candidates) {
|
|
64
|
+
const stat = await fsp.lstat(candidate);
|
|
65
|
+
if (stat.isSymbolicLink()) {
|
|
66
|
+
throw new Error(`${label} has a symlinked parent path segment: ${candidate}`);
|
|
67
|
+
}
|
|
68
|
+
if (!stat.isDirectory()) {
|
|
69
|
+
throw new Error(`${label} parent path segment must be a regular directory: ${candidate}`);
|
|
70
|
+
}
|
|
71
|
+
identities.push({ path: candidate, dev: stat.dev, ino: stat.ino });
|
|
72
|
+
}
|
|
73
|
+
return identities;
|
|
74
|
+
}
|
|
75
|
+
async function revalidateRegularParentPath(identities, label) {
|
|
76
|
+
for (const identity of identities) {
|
|
77
|
+
const stat = await fsp.lstat(identity.path);
|
|
78
|
+
if (stat.isSymbolicLink()
|
|
79
|
+
|| !stat.isDirectory()
|
|
80
|
+
|| stat.dev !== identity.dev
|
|
81
|
+
|| stat.ino !== identity.ino) {
|
|
82
|
+
throw new Error(`${label} parent path changed identity: ${identity.path}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async function captureRegularDirectoryPath(boundaryValue, directoryValue, label) {
|
|
87
|
+
const boundary = path.resolve(boundaryValue);
|
|
88
|
+
const directory = path.resolve(directoryValue);
|
|
89
|
+
if (boundary === directory) {
|
|
90
|
+
const stat = await fsp.lstat(directory);
|
|
91
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
92
|
+
throw new Error(`${label} must be a regular directory: ${directory}`);
|
|
93
|
+
}
|
|
94
|
+
return [{ path: directory, dev: stat.dev, ino: stat.ino }];
|
|
95
|
+
}
|
|
96
|
+
const identities = await captureRegularParentPath(boundary, directory, label);
|
|
97
|
+
const stat = await fsp.lstat(directory);
|
|
98
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
99
|
+
throw new Error(`${label} must be a regular directory: ${directory}`);
|
|
100
|
+
}
|
|
101
|
+
identities.push({ path: directory, dev: stat.dev, ino: stat.ino });
|
|
102
|
+
return identities;
|
|
103
|
+
}
|
|
104
|
+
function readOnlyNoFollowFlags() {
|
|
105
|
+
const noFollow = process.platform !== 'win32' && typeof fsConstants.O_NOFOLLOW === 'number'
|
|
106
|
+
? fsConstants.O_NOFOLLOW
|
|
107
|
+
: 0;
|
|
108
|
+
return fsConstants.O_RDONLY | noFollow;
|
|
109
|
+
}
|
|
110
|
+
async function confirmWindowsPathForHandle(target, opened, before, label) {
|
|
111
|
+
if (before.birthtimeNs <= 0n) {
|
|
112
|
+
throw new Error(`${label} requires a positive birthtime on Windows: ${target}`);
|
|
113
|
+
}
|
|
114
|
+
let pathHandle;
|
|
115
|
+
try {
|
|
116
|
+
pathHandle = await fsp.open(target, fsConstants.O_RDONLY);
|
|
117
|
+
const pathOpened = await pathHandle.stat({ bigint: true });
|
|
118
|
+
const confirmed = await fsp.lstat(target, { bigint: true });
|
|
119
|
+
if (!opened.isFile()
|
|
120
|
+
|| !pathOpened.isFile()
|
|
121
|
+
|| pathOpened.dev !== opened.dev
|
|
122
|
+
|| pathOpened.ino !== opened.ino
|
|
123
|
+
|| confirmed.isSymbolicLink()
|
|
124
|
+
|| !confirmed.isFile()
|
|
125
|
+
|| confirmed.dev !== before.dev
|
|
126
|
+
|| confirmed.ino !== before.ino
|
|
127
|
+
|| confirmed.birthtimeNs !== before.birthtimeNs) {
|
|
128
|
+
throw new Error(`${label} changed identity while confirming Windows path ownership: ${target}`);
|
|
129
|
+
}
|
|
130
|
+
return confirmed;
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
if (pathHandle)
|
|
134
|
+
await pathHandle.close();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
async function captureRegularFile(file, boundary = path.dirname(file)) {
|
|
138
|
+
const parents = await captureRegularParentPath(boundary, file, 'regular file read');
|
|
139
|
+
const before = await fsp.lstat(file, { bigint: true });
|
|
140
|
+
if (!before.isFile() || before.isSymbolicLink())
|
|
141
|
+
throw new Error(`expected a regular file: ${file}`);
|
|
142
|
+
const handle = await fsp.open(file, readOnlyNoFollowFlags());
|
|
143
|
+
try {
|
|
144
|
+
const opened = await handle.stat({ bigint: true });
|
|
145
|
+
if (!opened.isFile())
|
|
146
|
+
throw new Error(`expected a regular file: ${file}`);
|
|
147
|
+
const bytes = await handle.readFile();
|
|
148
|
+
const afterOpened = await handle.stat({ bigint: true });
|
|
149
|
+
const after = process.platform === 'win32'
|
|
150
|
+
? await confirmWindowsPathForHandle(file, opened, before, 'regular file')
|
|
151
|
+
: await fsp.lstat(file, { bigint: true });
|
|
152
|
+
await revalidateRegularParentPath(parents, 'regular file read');
|
|
153
|
+
if (!after.isFile()
|
|
154
|
+
|| after.isSymbolicLink()
|
|
155
|
+
|| afterOpened.dev !== opened.dev
|
|
156
|
+
|| afterOpened.ino !== opened.ino
|
|
157
|
+
|| (process.platform !== 'win32' && (before.dev !== opened.dev
|
|
158
|
+
|| before.ino !== opened.ino
|
|
159
|
+
|| after.dev !== opened.dev
|
|
160
|
+
|| after.ino !== opened.ino))) {
|
|
161
|
+
throw new Error(`regular file changed identity while being captured: ${file}`);
|
|
162
|
+
}
|
|
163
|
+
return { bytes, mode: Number(opened.mode & 511n) };
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
await handle.close();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
async function readRegularFileBytes(file, boundary = path.dirname(file)) {
|
|
170
|
+
return (await captureRegularFile(file, boundary)).bytes;
|
|
171
|
+
}
|
|
172
|
+
async function copyRegularFile(source, target, sourceBoundary, targetBoundary) {
|
|
173
|
+
const sourceFile = await captureRegularFile(source, sourceBoundary);
|
|
174
|
+
const targetParents = await captureRegularParentPath(targetBoundary, target, 'install copy target');
|
|
175
|
+
const noFollow = process.platform !== 'win32' && typeof fsConstants.O_NOFOLLOW === 'number'
|
|
176
|
+
? fsConstants.O_NOFOLLOW
|
|
177
|
+
: 0;
|
|
178
|
+
const handle = await fsp.open(target, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | noFollow, sourceFile.mode);
|
|
179
|
+
try {
|
|
180
|
+
await handle.writeFile(sourceFile.bytes);
|
|
181
|
+
const opened = await handle.stat({ bigint: true });
|
|
182
|
+
if (!opened.isFile())
|
|
183
|
+
throw new Error(`install copy target must be a regular file: ${target}`);
|
|
184
|
+
const openedIdentity = { dev: opened.dev, ino: opened.ino };
|
|
185
|
+
const afterOpened = await handle.stat({ bigint: true });
|
|
186
|
+
if (afterOpened.dev !== openedIdentity.dev || afterOpened.ino !== openedIdentity.ino) {
|
|
187
|
+
throw new Error(`install copy target changed descriptor identity: ${target}`);
|
|
188
|
+
}
|
|
189
|
+
await revalidateRegularParentPath(targetParents, 'install copy target');
|
|
190
|
+
const beforeInstalled = await fsp.lstat(target, { bigint: true });
|
|
191
|
+
const installed = process.platform === 'win32'
|
|
192
|
+
? await confirmWindowsPathForHandle(target, opened, beforeInstalled, 'install copy target')
|
|
193
|
+
: beforeInstalled;
|
|
194
|
+
if (!installed.isFile()
|
|
195
|
+
|| installed.isSymbolicLink()
|
|
196
|
+
|| (process.platform !== 'win32' && (installed.dev !== openedIdentity.dev
|
|
197
|
+
|| installed.ino !== openedIdentity.ino))) {
|
|
198
|
+
throw new Error(`install copy target changed path identity: ${target}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
finally {
|
|
202
|
+
await handle.close();
|
|
203
|
+
}
|
|
204
|
+
}
|
|
40
205
|
export async function findInstallRecoveryArtifacts(root) {
|
|
41
206
|
try {
|
|
42
207
|
const entries = await fsp.readdir(root, { withFileTypes: true });
|
|
@@ -51,25 +216,23 @@ export async function findInstallRecoveryArtifacts(root) {
|
|
|
51
216
|
throw error;
|
|
52
217
|
}
|
|
53
218
|
}
|
|
54
|
-
async function digestFile(file, relativePath) {
|
|
219
|
+
async function digestFile(file, relativePath, boundary = path.dirname(file)) {
|
|
55
220
|
const stat = await fsp.lstat(file);
|
|
56
221
|
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
57
222
|
throw new Error(`install source must be a regular file: ${file}`);
|
|
58
223
|
}
|
|
59
|
-
const contents = await
|
|
224
|
+
const contents = await readRegularFileBytes(file, boundary);
|
|
60
225
|
return {
|
|
61
226
|
path: relativePath.split(path.sep).join('/'),
|
|
62
227
|
bytes: contents.byteLength,
|
|
63
228
|
sha256: createHash('sha256').update(contents).digest('hex')
|
|
64
229
|
};
|
|
65
230
|
}
|
|
66
|
-
async function collectTreeManifest(root, prefix = '') {
|
|
67
|
-
const
|
|
68
|
-
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
69
|
-
throw new Error(`install tree must be a regular directory: ${root}`);
|
|
70
|
-
}
|
|
231
|
+
async function collectTreeManifest(root, prefix = '', boundary = root) {
|
|
232
|
+
const rootIdentity = await captureRegularDirectoryPath(boundary, root, 'install tree');
|
|
71
233
|
const files = [];
|
|
72
234
|
async function walk(current, relative) {
|
|
235
|
+
const directoryIdentity = await captureRegularDirectoryPath(boundary, current, 'install tree');
|
|
73
236
|
const entries = await fsp.readdir(current, { withFileTypes: true });
|
|
74
237
|
entries.sort((left, right) => compareStableText(left.name, right.name));
|
|
75
238
|
for (const entry of entries) {
|
|
@@ -79,33 +242,41 @@ async function collectTreeManifest(root, prefix = '') {
|
|
|
79
242
|
await walk(absolute, nextRelative);
|
|
80
243
|
}
|
|
81
244
|
else if (entry.isFile()) {
|
|
82
|
-
files.push(await digestFile(absolute, path.join(prefix, nextRelative)));
|
|
245
|
+
files.push(await digestFile(absolute, path.join(prefix, nextRelative), boundary));
|
|
83
246
|
}
|
|
84
247
|
else {
|
|
85
248
|
throw new Error(`unsupported install tree entry: ${absolute}`);
|
|
86
249
|
}
|
|
87
250
|
}
|
|
251
|
+
await revalidateRegularParentPath(directoryIdentity, 'install tree');
|
|
88
252
|
}
|
|
89
253
|
await walk(root, '');
|
|
254
|
+
await revalidateRegularParentPath(rootIdentity, 'install tree');
|
|
90
255
|
return files.sort((left, right) => compareStableText(left.path, right.path));
|
|
91
256
|
}
|
|
92
257
|
async function expectedInstallManifest(sourceRoot, skills) {
|
|
258
|
+
const sourceIdentity = await captureRegularDirectoryPath(sourceRoot, sourceRoot, 'install source root');
|
|
93
259
|
const referencesRoot = path.join(sourceRoot, 'references');
|
|
94
|
-
const referenceFiles = await collectTreeManifest(referencesRoot);
|
|
260
|
+
const referenceFiles = await collectTreeManifest(referencesRoot, '', sourceRoot);
|
|
95
261
|
if (referenceFiles.length === 0)
|
|
96
262
|
throw new Error(`no reference files found: ${referencesRoot}`);
|
|
97
263
|
const files = [];
|
|
98
264
|
for (const skill of skills) {
|
|
99
265
|
const skillFile = path.join(sourceRoot, 'skills', skill, 'SKILL.md');
|
|
100
|
-
const
|
|
101
|
-
const
|
|
102
|
-
const
|
|
266
|
+
const skillBytes = await readRegularFileBytes(skillFile, sourceRoot);
|
|
267
|
+
const skillText = skillBytes.toString('utf8');
|
|
268
|
+
const frontmatter = skillText.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
269
|
+
const name = frontmatter?.[1].match(/^name:\s*([^\r\n]+)\s*$/m)?.[1]
|
|
103
270
|
?.trim()
|
|
104
271
|
.replace(/^["']|["']$/g, '');
|
|
105
272
|
if (name !== skill) {
|
|
106
273
|
throw new Error(`skill source frontmatter mismatch for ${skill}: ${name || 'missing'}`);
|
|
107
274
|
}
|
|
108
|
-
files.push(
|
|
275
|
+
files.push({
|
|
276
|
+
path: path.join(skill, 'SKILL.md').split(path.sep).join('/'),
|
|
277
|
+
bytes: skillBytes.byteLength,
|
|
278
|
+
sha256: createHash('sha256').update(skillBytes).digest('hex')
|
|
279
|
+
});
|
|
109
280
|
for (const reference of referenceFiles) {
|
|
110
281
|
files.push({
|
|
111
282
|
...reference,
|
|
@@ -113,29 +284,107 @@ async function expectedInstallManifest(sourceRoot, skills) {
|
|
|
113
284
|
});
|
|
114
285
|
}
|
|
115
286
|
}
|
|
287
|
+
await revalidateRegularParentPath(sourceIdentity, 'install source root');
|
|
116
288
|
return files.sort((left, right) => compareStableText(left.path, right.path));
|
|
117
289
|
}
|
|
118
|
-
async function copyTree(source, target) {
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
throw new Error(`install source must be a regular directory: ${source}`);
|
|
122
|
-
}
|
|
290
|
+
async function copyTree(source, target, sourceBoundary = source, targetBoundary = target) {
|
|
291
|
+
const sourceIdentity = await captureRegularDirectoryPath(sourceBoundary, source, 'install source tree');
|
|
292
|
+
const targetParents = await captureRegularParentPath(targetBoundary, target, 'install target tree');
|
|
123
293
|
await fsp.mkdir(target, { recursive: true });
|
|
294
|
+
const targetIdentity = await captureRegularDirectoryPath(targetBoundary, target, 'install target tree');
|
|
295
|
+
await revalidateRegularParentPath(sourceIdentity, 'install source tree');
|
|
296
|
+
await revalidateRegularParentPath(targetParents, 'install target tree');
|
|
124
297
|
const entries = await fsp.readdir(source, { withFileTypes: true });
|
|
125
298
|
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
126
299
|
for (const entry of entries) {
|
|
127
300
|
const from = path.join(source, entry.name);
|
|
128
301
|
const to = path.join(target, entry.name);
|
|
129
302
|
if (entry.isDirectory()) {
|
|
130
|
-
await copyTree(from, to);
|
|
303
|
+
await copyTree(from, to, sourceBoundary, targetBoundary);
|
|
131
304
|
}
|
|
132
305
|
else if (entry.isFile()) {
|
|
133
|
-
await
|
|
306
|
+
await copyRegularFile(from, to, sourceBoundary, targetBoundary);
|
|
134
307
|
}
|
|
135
308
|
else {
|
|
136
309
|
throw new Error(`unsupported install source entry: ${from}`);
|
|
137
310
|
}
|
|
138
311
|
}
|
|
312
|
+
await revalidateRegularParentPath(sourceIdentity, 'install source tree');
|
|
313
|
+
await revalidateRegularParentPath(targetIdentity, 'install target tree');
|
|
314
|
+
}
|
|
315
|
+
async function moveRegularDirectory(source, target, sourceBoundary, targetBoundary, label, onMoved, identityOwner) {
|
|
316
|
+
const sourceParents = await captureRegularParentPath(sourceBoundary, source, `${label} source`);
|
|
317
|
+
const sourceStat = await fsp.lstat(source);
|
|
318
|
+
if (!sourceStat.isDirectory() || sourceStat.isSymbolicLink()) {
|
|
319
|
+
throw new Error(`${label} source must be a regular directory: ${source}`);
|
|
320
|
+
}
|
|
321
|
+
const targetParents = await captureRegularParentPath(targetBoundary, target, `${label} target`);
|
|
322
|
+
if (await exists(target))
|
|
323
|
+
throw new Error(`${label} target unexpectedly exists: ${target}`);
|
|
324
|
+
const recorded = identityOwner ? await captureRecordedEntry(source, 'directory', label) : undefined;
|
|
325
|
+
let identityTransferred = false;
|
|
326
|
+
try {
|
|
327
|
+
await fsp.rename(source, target);
|
|
328
|
+
if (!recorded)
|
|
329
|
+
onMoved?.();
|
|
330
|
+
if (recorded) {
|
|
331
|
+
recorded.path = target;
|
|
332
|
+
identityOwner?.push(recorded);
|
|
333
|
+
identityTransferred = true;
|
|
334
|
+
}
|
|
335
|
+
await revalidateRegularParentPath(sourceParents, `${label} source`);
|
|
336
|
+
await revalidateRegularParentPath(targetParents, `${label} target`);
|
|
337
|
+
const targetStat = await fsp.lstat(target);
|
|
338
|
+
if (!targetStat.isDirectory()
|
|
339
|
+
|| targetStat.isSymbolicLink()
|
|
340
|
+
|| targetStat.dev !== sourceStat.dev
|
|
341
|
+
|| targetStat.ino !== sourceStat.ino) {
|
|
342
|
+
throw new Error(`${label} directory changed identity while being moved: ${target}`);
|
|
343
|
+
}
|
|
344
|
+
return recorded;
|
|
345
|
+
}
|
|
346
|
+
catch (error) {
|
|
347
|
+
if (recorded && !identityTransferred)
|
|
348
|
+
await closeRecordedEntry(recorded);
|
|
349
|
+
throw error;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
async function moveRegularFile(source, target, sourceBoundary, targetBoundary, label, onMoved, identityOwner) {
|
|
353
|
+
const sourceParents = await captureRegularParentPath(sourceBoundary, source, `${label} source`);
|
|
354
|
+
const sourceStat = await fsp.lstat(source);
|
|
355
|
+
if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) {
|
|
356
|
+
throw new Error(`${label} source must be a regular file: ${source}`);
|
|
357
|
+
}
|
|
358
|
+
const targetParents = await captureRegularParentPath(targetBoundary, target, `${label} target`);
|
|
359
|
+
if (await exists(target))
|
|
360
|
+
throw new Error(`${label} target unexpectedly exists: ${target}`);
|
|
361
|
+
const recorded = identityOwner ? await captureRecordedEntry(source, 'file', label) : undefined;
|
|
362
|
+
let identityTransferred = false;
|
|
363
|
+
try {
|
|
364
|
+
await fsp.rename(source, target);
|
|
365
|
+
if (!recorded)
|
|
366
|
+
onMoved?.();
|
|
367
|
+
if (recorded) {
|
|
368
|
+
recorded.path = target;
|
|
369
|
+
identityOwner?.push(recorded);
|
|
370
|
+
identityTransferred = true;
|
|
371
|
+
}
|
|
372
|
+
await revalidateRegularParentPath(sourceParents, `${label} source`);
|
|
373
|
+
await revalidateRegularParentPath(targetParents, `${label} target`);
|
|
374
|
+
const targetStat = await fsp.lstat(target);
|
|
375
|
+
if (!targetStat.isFile()
|
|
376
|
+
|| targetStat.isSymbolicLink()
|
|
377
|
+
|| targetStat.dev !== sourceStat.dev
|
|
378
|
+
|| targetStat.ino !== sourceStat.ino) {
|
|
379
|
+
throw new Error(`${label} file changed identity while being moved: ${target}`);
|
|
380
|
+
}
|
|
381
|
+
return recorded;
|
|
382
|
+
}
|
|
383
|
+
catch (error) {
|
|
384
|
+
if (recorded && !identityTransferred)
|
|
385
|
+
await closeRecordedEntry(recorded);
|
|
386
|
+
throw error;
|
|
387
|
+
}
|
|
139
388
|
}
|
|
140
389
|
function manifestDigest(files) {
|
|
141
390
|
return digestManifestFiles([...files].sort((left, right) => compareStableText(left.path, right.path)));
|
|
@@ -282,7 +531,7 @@ async function readOwnershipReceipt(destination, packageName) {
|
|
|
282
531
|
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
283
532
|
return { exists: true, issues: [`install receipt must be a regular file: ${receiptPath}`] };
|
|
284
533
|
}
|
|
285
|
-
const parsed = JSON.parse(await
|
|
534
|
+
const parsed = JSON.parse((await readRegularFileBytes(receiptPath, destination)).toString('utf8'));
|
|
286
535
|
const issues = ownershipReceiptIssues(parsed, packageName, destination);
|
|
287
536
|
return issues.length
|
|
288
537
|
? { exists: true, issues }
|
|
@@ -306,7 +555,7 @@ async function verifyReceiptOwnedSkill(root, skill, receipt) {
|
|
|
306
555
|
if (!await exists(target))
|
|
307
556
|
return [`receipt-owned skill is missing: ${skill}`];
|
|
308
557
|
try {
|
|
309
|
-
const actual = await collectTreeManifest(target, skill);
|
|
558
|
+
const actual = await collectTreeManifest(target, skill, root);
|
|
310
559
|
return manifestIssues(expected, actual, `installed ${skill}`);
|
|
311
560
|
}
|
|
312
561
|
catch (error) {
|
|
@@ -382,36 +631,11 @@ async function preflightSafeUninstallOwnership(destination, packageName, skills)
|
|
|
382
631
|
}
|
|
383
632
|
return receipt;
|
|
384
633
|
}
|
|
385
|
-
async function readRegularFileBytes(file) {
|
|
386
|
-
const before = await fsp.lstat(file);
|
|
387
|
-
if (!before.isFile() || before.isSymbolicLink())
|
|
388
|
-
throw new Error(`expected a regular file: ${file}`);
|
|
389
|
-
const handle = await fsp.open(file, 'r');
|
|
390
|
-
try {
|
|
391
|
-
const opened = await handle.stat();
|
|
392
|
-
if (!opened.isFile())
|
|
393
|
-
throw new Error(`expected a regular file: ${file}`);
|
|
394
|
-
const bytes = await handle.readFile();
|
|
395
|
-
const after = await fsp.lstat(file);
|
|
396
|
-
if (!after.isFile()
|
|
397
|
-
|| after.isSymbolicLink()
|
|
398
|
-
|| before.dev !== opened.dev
|
|
399
|
-
|| before.ino !== opened.ino
|
|
400
|
-
|| after.dev !== opened.dev
|
|
401
|
-
|| after.ino !== opened.ino) {
|
|
402
|
-
throw new Error(`regular file changed identity while being captured: ${file}`);
|
|
403
|
-
}
|
|
404
|
-
return bytes;
|
|
405
|
-
}
|
|
406
|
-
finally {
|
|
407
|
-
await handle.close();
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
634
|
async function captureReceiptMutationSnapshot(destination, packageName) {
|
|
411
635
|
const receiptPath = path.join(destination, INSTALL_RECEIPT_NAME);
|
|
412
636
|
let bytes;
|
|
413
637
|
try {
|
|
414
|
-
bytes = await readRegularFileBytes(receiptPath);
|
|
638
|
+
bytes = await readRegularFileBytes(receiptPath, destination);
|
|
415
639
|
}
|
|
416
640
|
catch (error) {
|
|
417
641
|
if (error.code === 'ENOENT')
|
|
@@ -440,7 +664,7 @@ async function captureMutationSnapshot(destination, packageName, names, explicit
|
|
|
440
664
|
const receipt = await captureReceiptMutationSnapshot(destination, packageName);
|
|
441
665
|
const trees = [];
|
|
442
666
|
for (const name of names) {
|
|
443
|
-
const files = await collectTreeManifest(path.join(destination, name), name);
|
|
667
|
+
const files = await collectTreeManifest(path.join(destination, name), name, destination);
|
|
444
668
|
if (!explicitReplacements.includes(name)) {
|
|
445
669
|
if (!receipt.exists) {
|
|
446
670
|
throw new Error(`cannot capture receipt-owned skill without an install receipt: ${name}`);
|
|
@@ -468,7 +692,7 @@ async function verifyMovedMutationSnapshot(destination, snapshot, moved, receipt
|
|
|
468
692
|
continue;
|
|
469
693
|
}
|
|
470
694
|
try {
|
|
471
|
-
const actual = await collectTreeManifest(movedEntry.backup, tree.name);
|
|
695
|
+
const actual = await collectTreeManifest(movedEntry.backup, tree.name, path.dirname(movedEntry.backup));
|
|
472
696
|
issues.push(...manifestIssues(tree.files, actual, `moved backup ${tree.name}`));
|
|
473
697
|
}
|
|
474
698
|
catch (error) {
|
|
@@ -482,7 +706,7 @@ async function verifyMovedMutationSnapshot(destination, snapshot, moved, receipt
|
|
|
482
706
|
}
|
|
483
707
|
else if (snapshot.receipt.exists && receiptBackedUp) {
|
|
484
708
|
try {
|
|
485
|
-
const bytes = await readRegularFileBytes(receiptBackupPath);
|
|
709
|
+
const bytes = await readRegularFileBytes(receiptBackupPath, path.dirname(receiptBackupPath));
|
|
486
710
|
const sha256 = createHash('sha256').update(bytes).digest('hex');
|
|
487
711
|
if (sha256 !== snapshot.receipt.sha256 || !bytes.equals(snapshot.receipt.bytes)) {
|
|
488
712
|
issues.push('moved install receipt differs from the captured bytes');
|
|
@@ -556,7 +780,7 @@ export async function inspectSkillInstallation(options) {
|
|
|
556
780
|
continue;
|
|
557
781
|
}
|
|
558
782
|
try {
|
|
559
|
-
const installed = (await collectTreeManifest(target, skill));
|
|
783
|
+
const installed = (await collectTreeManifest(target, skill, destination));
|
|
560
784
|
const skillIssues = manifestIssues(expectedForSkill, installed, `installed ${skill}`);
|
|
561
785
|
skillStatuses.push({ skill, status: skillIssues.length ? 'drift' : 'ok', issues: skillIssues });
|
|
562
786
|
issues.push(...skillIssues);
|
|
@@ -574,7 +798,7 @@ export async function inspectSkillInstallation(options) {
|
|
|
574
798
|
}
|
|
575
799
|
else {
|
|
576
800
|
try {
|
|
577
|
-
const parsed = JSON.parse(await
|
|
801
|
+
const parsed = JSON.parse((await readRegularFileBytes(receiptPath, destination)).toString('utf8'));
|
|
578
802
|
const problems = receiptIssues(parsed, options, destination, expected);
|
|
579
803
|
if (problems.length) {
|
|
580
804
|
receiptStatus = 'drift';
|
|
@@ -604,6 +828,7 @@ export async function inspectSkillInstallation(options) {
|
|
|
604
828
|
}
|
|
605
829
|
async function acquireInstallLock(destination) {
|
|
606
830
|
const lockPath = path.join(destination, INSTALL_LOCK_NAME);
|
|
831
|
+
const destinationIdentity = await captureRegularDirectoryPath(destination, destination, 'skill destination');
|
|
607
832
|
let handle;
|
|
608
833
|
try {
|
|
609
834
|
handle = await fsp.open(lockPath, 'wx', 0o600);
|
|
@@ -614,25 +839,64 @@ async function acquireInstallLock(destination) {
|
|
|
614
839
|
}
|
|
615
840
|
throw error;
|
|
616
841
|
}
|
|
842
|
+
let lockIdentity;
|
|
617
843
|
try {
|
|
618
|
-
|
|
844
|
+
const lockContents = JSON.stringify({ pid: process.pid, started_at: new Date().toISOString() }, null, 2);
|
|
845
|
+
await handle.writeFile(lockContents);
|
|
846
|
+
const opened = await handle.stat({ bigint: true });
|
|
847
|
+
if (!opened.isFile())
|
|
848
|
+
throw new Error(`install lock must be a regular file: ${lockPath}`);
|
|
849
|
+
lockIdentity = await captureLockIdentity(lockPath, opened);
|
|
850
|
+
lockIdentity.contentsSha256 = createHash('sha256').update(lockContents).digest('hex');
|
|
851
|
+
await revalidateRegularParentPath(destinationIdentity, 'skill destination');
|
|
852
|
+
const current = await fsp.lstat(lockPath, { bigint: true });
|
|
853
|
+
if (current.isSymbolicLink()
|
|
854
|
+
|| !current.isFile()
|
|
855
|
+
|| current.dev !== lockIdentity.dev
|
|
856
|
+
|| current.ino !== lockIdentity.ino
|
|
857
|
+
|| (lockIdentity.birthtimeNs !== undefined && current.birthtimeNs !== lockIdentity.birthtimeNs)) {
|
|
858
|
+
throw new Error(`install lock changed identity while being acquired: ${lockPath}`);
|
|
859
|
+
}
|
|
619
860
|
}
|
|
620
861
|
catch (error) {
|
|
621
|
-
|
|
622
|
-
await
|
|
862
|
+
const cleanupErrors = [];
|
|
863
|
+
cleanupErrors.push(...await closeFileHandle(handle, lockPath));
|
|
864
|
+
try {
|
|
865
|
+
await revalidateRegularParentPath(destinationIdentity, 'skill destination');
|
|
866
|
+
if (!lockIdentity)
|
|
867
|
+
throw new Error(`install lock identity was not captured: ${lockPath}`);
|
|
868
|
+
await removeRecordedEntry(lockIdentity, 'install lock');
|
|
869
|
+
}
|
|
870
|
+
catch (cleanupError) {
|
|
871
|
+
cleanupErrors.push(message(cleanupError));
|
|
872
|
+
}
|
|
873
|
+
finally {
|
|
874
|
+
const closeErrors = lockIdentity
|
|
875
|
+
? await closeRecordedEntries([lockIdentity])
|
|
876
|
+
: [];
|
|
877
|
+
cleanupErrors.push(...closeErrors);
|
|
878
|
+
}
|
|
879
|
+
if (cleanupErrors.length) {
|
|
880
|
+
throw new Error(`${message(error)}. Install lock cleanup failed: ${cleanupErrors.join('; ')}`);
|
|
881
|
+
}
|
|
623
882
|
throw error;
|
|
624
883
|
}
|
|
625
|
-
return { handle, lockPath };
|
|
884
|
+
return { handle, lockPath, lockIdentity, destinationIdentity };
|
|
626
885
|
}
|
|
627
886
|
async function moveExistingEntries(root, names, backupRoot, moved) {
|
|
628
887
|
await fsp.mkdir(backupRoot, { recursive: true });
|
|
888
|
+
const backupBoundary = path.dirname(backupRoot);
|
|
629
889
|
for (const name of names) {
|
|
630
890
|
const original = path.join(root, name);
|
|
631
891
|
if (!await exists(original))
|
|
632
892
|
continue;
|
|
633
893
|
const backup = path.join(backupRoot, name);
|
|
634
|
-
await
|
|
635
|
-
|
|
894
|
+
await moveRegularDirectory(original, backup, root, backupBoundary, 'managed install backup', () => moved.push({
|
|
895
|
+
original,
|
|
896
|
+
backup,
|
|
897
|
+
originalBoundary: root,
|
|
898
|
+
backupBoundary
|
|
899
|
+
}));
|
|
636
900
|
}
|
|
637
901
|
}
|
|
638
902
|
async function restoreMovedEntries(entries) {
|
|
@@ -643,7 +907,7 @@ async function restoreMovedEntries(entries) {
|
|
|
643
907
|
throw new Error(`rollback target unexpectedly exists: ${entry.original}`);
|
|
644
908
|
}
|
|
645
909
|
await fsp.mkdir(path.dirname(entry.original), { recursive: true });
|
|
646
|
-
await
|
|
910
|
+
await moveRegularDirectory(entry.backup, entry.original, entry.backupBoundary, entry.originalBoundary, 'managed install rollback');
|
|
647
911
|
}
|
|
648
912
|
catch (error) {
|
|
649
913
|
errors.push(message(error));
|
|
@@ -655,14 +919,349 @@ async function removeInstalledEntries(entries) {
|
|
|
655
919
|
const errors = [];
|
|
656
920
|
for (const entry of [...entries].reverse()) {
|
|
657
921
|
try {
|
|
658
|
-
await
|
|
922
|
+
await removeRecordedEntry(entry, 'rollback removal');
|
|
659
923
|
}
|
|
660
924
|
catch (error) {
|
|
661
|
-
errors.push(`could not remove new install ${entry}: ${message(error)}`);
|
|
925
|
+
errors.push(`could not remove new install ${entry.path}: ${message(error)}`);
|
|
662
926
|
}
|
|
663
927
|
}
|
|
664
928
|
return errors;
|
|
665
929
|
}
|
|
930
|
+
async function captureRecordedEntry(target, kind, label, ownershipMarker) {
|
|
931
|
+
const noFollow = process.platform !== 'win32' && typeof fsConstants.O_NOFOLLOW === 'number'
|
|
932
|
+
? fsConstants.O_NOFOLLOW
|
|
933
|
+
: 0;
|
|
934
|
+
const directoryOnly = kind === 'directory'
|
|
935
|
+
&& process.platform !== 'win32'
|
|
936
|
+
&& typeof fsConstants.O_DIRECTORY === 'number'
|
|
937
|
+
? fsConstants.O_DIRECTORY
|
|
938
|
+
: 0;
|
|
939
|
+
let handle;
|
|
940
|
+
try {
|
|
941
|
+
if (process.platform !== 'win32') {
|
|
942
|
+
handle = await fsp.open(target, fsConstants.O_RDONLY | noFollow | directoryOnly);
|
|
943
|
+
}
|
|
944
|
+
if (handle)
|
|
945
|
+
return await captureRecordedEntryFromHandle(target, kind, label, handle);
|
|
946
|
+
const stat = await fsp.lstat(target, { bigint: true });
|
|
947
|
+
const typeMatches = kind === 'directory'
|
|
948
|
+
? stat.isDirectory() && !stat.isSymbolicLink()
|
|
949
|
+
: stat.isFile() && !stat.isSymbolicLink();
|
|
950
|
+
if (!typeMatches || stat.birthtimeNs <= 0n) {
|
|
951
|
+
throw new Error(`${label} must be one stable regular ${kind} with a positive birthtime: ${target}`);
|
|
952
|
+
}
|
|
953
|
+
const entry = {
|
|
954
|
+
path: target,
|
|
955
|
+
dev: stat.dev,
|
|
956
|
+
ino: stat.ino,
|
|
957
|
+
kind,
|
|
958
|
+
birthtimeNs: stat.birthtimeNs,
|
|
959
|
+
ownershipMarker
|
|
960
|
+
};
|
|
961
|
+
if (kind === 'file') {
|
|
962
|
+
const bytes = await readRegularFileBytes(target, path.dirname(target));
|
|
963
|
+
entry.contentsSha256 = createHash('sha256').update(bytes).digest('hex');
|
|
964
|
+
}
|
|
965
|
+
else if (!ownershipMarker) {
|
|
966
|
+
entry.treeDigest = manifestDigest(await collectTreeManifest(target));
|
|
967
|
+
}
|
|
968
|
+
return entry;
|
|
969
|
+
}
|
|
970
|
+
catch (error) {
|
|
971
|
+
if (handle)
|
|
972
|
+
await handle.close().catch(() => { });
|
|
973
|
+
throw error;
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
async function captureRecordedEntryFromHandle(target, kind, label, handle) {
|
|
977
|
+
const opened = await handle.stat({ bigint: true });
|
|
978
|
+
const current = await fsp.lstat(target, { bigint: true });
|
|
979
|
+
const typeMatches = kind === 'directory'
|
|
980
|
+
? opened.isDirectory() && current.isDirectory() && !current.isSymbolicLink()
|
|
981
|
+
: opened.isFile() && current.isFile() && !current.isSymbolicLink();
|
|
982
|
+
const birthtimeAvailable = opened.birthtimeNs > 0n && current.birthtimeNs > 0n;
|
|
983
|
+
if (!typeMatches
|
|
984
|
+
|| opened.dev !== current.dev
|
|
985
|
+
|| opened.ino !== current.ino
|
|
986
|
+
|| (birthtimeAvailable && opened.birthtimeNs !== current.birthtimeNs)) {
|
|
987
|
+
throw new Error(`${label} must be one stable regular ${kind}: ${target}`);
|
|
988
|
+
}
|
|
989
|
+
return {
|
|
990
|
+
path: target,
|
|
991
|
+
dev: opened.dev,
|
|
992
|
+
ino: opened.ino,
|
|
993
|
+
kind,
|
|
994
|
+
birthtimeNs: birthtimeAvailable ? opened.birthtimeNs : undefined,
|
|
995
|
+
handle
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
async function captureLockIdentity(target, created) {
|
|
999
|
+
const current = await fsp.lstat(target, { bigint: true });
|
|
1000
|
+
if (!created.isFile()
|
|
1001
|
+
|| current.isSymbolicLink()
|
|
1002
|
+
|| !current.isFile()
|
|
1003
|
+
|| (process.platform !== 'win32' && (created.dev !== current.dev
|
|
1004
|
+
|| created.ino !== current.ino))) {
|
|
1005
|
+
throw new Error(`install lock changed identity after exclusive creation: ${target}`);
|
|
1006
|
+
}
|
|
1007
|
+
let pinHandle;
|
|
1008
|
+
try {
|
|
1009
|
+
if (process.platform !== 'win32') {
|
|
1010
|
+
const noFollow = typeof fsConstants.O_NOFOLLOW === 'number' ? fsConstants.O_NOFOLLOW : 0;
|
|
1011
|
+
pinHandle = await fsp.open(target, fsConstants.O_RDONLY | noFollow);
|
|
1012
|
+
const pinned = await pinHandle.stat({ bigint: true });
|
|
1013
|
+
if (!pinned.isFile()
|
|
1014
|
+
|| pinned.dev !== created.dev
|
|
1015
|
+
|| pinned.ino !== created.ino) {
|
|
1016
|
+
throw new Error(`install lock pin descriptor does not match exclusive creation: ${target}`);
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
else {
|
|
1020
|
+
const confirmed = await confirmWindowsPathForHandle(target, created, current, 'install lock');
|
|
1021
|
+
return {
|
|
1022
|
+
path: target,
|
|
1023
|
+
dev: confirmed.dev,
|
|
1024
|
+
ino: confirmed.ino,
|
|
1025
|
+
kind: 'file',
|
|
1026
|
+
birthtimeNs: confirmed.birthtimeNs
|
|
1027
|
+
};
|
|
1028
|
+
}
|
|
1029
|
+
return {
|
|
1030
|
+
path: target,
|
|
1031
|
+
dev: created.dev,
|
|
1032
|
+
ino: created.ino,
|
|
1033
|
+
kind: 'file',
|
|
1034
|
+
handle: pinHandle
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
1037
|
+
catch (error) {
|
|
1038
|
+
if (pinHandle)
|
|
1039
|
+
await pinHandle.close().catch(() => { });
|
|
1040
|
+
throw error;
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
async function removeRecordedEntry(entry, label) {
|
|
1044
|
+
try {
|
|
1045
|
+
let current;
|
|
1046
|
+
try {
|
|
1047
|
+
current = await fsp.lstat(entry.path, { bigint: true });
|
|
1048
|
+
}
|
|
1049
|
+
catch (error) {
|
|
1050
|
+
if (error.code === 'ENOENT')
|
|
1051
|
+
return;
|
|
1052
|
+
throw error;
|
|
1053
|
+
}
|
|
1054
|
+
const opened = entry.handle ? await entry.handle.stat({ bigint: true }) : undefined;
|
|
1055
|
+
const typeMatches = entry.kind === 'directory'
|
|
1056
|
+
? current.isDirectory() && !current.isSymbolicLink()
|
|
1057
|
+
: current.isFile() && !current.isSymbolicLink();
|
|
1058
|
+
if (!typeMatches
|
|
1059
|
+
|| current.dev !== entry.dev
|
|
1060
|
+
|| current.ino !== entry.ino
|
|
1061
|
+
|| (entry.birthtimeNs !== undefined && current.birthtimeNs !== entry.birthtimeNs)
|
|
1062
|
+
|| (opened && (opened.dev !== current.dev
|
|
1063
|
+
|| opened.ino !== current.ino
|
|
1064
|
+
|| (entry.birthtimeNs !== undefined && opened.birthtimeNs !== current.birthtimeNs)))) {
|
|
1065
|
+
throw new Error(`${label} identity mismatch; preserved current path: ${entry.path}`);
|
|
1066
|
+
}
|
|
1067
|
+
if (entry.contentsSha256) {
|
|
1068
|
+
try {
|
|
1069
|
+
const bytes = await readRegularFileBytes(entry.path, path.dirname(entry.path));
|
|
1070
|
+
const currentSha256 = createHash('sha256').update(bytes).digest('hex');
|
|
1071
|
+
if (currentSha256 !== entry.contentsSha256)
|
|
1072
|
+
throw new Error('content digest changed');
|
|
1073
|
+
}
|
|
1074
|
+
catch (error) {
|
|
1075
|
+
throw new Error(`${label} identity mismatch (content: ${message(error)}); preserved current path: ${entry.path}`);
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
if (entry.treeDigest) {
|
|
1079
|
+
try {
|
|
1080
|
+
const currentTreeDigest = manifestDigest(await collectTreeManifest(entry.path));
|
|
1081
|
+
if (currentTreeDigest !== entry.treeDigest)
|
|
1082
|
+
throw new Error('tree digest changed');
|
|
1083
|
+
}
|
|
1084
|
+
catch (error) {
|
|
1085
|
+
throw new Error(`${label} identity mismatch (tree: ${message(error)}); preserved current path: ${entry.path}`);
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
if (entry.ownershipMarker) {
|
|
1089
|
+
try {
|
|
1090
|
+
const markerPath = path.join(entry.path, entry.ownershipMarker.name);
|
|
1091
|
+
const markerBytes = await readRegularFileBytes(markerPath, entry.path);
|
|
1092
|
+
const markerSha256 = createHash('sha256').update(markerBytes).digest('hex');
|
|
1093
|
+
if (markerSha256 !== entry.ownershipMarker.sha256)
|
|
1094
|
+
throw new Error('ownership marker digest changed');
|
|
1095
|
+
}
|
|
1096
|
+
catch (error) {
|
|
1097
|
+
throw new Error(`${label} identity mismatch (ownership marker: ${message(error)}); preserved current path: ${entry.path}`);
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
await fsp.rm(entry.path, { recursive: entry.kind === 'directory', force: entry.kind === 'file' });
|
|
1101
|
+
}
|
|
1102
|
+
finally {
|
|
1103
|
+
await closeRecordedEntry(entry);
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
async function captureTransactionRoot(target, label) {
|
|
1107
|
+
const markerContents = `${randomUUID()}\n`;
|
|
1108
|
+
const markerPath = path.join(target, TRANSACTION_OWNER_MARKER);
|
|
1109
|
+
await fsp.writeFile(markerPath, markerContents, { flag: 'wx', mode: 0o600 });
|
|
1110
|
+
return captureRecordedEntry(target, 'directory', label, {
|
|
1111
|
+
name: TRANSACTION_OWNER_MARKER,
|
|
1112
|
+
sha256: createHash('sha256').update(markerContents).digest('hex')
|
|
1113
|
+
});
|
|
1114
|
+
}
|
|
1115
|
+
async function closeRecordedEntry(entry) {
|
|
1116
|
+
const handle = entry.handle;
|
|
1117
|
+
entry.handle = undefined;
|
|
1118
|
+
if (handle)
|
|
1119
|
+
await handle.close();
|
|
1120
|
+
}
|
|
1121
|
+
async function closeRecordedEntries(entries) {
|
|
1122
|
+
const errors = [];
|
|
1123
|
+
for (const entry of entries) {
|
|
1124
|
+
if (!entry)
|
|
1125
|
+
continue;
|
|
1126
|
+
try {
|
|
1127
|
+
await closeRecordedEntry(entry);
|
|
1128
|
+
}
|
|
1129
|
+
catch (error) {
|
|
1130
|
+
errors.push(`${entry.path}: ${message(error)}`);
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
return errors;
|
|
1134
|
+
}
|
|
1135
|
+
async function closeFileHandle(handle, pathLabel) {
|
|
1136
|
+
try {
|
|
1137
|
+
await handle.close();
|
|
1138
|
+
return [];
|
|
1139
|
+
}
|
|
1140
|
+
catch (error) {
|
|
1141
|
+
return [`${pathLabel}: ${message(error)}`];
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
export async function planSkillSetInstallation(options) {
|
|
1145
|
+
const sourceRoot = path.resolve(options.sourceRoot);
|
|
1146
|
+
const destination = safeRoot(options.destination, 'skill destination');
|
|
1147
|
+
const codexMirror = options.codexMirror ? safeRoot(options.codexMirror, 'Codex mirror') : '';
|
|
1148
|
+
const skills = unique(options.skills);
|
|
1149
|
+
const cleanupCandidates = unique([...(options.legacySkills || []), ...(options.retiredSkills || [])]);
|
|
1150
|
+
const replaceSkills = unique(options.replaceSkills || []);
|
|
1151
|
+
validateManagedNames([...skills, ...cleanupCandidates, ...replaceSkills]);
|
|
1152
|
+
const expected = await expectedInstallManifest(sourceRoot, skills);
|
|
1153
|
+
const sourceDigest = manifestDigest(expected);
|
|
1154
|
+
const receiptPath = path.join(destination, INSTALL_RECEIPT_NAME);
|
|
1155
|
+
const operations = [];
|
|
1156
|
+
const blockers = await nonMutatingInstallGateBlockers(destination);
|
|
1157
|
+
let ownership;
|
|
1158
|
+
try {
|
|
1159
|
+
if (blockers.length)
|
|
1160
|
+
throw new Error(blockers[0]);
|
|
1161
|
+
ownership = await preflightInstallOwnership(destination, options.packageName, skills, cleanupCandidates, replaceSkills);
|
|
1162
|
+
}
|
|
1163
|
+
catch (error) {
|
|
1164
|
+
if (!blockers.length)
|
|
1165
|
+
blockers.push(message(error));
|
|
1166
|
+
}
|
|
1167
|
+
if (ownership) {
|
|
1168
|
+
const replaceNames = new Set(ownership.replaceNames);
|
|
1169
|
+
for (const skill of skills) {
|
|
1170
|
+
const target = path.join(destination, skill);
|
|
1171
|
+
const existing = await exists(target);
|
|
1172
|
+
operations.push({
|
|
1173
|
+
action: !existing
|
|
1174
|
+
? 'create'
|
|
1175
|
+
: replaceSkills.includes(skill)
|
|
1176
|
+
? 'replace_explicitly_authorized'
|
|
1177
|
+
: 'replace_managed',
|
|
1178
|
+
skill,
|
|
1179
|
+
path: target,
|
|
1180
|
+
reason: !existing
|
|
1181
|
+
? 'active skill is not installed'
|
|
1182
|
+
: replaceSkills.includes(skill)
|
|
1183
|
+
? 'operator supplied an explicit per-skill replacement override'
|
|
1184
|
+
: 'existing skill ownership and complete content integrity match the yam receipt'
|
|
1185
|
+
});
|
|
1186
|
+
}
|
|
1187
|
+
for (const skill of cleanupCandidates) {
|
|
1188
|
+
if (!replaceNames.has(skill) || skills.includes(skill))
|
|
1189
|
+
continue;
|
|
1190
|
+
operations.push({
|
|
1191
|
+
action: 'remove_retired',
|
|
1192
|
+
skill,
|
|
1193
|
+
path: path.join(destination, skill),
|
|
1194
|
+
reason: 'retired or legacy skill is still proven yam-owned by the current receipt'
|
|
1195
|
+
});
|
|
1196
|
+
}
|
|
1197
|
+
operations.push({
|
|
1198
|
+
action: 'write_receipt',
|
|
1199
|
+
skill: '',
|
|
1200
|
+
path: receiptPath,
|
|
1201
|
+
reason: ownership.receipt ? 'replace the verified yam ownership receipt' : 'create the yam ownership receipt'
|
|
1202
|
+
});
|
|
1203
|
+
}
|
|
1204
|
+
const preservedPaths = codexMirror && codexMirror !== destination ? [codexMirror] : [];
|
|
1205
|
+
const replaceNames = new Set(ownership?.replaceNames || []);
|
|
1206
|
+
for (const skill of cleanupCandidates) {
|
|
1207
|
+
if (replaceNames.has(skill) || !await exists(path.join(destination, skill)))
|
|
1208
|
+
continue;
|
|
1209
|
+
preservedPaths.push(path.join(destination, skill));
|
|
1210
|
+
}
|
|
1211
|
+
preservedPaths.sort(compareStableText);
|
|
1212
|
+
const planBasis = {
|
|
1213
|
+
package: { name: options.packageName, version: options.version },
|
|
1214
|
+
source: { root: sourceRoot, digest: sourceDigest, file_count: expected.length },
|
|
1215
|
+
destination,
|
|
1216
|
+
receipt_path: receiptPath,
|
|
1217
|
+
operations,
|
|
1218
|
+
preserved_paths: preservedPaths,
|
|
1219
|
+
blockers
|
|
1220
|
+
};
|
|
1221
|
+
const planDigest = createHash('sha256').update(JSON.stringify(planBasis)).digest('hex');
|
|
1222
|
+
const ready = blockers.length === 0;
|
|
1223
|
+
return {
|
|
1224
|
+
schema: 'yam.install-plan.v1',
|
|
1225
|
+
generated_at: (options.now || (() => new Date()))().toISOString(),
|
|
1226
|
+
mutation_authorized: false,
|
|
1227
|
+
...planBasis,
|
|
1228
|
+
ready,
|
|
1229
|
+
truth_status: ready ? 'verified' : 'blocked',
|
|
1230
|
+
plan_digest: planDigest,
|
|
1231
|
+
next_action: ready
|
|
1232
|
+
? 'review this non-mutating plan, then run `yam install` without --dry-run to authorize the transaction'
|
|
1233
|
+
: 'resolve the listed lock, recovery, destination, or ownership blocker without deleting unreviewed state, then regenerate the plan'
|
|
1234
|
+
};
|
|
1235
|
+
}
|
|
1236
|
+
async function nonMutatingInstallGateBlockers(destination) {
|
|
1237
|
+
let stat;
|
|
1238
|
+
try {
|
|
1239
|
+
stat = await fsp.lstat(destination);
|
|
1240
|
+
}
|
|
1241
|
+
catch (error) {
|
|
1242
|
+
if (error.code === 'ENOENT')
|
|
1243
|
+
return [];
|
|
1244
|
+
return [`skill destination cannot be inspected: ${message(error)}`];
|
|
1245
|
+
}
|
|
1246
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
1247
|
+
return [`skill destination must be a regular physical directory: ${destination}`];
|
|
1248
|
+
}
|
|
1249
|
+
const blockers = [];
|
|
1250
|
+
const lockPath = path.join(destination, INSTALL_LOCK_NAME);
|
|
1251
|
+
if (await exists(lockPath)) {
|
|
1252
|
+
blockers.push(`another yam install may be active; lock exists at ${lockPath}`);
|
|
1253
|
+
}
|
|
1254
|
+
try {
|
|
1255
|
+
const recoveryArtifacts = await findInstallRecoveryArtifacts(destination);
|
|
1256
|
+
if (recoveryArtifacts.length) {
|
|
1257
|
+
blockers.push(`unfinished yam install transaction requires inspection: ${recoveryArtifacts.join(', ')}`);
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
catch (error) {
|
|
1261
|
+
blockers.push(`install recovery state cannot be inspected: ${message(error)}`);
|
|
1262
|
+
}
|
|
1263
|
+
return blockers;
|
|
1264
|
+
}
|
|
666
1265
|
export async function installSkillSetTransactional(options) {
|
|
667
1266
|
const sourceRoot = path.resolve(options.sourceRoot);
|
|
668
1267
|
const destination = safeRoot(options.destination, 'skill destination');
|
|
@@ -678,6 +1277,7 @@ export async function installSkillSetTransactional(options) {
|
|
|
678
1277
|
let result;
|
|
679
1278
|
let primaryError;
|
|
680
1279
|
try {
|
|
1280
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
681
1281
|
const recoveryArtifacts = await findInstallRecoveryArtifacts(destination);
|
|
682
1282
|
if (recoveryArtifacts.length) {
|
|
683
1283
|
throw new Error(`unfinished yam install transaction found: ${recoveryArtifacts.join(', ')}. `
|
|
@@ -687,23 +1287,25 @@ export async function installSkillSetTransactional(options) {
|
|
|
687
1287
|
const expected = await expectedInstallManifest(sourceRoot, skills);
|
|
688
1288
|
const transactionId = options.transactionId || randomUUID();
|
|
689
1289
|
const transactionRoot = await fsp.mkdtemp(path.join(destination, '.yam-flow-install-'));
|
|
1290
|
+
const transactionEntry = await captureTransactionRoot(transactionRoot, 'install transaction root');
|
|
690
1291
|
const stagedRoot = path.join(transactionRoot, 'staged');
|
|
691
1292
|
const backupRoot = path.join(transactionRoot, 'backup');
|
|
692
1293
|
const stagedReceiptPath = path.join(transactionRoot, 'receipt.json');
|
|
693
1294
|
const destinationReceiptPath = path.join(destination, INSTALL_RECEIPT_NAME);
|
|
694
1295
|
const receiptBackupPath = path.join(transactionRoot, 'receipt.backup');
|
|
695
1296
|
const installedEntries = [];
|
|
1297
|
+
const receiptInstalledEntries = [];
|
|
696
1298
|
let destinationBackups = [];
|
|
697
1299
|
let receiptBackedUp = false;
|
|
698
|
-
let receiptInstalled
|
|
1300
|
+
let receiptInstalled;
|
|
699
1301
|
let mutationStarted = false;
|
|
700
1302
|
try {
|
|
701
1303
|
await fsp.mkdir(stagedRoot, { recursive: true });
|
|
702
1304
|
for (const skill of skills) {
|
|
703
1305
|
const stagedSkill = path.join(stagedRoot, skill);
|
|
704
1306
|
await fsp.mkdir(stagedSkill, { recursive: true });
|
|
705
|
-
await
|
|
706
|
-
await copyTree(path.join(sourceRoot, 'references'), path.join(stagedSkill, 'references'));
|
|
1307
|
+
await copyRegularFile(path.join(sourceRoot, 'skills', skill, 'SKILL.md'), path.join(stagedSkill, 'SKILL.md'), sourceRoot, stagedRoot);
|
|
1308
|
+
await copyTree(path.join(sourceRoot, 'references'), path.join(stagedSkill, 'references'), sourceRoot, stagedRoot);
|
|
707
1309
|
}
|
|
708
1310
|
const stagedManifest = await collectTreeManifest(stagedRoot);
|
|
709
1311
|
const stagedIssues = manifestIssues(expected, stagedManifest, 'staged install');
|
|
@@ -729,24 +1331,31 @@ export async function installSkillSetTransactional(options) {
|
|
|
729
1331
|
const mutationOwnership = await preflightInstallOwnership(destination, options.packageName, skills, cleanupCandidates, replaceSkills);
|
|
730
1332
|
const mutationSnapshot = await captureMutationSnapshot(destination, options.packageName, mutationOwnership.replaceNames, replaceSkills);
|
|
731
1333
|
await options.failpoint?.({ phase: 'before-mutation' });
|
|
1334
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
732
1335
|
mutationStarted = true;
|
|
733
1336
|
await moveExistingEntries(destination, mutationOwnership.replaceNames, backupRoot, destinationBackups);
|
|
734
1337
|
if (await exists(destinationReceiptPath)) {
|
|
735
|
-
await
|
|
736
|
-
receiptBackedUp = true;
|
|
1338
|
+
await moveRegularFile(destinationReceiptPath, receiptBackupPath, destination, transactionRoot, 'install receipt backup', () => { receiptBackedUp = true; });
|
|
737
1339
|
}
|
|
738
1340
|
await verifyMovedMutationSnapshot(destination, mutationSnapshot, destinationBackups, receiptBackedUp, receiptBackupPath);
|
|
739
1341
|
await options.failpoint?.({ phase: 'after-backup' });
|
|
1342
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
740
1343
|
for (const skill of skills) {
|
|
1344
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
741
1345
|
const target = path.join(destination, skill);
|
|
742
|
-
await
|
|
743
|
-
|
|
1346
|
+
const installedEntry = await moveRegularDirectory(path.join(stagedRoot, skill), target, stagedRoot, destination, 'staged skill install', undefined, installedEntries);
|
|
1347
|
+
if (!installedEntry)
|
|
1348
|
+
throw new Error(`staged skill install identity was not recorded: ${target}`);
|
|
744
1349
|
await options.failpoint?.({ phase: 'skill-installed', skill });
|
|
745
1350
|
}
|
|
746
|
-
await
|
|
747
|
-
receiptInstalled =
|
|
1351
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
1352
|
+
receiptInstalled = await moveRegularFile(stagedReceiptPath, destinationReceiptPath, transactionRoot, destination, 'staged install receipt', undefined, receiptInstalledEntries);
|
|
1353
|
+
if (!receiptInstalled) {
|
|
1354
|
+
throw new Error(`staged install receipt identity was not recorded: ${destinationReceiptPath}`);
|
|
1355
|
+
}
|
|
748
1356
|
await options.failpoint?.({ phase: 'receipt-installed' });
|
|
749
1357
|
await options.failpoint?.({ phase: 'before-verify' });
|
|
1358
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
750
1359
|
const inspection = await inspectSkillInstallation({
|
|
751
1360
|
sourceRoot,
|
|
752
1361
|
destination,
|
|
@@ -757,15 +1366,15 @@ export async function installSkillSetTransactional(options) {
|
|
|
757
1366
|
});
|
|
758
1367
|
if (!inspection.ok)
|
|
759
1368
|
throw new Error(`post-install verification failed: ${inspection.issues.join('; ')}`);
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
}
|
|
1369
|
+
const ownershipHandleErrors = await closeRecordedEntries([receiptInstalled, ...installedEntries]);
|
|
1370
|
+
if (ownershipHandleErrors.length) {
|
|
1371
|
+
cleanupWarnings.push(`verified install identity handle cleanup failed: ${ownershipHandleErrors.join('; ')}`);
|
|
1372
|
+
}
|
|
1373
|
+
try {
|
|
1374
|
+
await removeRecordedEntry(transactionEntry, 'install transaction cleanup');
|
|
1375
|
+
}
|
|
1376
|
+
catch (error) {
|
|
1377
|
+
cleanupWarnings.push(`verified install retained cleanup artifact ${transactionRoot}: ${message(error)}`);
|
|
769
1378
|
}
|
|
770
1379
|
result = {
|
|
771
1380
|
receipt,
|
|
@@ -777,16 +1386,25 @@ export async function installSkillSetTransactional(options) {
|
|
|
777
1386
|
}
|
|
778
1387
|
catch (error) {
|
|
779
1388
|
const rollbackErrors = [];
|
|
780
|
-
|
|
1389
|
+
receiptInstalled ||= receiptInstalledEntries[0];
|
|
1390
|
+
let destinationStillOwned = true;
|
|
1391
|
+
try {
|
|
1392
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
1393
|
+
}
|
|
1394
|
+
catch (identityError) {
|
|
1395
|
+
destinationStillOwned = false;
|
|
1396
|
+
rollbackErrors.push(`skill destination identity changed; path-based rollback was not attempted: ${message(identityError)}`);
|
|
1397
|
+
}
|
|
1398
|
+
if (mutationStarted && destinationStillOwned) {
|
|
781
1399
|
if (receiptInstalled)
|
|
782
|
-
rollbackErrors.push(...await removeInstalledEntries([
|
|
1400
|
+
rollbackErrors.push(...await removeInstalledEntries([receiptInstalled]));
|
|
783
1401
|
rollbackErrors.push(...await removeInstalledEntries(installedEntries));
|
|
784
1402
|
rollbackErrors.push(...await restoreMovedEntries(destinationBackups));
|
|
785
1403
|
if (receiptBackedUp) {
|
|
786
1404
|
try {
|
|
787
1405
|
if (await exists(destinationReceiptPath))
|
|
788
1406
|
throw new Error(`rollback target unexpectedly exists: ${destinationReceiptPath}`);
|
|
789
|
-
await
|
|
1407
|
+
await moveRegularFile(receiptBackupPath, destinationReceiptPath, transactionRoot, destination, 'install receipt rollback');
|
|
790
1408
|
}
|
|
791
1409
|
catch (receiptError) {
|
|
792
1410
|
rollbackErrors.push(message(receiptError));
|
|
@@ -795,15 +1413,11 @@ export async function installSkillSetTransactional(options) {
|
|
|
795
1413
|
}
|
|
796
1414
|
if (rollbackErrors.length === 0) {
|
|
797
1415
|
const cleanupErrors = [];
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
}
|
|
804
|
-
catch (cleanupError) {
|
|
805
|
-
cleanupErrors.push(`${cleanup}: ${message(cleanupError)}`);
|
|
806
|
-
}
|
|
1416
|
+
try {
|
|
1417
|
+
await removeRecordedEntry(transactionEntry, 'install transaction cleanup');
|
|
1418
|
+
}
|
|
1419
|
+
catch (cleanupError) {
|
|
1420
|
+
cleanupErrors.push(`${transactionRoot}: ${message(cleanupError)}`);
|
|
807
1421
|
}
|
|
808
1422
|
if (cleanupErrors.length) {
|
|
809
1423
|
throw new Error(`yam install failed; previous installation state restored, but transaction cleanup is incomplete: ${message(error)}. `
|
|
@@ -811,6 +1425,8 @@ export async function installSkillSetTransactional(options) {
|
|
|
811
1425
|
}
|
|
812
1426
|
throw new Error(`yam install failed; previous installation state restored: ${message(error)}`);
|
|
813
1427
|
}
|
|
1428
|
+
const retainedHandleErrors = await closeRecordedEntries([transactionEntry, receiptInstalled, ...installedEntries]);
|
|
1429
|
+
rollbackErrors.push(...retainedHandleErrors.map((item) => `identity handle cleanup failed: ${item}`));
|
|
814
1430
|
throw new Error(`yam install failed and rollback is incomplete: ${message(error)}. `
|
|
815
1431
|
+ `Recovery artifacts were kept at ${transactionRoot}. `
|
|
816
1432
|
+ `Rollback errors: ${rollbackErrors.join('; ')}`);
|
|
@@ -820,17 +1436,16 @@ export async function installSkillSetTransactional(options) {
|
|
|
820
1436
|
primaryError = error;
|
|
821
1437
|
}
|
|
822
1438
|
const lockCleanupErrors = [];
|
|
1439
|
+
lockCleanupErrors.push(...await closeFileHandle(lock.handle, lock.lockPath));
|
|
823
1440
|
try {
|
|
824
|
-
await lock.
|
|
1441
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
1442
|
+
await removeRecordedEntry(lock.lockIdentity, 'install lock cleanup');
|
|
825
1443
|
}
|
|
826
1444
|
catch (error) {
|
|
827
1445
|
lockCleanupErrors.push(message(error));
|
|
828
1446
|
}
|
|
829
|
-
|
|
830
|
-
await
|
|
831
|
-
}
|
|
832
|
-
catch (error) {
|
|
833
|
-
lockCleanupErrors.push(message(error));
|
|
1447
|
+
finally {
|
|
1448
|
+
lockCleanupErrors.push(...await closeRecordedEntries([lock.lockIdentity]));
|
|
834
1449
|
}
|
|
835
1450
|
if (lockCleanupErrors.length) {
|
|
836
1451
|
const warning = `install lock cleanup failed at ${lock.lockPath}: ${lockCleanupErrors.join('; ')}`;
|
|
@@ -860,6 +1475,7 @@ export async function uninstallSkillSetSafely(options) {
|
|
|
860
1475
|
let result;
|
|
861
1476
|
let primaryError;
|
|
862
1477
|
try {
|
|
1478
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
863
1479
|
const recoveryArtifacts = await findInstallRecoveryArtifacts(destination);
|
|
864
1480
|
if (recoveryArtifacts.length) {
|
|
865
1481
|
throw new Error(`unfinished yam install transaction found: ${recoveryArtifacts.join(', ')}. `
|
|
@@ -867,6 +1483,7 @@ export async function uninstallSkillSetSafely(options) {
|
|
|
867
1483
|
}
|
|
868
1484
|
await preflightSafeUninstallOwnership(destination, options.packageName, skills);
|
|
869
1485
|
const transactionRoot = await fsp.mkdtemp(path.join(destination, '.yam-flow-install-uninstall-'));
|
|
1486
|
+
const transactionEntry = await captureTransactionRoot(transactionRoot, 'uninstall transaction root');
|
|
870
1487
|
const backupRoot = path.join(transactionRoot, 'backup');
|
|
871
1488
|
const receiptPath = path.join(destination, INSTALL_RECEIPT_NAME);
|
|
872
1489
|
const receiptBackup = path.join(transactionRoot, INSTALL_RECEIPT_NAME);
|
|
@@ -877,13 +1494,15 @@ export async function uninstallSkillSetSafely(options) {
|
|
|
877
1494
|
await preflightSafeUninstallOwnership(destination, options.packageName, skills);
|
|
878
1495
|
const mutationSnapshot = await captureMutationSnapshot(destination, options.packageName, skills, []);
|
|
879
1496
|
await options.failpoint?.({ phase: 'before-mutation' });
|
|
1497
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
880
1498
|
mutationStarted = true;
|
|
881
1499
|
await moveExistingEntries(destination, skills, backupRoot, moved);
|
|
882
|
-
await
|
|
883
|
-
receiptMoved = true;
|
|
1500
|
+
await moveRegularFile(receiptPath, receiptBackup, destination, transactionRoot, 'uninstall receipt backup', () => { receiptMoved = true; });
|
|
884
1501
|
await verifyMovedMutationSnapshot(destination, mutationSnapshot, moved, receiptMoved, receiptBackup);
|
|
885
1502
|
await options.failpoint?.({ phase: 'after-backup' });
|
|
1503
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
886
1504
|
await options.failpoint?.({ phase: 'before-verify' });
|
|
1505
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
887
1506
|
const remaining = [];
|
|
888
1507
|
for (const skill of skills) {
|
|
889
1508
|
if (await exists(path.join(destination, skill)))
|
|
@@ -895,7 +1514,7 @@ export async function uninstallSkillSetSafely(options) {
|
|
|
895
1514
|
throw new Error(`post-uninstall verification found active entries: ${remaining.join(', ')}`);
|
|
896
1515
|
}
|
|
897
1516
|
try {
|
|
898
|
-
await
|
|
1517
|
+
await removeRecordedEntry(transactionEntry, 'uninstall transaction cleanup');
|
|
899
1518
|
}
|
|
900
1519
|
catch (error) {
|
|
901
1520
|
cleanupWarnings.push(`verified uninstall retained cleanup artifact ${transactionRoot}: ${message(error)}`);
|
|
@@ -908,12 +1527,20 @@ export async function uninstallSkillSetSafely(options) {
|
|
|
908
1527
|
}
|
|
909
1528
|
catch (error) {
|
|
910
1529
|
const rollbackErrors = [];
|
|
911
|
-
|
|
1530
|
+
let destinationStillOwned = true;
|
|
1531
|
+
try {
|
|
1532
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
1533
|
+
}
|
|
1534
|
+
catch (identityError) {
|
|
1535
|
+
destinationStillOwned = false;
|
|
1536
|
+
rollbackErrors.push(`skill destination identity changed; path-based rollback was not attempted: ${message(identityError)}`);
|
|
1537
|
+
}
|
|
1538
|
+
if (mutationStarted && destinationStillOwned) {
|
|
912
1539
|
if (receiptMoved) {
|
|
913
1540
|
try {
|
|
914
1541
|
if (await exists(receiptPath))
|
|
915
1542
|
throw new Error(`rollback target unexpectedly exists: ${receiptPath}`);
|
|
916
|
-
await
|
|
1543
|
+
await moveRegularFile(receiptBackup, receiptPath, transactionRoot, destination, 'uninstall receipt rollback');
|
|
917
1544
|
}
|
|
918
1545
|
catch (receiptError) {
|
|
919
1546
|
rollbackErrors.push(message(receiptError));
|
|
@@ -923,7 +1550,7 @@ export async function uninstallSkillSetSafely(options) {
|
|
|
923
1550
|
}
|
|
924
1551
|
if (rollbackErrors.length === 0) {
|
|
925
1552
|
try {
|
|
926
|
-
await
|
|
1553
|
+
await removeRecordedEntry(transactionEntry, 'uninstall transaction cleanup');
|
|
927
1554
|
}
|
|
928
1555
|
catch (cleanupError) {
|
|
929
1556
|
throw new Error(`yam uninstall failed; previous installation state restored, but transaction cleanup is incomplete: ${message(error)}. `
|
|
@@ -931,6 +1558,8 @@ export async function uninstallSkillSetSafely(options) {
|
|
|
931
1558
|
}
|
|
932
1559
|
throw new Error(`yam uninstall failed; previous installation state restored: ${message(error)}`);
|
|
933
1560
|
}
|
|
1561
|
+
const retainedHandleErrors = await closeRecordedEntries([transactionEntry]);
|
|
1562
|
+
rollbackErrors.push(...retainedHandleErrors.map((item) => `identity handle cleanup failed: ${item}`));
|
|
934
1563
|
throw new Error(`yam uninstall failed and rollback is incomplete: ${message(error)}. `
|
|
935
1564
|
+ `Recovery artifacts were kept at ${transactionRoot}. Rollback errors: ${rollbackErrors.join('; ')}`);
|
|
936
1565
|
}
|
|
@@ -939,17 +1568,16 @@ export async function uninstallSkillSetSafely(options) {
|
|
|
939
1568
|
primaryError = error;
|
|
940
1569
|
}
|
|
941
1570
|
const lockCleanupErrors = [];
|
|
1571
|
+
lockCleanupErrors.push(...await closeFileHandle(lock.handle, lock.lockPath));
|
|
942
1572
|
try {
|
|
943
|
-
await lock.
|
|
1573
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
1574
|
+
await removeRecordedEntry(lock.lockIdentity, 'install lock cleanup');
|
|
944
1575
|
}
|
|
945
1576
|
catch (error) {
|
|
946
1577
|
lockCleanupErrors.push(message(error));
|
|
947
1578
|
}
|
|
948
|
-
|
|
949
|
-
await
|
|
950
|
-
}
|
|
951
|
-
catch (error) {
|
|
952
|
-
lockCleanupErrors.push(message(error));
|
|
1579
|
+
finally {
|
|
1580
|
+
lockCleanupErrors.push(...await closeRecordedEntries([lock.lockIdentity]));
|
|
953
1581
|
}
|
|
954
1582
|
if (lockCleanupErrors.length) {
|
|
955
1583
|
const warning = `install lock cleanup failed at ${lock.lockPath}: ${lockCleanupErrors.join('; ')}`;
|