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